diff --git a/sdk/typescript/src/contract.ts b/sdk/typescript/src/contract.ts index 7f75b6c5d..805a8cbe1 100644 --- a/sdk/typescript/src/contract.ts +++ b/sdk/typescript/src/contract.ts @@ -19,6 +19,7 @@ import { requirePrivateOutputDirectory, requireSecureOutputAncestry, } from "./runtime.js"; +import { MODEL_UNSAFE_STRING } from "./string-safety.js"; import type { NormalizedTarget, ScanMode } from "./targets.js"; import { isWindowsUnsafePathComponent } from "./windows-path.js"; @@ -428,7 +429,11 @@ function validateCanonicalContract( const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]+)/.exec( remote, )?.[1]; - if (remote.includes("\\") || authority === undefined) { + if ( + MODEL_UNSAFE_STRING.test(remote) || + remote.includes("\\") || + authority === undefined + ) { throw new ContractValidationError( "scan.target.remote: expected a sanitized canonical absolute URL.", ); diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index ae691b3de..29aace78c 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -63,6 +63,7 @@ import { isWindowsUnsafePathComponent, windowsUnsafePathComponent, } from "./windows-path.js"; +import { MODEL_UNSAFE_STRING } from "./string-safety.js"; const execFile = promisify(execFileCallback); @@ -73,7 +74,6 @@ const MAX_ZIP_ENTRIES = 4_096; const MAX_ZIP_CENTRAL_DIRECTORY = 16 * 1024 * 1024; const MAX_ZIP_ENTRY_SIZE = 128 * 1024 * 1024; const MAX_ZIP_EXPANDED_SIZE = 512 * 1024 * 1024; -const MODEL_UNSAFE_PATH = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u; const CREDENTIAL_LOCK_NAME = ".codex-security-scan.lock"; const CREDENTIAL_LOCK_DATABASE = ".codex-security-scan.sqlite3"; const CREDENTIAL_LOGOUT_MARKER = ".codex-security-logged-out"; @@ -1828,7 +1828,7 @@ export async function planOutputArchive( } export function requireModelSafeOutputDir(path: string): void { - if (MODEL_UNSAFE_PATH.test(path)) { + if (MODEL_UNSAFE_STRING.test(path)) { throw new OutputDirectoryError( "Scan output directory must not contain control or line-separator characters.", ); diff --git a/sdk/typescript/src/string-safety.ts b/sdk/typescript/src/string-safety.ts new file mode 100644 index 000000000..ffae259d7 --- /dev/null +++ b/sdk/typescript/src/string-safety.ts @@ -0,0 +1 @@ +export const MODEL_UNSAFE_STRING = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u; diff --git a/sdk/typescript/tests-ts/contract.test.ts b/sdk/typescript/tests-ts/contract.test.ts index 515df46ab..56349cb6a 100644 --- a/sdk/typescript/tests-ts/contract.test.ts +++ b/sdk/typescript/tests-ts/contract.test.ts @@ -988,6 +988,30 @@ describe("canonical scan contract", () => { }, "scan.target.remote", ], + [ + "tab in remote", + (manifest) => { + manifest["scan"]["target"]["remote"] = + "https://example.com\thttps://evil.example.net"; + }, + "scan.target.remote", + ], + [ + "line feed in remote", + (manifest) => { + manifest["scan"]["target"]["remote"] = + "https://example.com\nhttps://evil.example.net"; + }, + "scan.target.remote", + ], + [ + "carriage return in remote", + (manifest) => { + manifest["scan"]["target"]["remote"] = + "https://example.com\rhttps://evil.example.net"; + }, + "scan.target.remote", + ], ]; for (const [_name, mutate, expected] of cases) {