Skip to content

fix(contract): reject control characters in the scan target remote - #233

Open
rohanpoudel2 wants to merge 2 commits into
openai:mainfrom
rohanpoudel2:fix/contract-remote-control
Open

fix(contract): reject control characters in the scan target remote#233
rohanpoudel2 wants to merge 2 commits into
openai:mainfrom
rohanpoudel2:fix/contract-remote-control

Conversation

@rohanpoudel2

Copy link
Copy Markdown

Fixes #231

Problem

validateCanonicalContract validated scan.target.remote against a string that was not the string it stored.

The authority is extracted with:

const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]+)/.exec(remote)?.[1];

[^/?#] matches ASCII tab, LF and CR. The follow-up new URL(remote) check does not catch them either, because the WHATWG URL parser strips exactly those three characters from its input before parsing. So a manifest carrying:

"remote": "https://example.com\nhttps://evil.example.net"

loaded successfully — new URL() saw host example.comhttps, while loadContract handed the original two-line string straight back to callers.

Neither surrounding guard closes it: the schema pattern ^(?![^:/?#]+://[^/?#]*@)[^?#]+$ also matches newlines, and validateParsedJson checks only for well-formed Unicode.

Change

Reject C0/C1 control characters and Unicode line separators in remote before the regex and URL checks run, mirroring the class requireModelSafeOutputDir already applies in runtime.ts for the same reason. That constant is not exported, so it is mirrored locally with a comment naming the source of truth rather than widening runtime.ts's public surface for one caller.

Why not also require href === remote

I tested this and rejected it. The WHATWG serializer legitimately rewrites valid URLs — it strips a default :443, lowercases mixed-case hostnames, and punycode-encodes internationalized ones — so exact canonical equality would reject real remotes. The bundled producer (finalize_scan_contract.py) validates remote only via urlsplit scheme and netloc checks and imposes no canonical-equality requirement, so it can legitimately emit any of those forms.

That trade would only be worth it if the URL parser smuggled a wider set of characters. It does not. Enumerating every C0/C1 code point through new URL("https://exa<c>mple.com"):

SURVIVED new URL(): 09->example.com 0a->example.com 0d->example.com
threw count: 62

Only tab, LF and CR slip through; the other 62 already make new URL() throw. The narrow control-character rejection is therefore sufficient, and the broader C0/C1 class is defence in depth consistent with runtime.ts.

Impact, stated plainly

This closes a broken guarantee rather than a live exploit. Nothing under src/ consumes .remote today, and credential smuggling remains correctly blocked — any @ in the authority is still rejected before URL parsing, and that check is untouched. The value is that the contract layer is precisely where an untrusted manifest field is meant to be sanitized, and SDK consumers that render, log or re-emit manifest.scan.target.remote were inheriting a multi-line value that had passed validation.

Verification

Three rejection cases (\n, \r, \t) were added to the existing table-driven "rejects schema-valid but canonically invalid contract data" test, plus a standalone test proving an ordinary https://github.com/example/repo remote still loads and round-trips unchanged — so the fix cannot pass by rejecting everything.

Against the unfixed source the new case fails with Expected promise that rejects / Received promise that resolved; with the fix, contract.test.ts is 30 pass / 0 fail.

Full suite: 718 pass / 5 skip / 0 fail (717 baseline plus the new test). pnpm run types and pnpm run format are clean.

`validateCanonicalContract` extracted the URL authority with a regex whose
negated class `[^/?#]` matches ASCII tab, LF and CR, and the follow-up
`new URL(remote)` check did not catch them either: the WHATWG URL parser
strips exactly those three characters from its input before parsing. The
value that survived validation was therefore not the value that was
validated, and the unsanitized original was handed back to callers as
`manifest.scan.target.remote`.

A manifest carrying "https://example.com\nhttps://evil.example.net" loaded
successfully while `new URL()` saw host "example.comhttps".

Reject C0/C1 control characters and Unicode line separators in `remote`
before parsing it, mirroring the class `requireModelSafeOutputDir` already
applies in runtime.ts for the same reason.

Nothing under src/ consumes `remote` today, so this closes a broken
guarantee rather than a live exploit: SDK consumers that render or re-emit
the field were inheriting a multi-line value that had passed validation.

Fixes openai#231
@github-actions github-actions Bot added the bug Something isn't working label Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scan.target.remote accepts embedded tab/newline/CR because new URL() strips them before validating

1 participant