Skip to content

Commit 1113d8c

Browse files
committed
🔒 security(command): sanitize trigger env values
1 parent 54d93a3 commit 1113d8c

2 files changed

Lines changed: 96 additions & 2 deletions

File tree

‎app/triggers/providers/command/Command.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,59 @@ test('runCommand should use execFile with shell and -c arguments', async () => {
194194
expect(childProcessMockControl.execCalls).toBe(0);
195195
});
196196

197+
test('trigger should sanitize shell metacharacters from container-derived env vars', async () => {
198+
const cmd = new Command();
199+
await cmd.register('trigger', 'command', 'test', { cmd: 'echo test' });
200+
201+
let capturedEnv: Record<string, string | undefined> | undefined;
202+
childProcessMockControl.execFileImpl = (
203+
_file: unknown,
204+
_args: unknown,
205+
options: unknown,
206+
callback: (...callbackArgs: unknown[]) => void,
207+
) => {
208+
capturedEnv = (options as { env?: Record<string, string | undefined> }).env;
209+
setImmediate(() => callback(null, '', ''));
210+
return { pid: 2 };
211+
};
212+
213+
await cmd.trigger({
214+
id: '123',
215+
name: 'test',
216+
watcher: 'local',
217+
image: {
218+
id: 'image-123',
219+
registry: { name: 'hub', url: 'https://registry.example.test' },
220+
name: 'library/nginx',
221+
tag: {
222+
value: '1.0.0$(touch /tmp/drydock-pwn)`id`',
223+
semver: false,
224+
},
225+
digest: { watch: false },
226+
architecture: 'amd64',
227+
os: 'linux',
228+
},
229+
result: {
230+
tag: '2.0.0; touch /tmp/drydock-pwn',
231+
},
232+
updateAvailable: true,
233+
updateKind: {
234+
kind: 'tag',
235+
localValue: '1.0.0$(touch /tmp/drydock-pwn)',
236+
remoteValue: '2.0.0`id`',
237+
},
238+
});
239+
240+
const shellMetacharacters = /[$`;()]/u;
241+
expect(capturedEnv?.image_tag_value).not.toMatch(shellMetacharacters);
242+
expect(capturedEnv?.result_tag).not.toMatch(shellMetacharacters);
243+
expect(capturedEnv?.update_kind_local_value).not.toMatch(shellMetacharacters);
244+
expect(capturedEnv?.update_kind_remote_value).not.toMatch(shellMetacharacters);
245+
expect(capturedEnv?.container_json).not.toContain('$(touch /tmp/drydock-pwn)');
246+
expect(capturedEnv?.container_json).not.toContain('; touch /tmp/drydock-pwn');
247+
expect(capturedEnv?.container_json).not.toContain('`id`');
248+
});
249+
197250
test('triggerBatch should pass dd_title env var when runtimeContext.title is set', async () => {
198251
const cmd = new Command();
199252
await cmd.register('trigger', 'command', 'test', { cmd: 'echo batch' });

‎app/triggers/providers/command/Command.ts‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,53 @@ import Trigger, { type BatchRuntimeContext, type TriggerConfiguration } from '..
55

66
let hasLoggedShellExecutionWarning = false;
77

8+
const SHELL_UNSAFE_ENV_CHARACTERS = new Set(['`', '$', ';', '&', '|', '<', '>', '(', ')']);
9+
const DELETE_CONTROL_CODE_POINT = 0x7f;
10+
811
interface CommandConfiguration extends TriggerConfiguration {
912
cmd: string;
1013
shell: string;
1114
timeout: number;
1215
}
1316

17+
function sanitizeCommandEnvString(value: string) {
18+
return Array.from(value)
19+
.map((character) => {
20+
const codePoint = character.codePointAt(0);
21+
if (
22+
codePoint === undefined ||
23+
codePoint < 0x20 ||
24+
codePoint === DELETE_CONTROL_CODE_POINT ||
25+
SHELL_UNSAFE_ENV_CHARACTERS.has(character)
26+
) {
27+
return '_';
28+
}
29+
return character;
30+
})
31+
.join('');
32+
}
33+
34+
function toCommandEnvValue(value: unknown) {
35+
if (value === undefined) {
36+
return undefined;
37+
}
38+
if (value === null) {
39+
return '';
40+
}
41+
if (typeof value === 'string') {
42+
return sanitizeCommandEnvString(value);
43+
}
44+
return sanitizeCommandEnvString(String(value));
45+
}
46+
47+
function sanitizeCommandEnvVars(extraEnvVars: Record<string, unknown>) {
48+
const sanitizedEnvVars: Record<string, string | undefined> = {};
49+
for (const [key, value] of Object.entries(extraEnvVars)) {
50+
sanitizedEnvVars[key] = toCommandEnvValue(value);
51+
}
52+
return sanitizedEnvVars;
53+
}
54+
1455
export function resetShellExecutionWarningStateForTests() {
1556
hasLoggedShellExecutionWarning = false;
1657
}
@@ -73,13 +114,13 @@ class Command extends Trigger<CommandConfiguration> {
73114
* Run the command.
74115
* @param {*} extraEnvVars
75116
*/
76-
async runCommand(extraEnvVars) {
117+
async runCommand(extraEnvVars: Record<string, unknown>) {
77118
this.logShellExecutionWarningOnce();
78119

79120
const commandOptions = {
80121
env: {
81122
...process.env,
82-
...extraEnvVars,
123+
...sanitizeCommandEnvVars(extraEnvVars),
83124
},
84125
timeout: this.configuration.timeout,
85126
};

0 commit comments

Comments
 (0)