Tests pass shell paths through the environment, not into bash -c scripts.

GitHub CodeQL code scanning alerts #549 and #550
(js/shell-command-injection-from-environment): tests/snapSettingsKeys and
tests/snapSocketDenied ran `bash -c` with a script that interpolated an
absolute path built from __dirname (`source "${configPath}"`,
`source "${helper}"`). A checkout path containing a quote, `$(` or a
backtick would change the command that runs. tests/linuxPackageManagers
and tests/snapCaddyNofile6552 had the same shape without an alert yet.

All four now pass the path (and the soft limit, and the wekan-control
function under test) in environment variables, so every script text is
constant. tests/testShellPathsFromEnv.test.cjs fails on any test that
sources an interpolated value, and checks it catches the flagged forms.
These are test files run on a developer machine or CI, so no attack can be
attempted against a running WeKan and nothing is logged in Admin Panel ->
Problems.

Thanks to GitHub CodeQL and xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-10-08 00:16:24 +03:00
1 parent 5704d598da
commit 433e58a618
5 files changed
+47 -14

No files matched your search

+4 -4
View File
@@ -39,8 +39,8 @@ for (const [name, contents] of Object.entries(fixtures)) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-os-release-'));
const file = path.join(dir, 'os-release');
fs.writeFileSync(file, contents);
const result = spawnSync('bash', ['-c', `. "${helper}"; _et_linux_family`], {
encoding: 'utf8', env: { ...process.env, WEKAN_OS_RELEASE_FILE: file },
const result = spawnSync('bash', ['-c', '. "$ET_HELPER"; _et_linux_family'], {
encoding: 'utf8', env: { ...process.env, ET_HELPER: helper, WEKAN_OS_RELEASE_FILE: file },
});
fs.rmSync(dir, { recursive: true, force: true });
assert.strictEqual(result.status, 0, result.stderr);
@@ -50,8 +50,8 @@ for (const [name, contents] of Object.entries(fixtures)) {
}
test('macOS is detected without relying on the invoking shell', () => {
const result = spawnSync('bash', ['-c', `. "${helper}"; _et_os`], {
encoding: 'utf8', env: { ...process.env, WEKAN_UNAME_S: 'Darwin' },
const result = spawnSync('bash', ['-c', '. "$ET_HELPER"; _et_os'], {
encoding: 'utf8', env: { ...process.env, ET_HELPER: helper, WEKAN_UNAME_S: 'Darwin' },
});
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout.trim(), 'macos');
+2 -2
View File
@@ -9,8 +9,8 @@ const { spawnSync } = require('node:child_process');
const ROOT = path.join(__dirname, '..');
const helper = path.join(ROOT, 'snap-src/bin/raise-nofile-limit');
const run = (soft, env = {}) => {
const result = spawnSync('bash', ['-c', `ulimit -Sn ${soft} && source "${helper}" && raise_nofile_limit >/dev/null 2>&1; ulimit -Sn; ulimit -Hn`],
{ env: { PATH: process.env.PATH, ...env }, encoding: 'utf8' });
const result = spawnSync('bash', ['-c', 'ulimit -Sn "$SOFT_LIMIT" && source "$NOFILE_HELPER" && raise_nofile_limit >/dev/null 2>&1; ulimit -Sn; ulimit -Hn'],
{ env: { PATH: process.env.PATH, SOFT_LIMIT: String(soft), NOFILE_HELPER: helper, ...env }, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
const [after, hard] = result.stdout.trim().split('\n');
return { after, hard };
+4 -2
View File
@@ -28,12 +28,14 @@ function test(name, fn) {
}
function readConfig(configPath) {
// The path goes in through the environment, never into the script text
// (CodeQL js/shell-command-injection-from-environment).
const r = spawnSync('bash', ['-c', `
source "${configPath}"
source "$SNAP_CONFIG_PATH"
for k in $keys; do
kv="KEY_$k"; dv="DEFAULT_$k"; sv="DESCRIPTION_$k"
printf '%s\\t%s\\t%s\\t%s\\n' "$k" "\${!kv}" "\${!dv+set}" "\${!sv+set}"
done`], { encoding: 'utf8' });
done`], { encoding: 'utf8', env: { ...process.env, SNAP_CONFIG_PATH: configPath } });
assert.strictEqual(r.status, 0, r.stderr);
return r.stdout.trim().split('\n').map((line) => {
const [name, key, hasDefault, hasDescription] = line.split('\t');
+8 -6
View File
@@ -36,12 +36,14 @@ function test(name, fn) {
function bash(script, env = {}) {
return spawnSync('bash', ['-c', script], {
encoding: 'utf8',
env: { ...process.env, SNAP_INSTANCE_NAME: 'wekan', ...env },
// The helper's path goes in through the environment, never into the
// script text (CodeQL js/shell-command-injection-from-environment).
env: { ...process.env, SNAP_INSTANCE_NAME: 'wekan', SOCKET_DENIED_HELPER: helper, ...env },
});
}
function denied(err) {
return bash(`source "${helper}"; socket_denied "$ERR"`, { ERR: err }).status === 0;
return bash(`source "$SOCKET_DENIED_HELPER"; socket_denied "$ERR"`, { ERR: err }).status === 0;
}
// The exact errors the reporter posted: mongorestore (Go driver) and db-eval
@@ -69,7 +71,7 @@ test('negative: a database that is merely not up is not called an AppArmor probl
});
test('the explanation names the denial, the upstream bug, and both ways out', () => {
const r = bash(`source "${helper}"; socket_denied_hint`);
const r = bash(`source "$SOCKET_DENIED_HELPER"; socket_denied_hint`);
assert.strictEqual(r.status, 0, r.stderr);
assert.match(r.stdout, /journalctl -k \| grep -i denied/);
assert.match(r.stdout, /operation="file_perm" class="net"/);
@@ -80,7 +82,7 @@ test('the explanation names the denial, the upstream bug, and both ways out', ()
assert.match(r.stdout, /sudo snap revert snapd/);
assert.match(r.stdout, /sudo snap restart wekan/);
// A parallel install (wekan_2) is told to restart ITSELF.
const r2 = bash(`source "${helper}"; socket_denied_hint`, { SNAP_INSTANCE_NAME: 'wekan_2' });
const r2 = bash(`source "$SOCKET_DENIED_HELPER"; socket_denied_hint`, { SNAP_INSTANCE_NAME: 'wekan_2' });
assert.match(r2.stdout, /sudo snap restart wekan_2/);
});
@@ -112,8 +114,8 @@ test('show_socket_denied prints only for a refusal, and says so in the log', ()
const start = control.indexOf('show_socket_denied() {');
const fn = control.slice(start, control.indexOf('\n}\n', start) + 3);
const run = (err) => bash(
`source "${helper}"; MONGO_HOST=127.0.0.1; MONGO_PORT=27019\n${fn}\nshow_socket_denied FerretDB "$ERR"`,
{ ERR: err },
'source "$SOCKET_DENIED_HELPER"; MONGO_HOST=127.0.0.1; MONGO_PORT=27019; eval "$SHOW_FN"; show_socket_denied FerretDB "$ERR"',
{ ERR: err, SHOW_FN: fn },
);
const yes = run(REPORTED[0]);
assert.strictEqual(yes.status, 0);
+29
View File
@@ -0,0 +1,29 @@
'use strict';
// CodeQL js/shell-command-injection-from-environment (alerts #549, #550): tests
// ran `bash -c` with a script that interpolated an absolute path built from
// __dirname, e.g. `source "${helper}"`. A checkout path with a quote, `$(` or a
// backtick would change the command. The path goes in through the environment
// instead (`source "$HELPER"`), so the script text is constant.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
// A shell `source`/`.` of an interpolated JavaScript value inside a template.
const SHAPE = /(?:^|[\s;&|(`'"])(?:source|\.)\s+"?\$\{[^}]+\}/m;
const offenders = fs.readdirSync(path.join(root, 'tests'))
.filter(name => /\.(c?js|mjs)$/.test(name) && name !== path.basename(__filename))
.filter(name => SHAPE.test(fs.readFileSync(path.join(root, 'tests', name), 'utf8')))
.sort();
assert.deepEqual(offenders, [], 'pass the path in an environment variable: source "$HELPER"');
// Negative: the guard does catch the shapes that were flagged.
assert.ok(SHAPE.test('spawnSync(\'bash\', [\'-c\', `source "${helper}"; socket_denied`])'));
assert.ok(SHAPE.test('spawnSync(\'bash\', [\'-c\', `. "${helper}"; _et_os`])'));
assert.ok(SHAPE.test('`\n source "${configPath}"\n`'));
// ...and not the fixed form, nor a JavaScript template that merely mentions a dot.
assert.ok(!SHAPE.test('[\'-c\', \'source "$SOCKET_DENIED_HELPER"; socket_denied\']'));
assert.ok(!SHAPE.test('`${name}. ${detail}`'));
console.log('testShellPathsFromEnv: no test sources an interpolated path');