Repository navigation
fix: reject dot-segment path parameters; disable Speakeasy persistent edits - #1372
mattapperson wants to merge 4 commits into
Conversation
e1b6c79 to
3f1d10b
Compare
Addresses review feedback on #1372. With persistent edits off, the OPENROUTER_BASE_URL fallback lives in an SDKInitHook, so only client construction honors it. The generated serverURLFromOptions() helper reads only the options it is given. Keeping the env fallback in that helper would need a hand edit to generated config.ts, which a regen would now silently overwrite. Document this in the v2 migration notes, explain it in the hook's comment, and add a test that pins the helper's behavior.
Set persistentEdits.enabled to "never" so generated files are plain generator output and Speakeasy does not prompt to turn the feature back on. Every Speakeasy-tracked file was compared against its pristine blob in gen.lock. Five carried hand edits; each is handled without persistent edits: - src/lib/config.ts, src/lib/env.ts: OPENROUTER_BASE_URL support (#832) moves to an SDKInitHook (src/hooks/base-url-env.ts) registered from the hand-written src/hooks/registration.ts. Precedence is unchanged (serverURL > server > OPENROUTER_BASE_URL > production) and an invalid value still throws a ZodError at client construction. - RUNTIMES.md (Node v22 note) and .devcontainer/devcontainer.json (Node 24 image): added to .genignore. - tsconfig.json: a whitespace-only line, back to the generated form. The exported serverURLFromOptions() helper is generated code and no longer reads OPENROUTER_BASE_URL when called directly; clients built with new OpenRouter() are unaffected. A unit test pins this.
Output of the pinned Speakeasy 1.803.2 with persistent edits off, generated from the same spec revision as the current generated code. Removes the `@generated-id` header line from 1,392 generated files and drops the matching tracking data from gen.lock. No other source changes. The SDK version is left for the release workflow to bump.
Left over from the callModel removal (#1356); nothing imports it.
fb77bcd to
6c531cc
Compare
…hange the route Generated operations percent-encode path parameters, which leaves "." and ".." unchanged, and ClientSDK._createRequest resolves the path with new URL(path, baseURL), which collapses dot segments. A path parameter of exactly ".." or "." sent the request, with the client's API key, to a different route. pathToFunc now throws before any request is built. src/lib/url.ts is added to .genignore and hand-maintained, because persistent edits are off and neither a hook, an overlay nor gen.yaml can express the check. gen.lock drops the file's tracking entry, as the generator does for ignored files. Supersedes #1390.
| // Covers what the per-parameter check cannot name: a dot segment made | ||
| // of several parameters, or a value that carries its own "/". | ||
| if (path.split("/").some(isDotSegment)) { | ||
| throw new Error( | ||
| `Path '${pathPattern}' cannot contain a '.' or '..' segment`, | ||
| ); | ||
| } | ||
|
|
||
| return path; |
There was a problem hiding this comment.
P3: this backstop also rejects base URLs that main accepts.
serverURLFromOptions passes the caller's serverURL through pathToFunc(serverURL) (src/lib/config.ts:70), so this check runs on the base URL too. On this branch:
new OpenRouter({ serverURL }) |
main |
this PR |
|---|---|---|
https://x.example.com/a/../api/v1 |
https://x.example.com/api/v1/ |
throws Path '…' cannot contain a '.' or '..' segment |
https://x.example.com/./api/v1 |
https://x.example.com/api/v1/ |
throws |
OPENROUTER_BASE_URL behaves the same way. It's unlikely input, but in a patch release it shows up as a constructor throw, and it contradicts "the only inputs that behave differently are ones that were already being misrouted".
The backstop can't trigger for generated operations:
- All 103 path parameters go through
encodeSimple(..., { charEncoding: "percent" })first, so a value can never carry a raw/. - In all 55 path templates, each parameter fills a whole segment. None has
{a}{b}.
Suggested fix: delete the backstop and return pathPattern.replace(...).replace(/^\/+/, "") directly. Also drop the rejects a dot segment assembled from several parameters test. I tried this locally: the other 27 dot-segment tests still pass, .. still throws with the parameter named, %2e%2e is still sent as %252e%252e, and the base URLs above normalise as they do on main. The generator contract tests already catch the generator changing the call shape.
If you'd rather keep a backstop, have it skip dot segments already in the template:
const templateHasDotSegment = pathPattern.split("/").some(isDotSegment);
if (!templateHasDotSegment && path.split("/").some(isDotSegment)) { ... }
christineschen
left a comment
There was a problem hiding this comment.
one small nit but otherwise lgtm
Summary
Two things, both patch-level:
..or.no longer sends the request to a different route. Supersedes fix(url): reject "." and ".." path parameters so URL resolution can't change the route #1390, whose fix was a hand edit to a generated file and would be overwritten once persistent edits are off.No version change and no API removal: this is intended to ship as an ordinary patch release.
The branch is up to date with
main(a709c011, which already includes #1425 and #1436); nothing to rebase in this round. See "Dropped from the original PR" at the bottom.Security fix: dot-segment path parameters
Generated operations percent-encode path parameters, which leaves
.and..unchanged.ClientSDK._createRequest(src/lib/sdks.ts) then builds the URL withnew URL(path, baseURL), which resolves them as dot segments. Code that passes an untrusted value as a path parameter could therefore have the request, with the client's API key, sent to another route:Behaviour.
pathToFunc(src/lib/url.ts), which every operation uses to fill its path template, now throws when a parameter that forms a whole path segment is exactly.or.., and as a backstop when the finished path contains such a segment....,.env,a..bare unaffected,%2e%2eis still sent double-encoded as%252e%252e, and the existing "is required" / "must be a string or number" errors are unchanged. The only inputs that behave differently are ones that were already being misrouted.Error type. A plain
Error, as a rejected promise, the same as the existing "Parameter 'x' is required" error. It is thrown before_createRequest, so it is not wrapped in the SDK'sResult/InvalidRequestError, including for callers of the standalonefuncs/*functions.Mechanism:
src/lib/url.tsis added to.genignoreand is now hand-maintained. The alternatives were tried first:patternon path-parameter schemas via an overlayout.openapi.yaml, and regeneration produced no change undersrc/. The generator does not emitpattern(orminLength: the spec has both today and the generated zod schemas have neither).gen.yamloptionbeforeCreateRequestruns at the end of_createRequest, afternew URL(path, baseURL), and receives only the resolved URL;HookContexthas no path template or raw parameters. A misrouted/files/file-abc/contentis indistinguishable from a real call to that route.Regenerating with the entry in place leaves
src/lib/url.tsbyte-identical, and the generator removes the file's entry fromgen.locktrackedFiles, which is committed here.Cost and mitigation. Generator updates to this one file no longer arrive.
tests/unit/path-param-dot-segments.test.tshas a "generator contract" block that fails if generated operations start using the file differently (the exported signature, what they import from it, the call shape).Removing it. The bug has been reported to Speakeasy. When the generator rejects or safely encodes dot segments: delete the
src/lib/url.tsline from.genignore, regenerate, keep the behaviour tests. The file's header comment says the same.Consumer-visible behaviour changes
Please read these before deciding this is fine as a patch.
serverURLFromOptions()called directly no longer readsOPENROUTER_BASE_URL. With the env var set,serverURLFromOptions({})now returns the production URL.new OpenRouter()and every sub-SDK (chat, etc.) still honour the env var with the same precedence, through a hook. This one is not avoidable while persistent edits are off: the helper lives in generatedsrc/lib/config.ts, and Speakeasy's TypeScript target has no setting that makes the server URL read an env var (envVarPrefixcovers security and global parameters only). Workaround for direct callers:serverURLFromOptions({ serverURL: process.env.OPENROUTER_BASE_URL }).EnvandenvSchemainsrc/lib/env.tsno longer have anOPENROUTER_BASE_URLfield. Only code that deep-imports@openrouter/sdk/lib/env.jsand readsenv().OPENROUTER_BASE_URLis affected. Same cause as (1).OPENROUTER_BASE_URLeach time a client is constructed. Before, the value was memoised with the rest ofenv()on first use.client._options.serverURLis now populated with the env value when it is applied.Not changed:
serverURL>server>OPENROUTER_BASE_URL> production.OPENROUTER_BASE_URLstill throws aZodError(issue path["OPENROUTER_BASE_URL"]) at client construction, including whenserverURLis passed explicitly. The original version of this PR changed it to aTypeError; that is gone.// @generated-idcomment line disappears from generated sources. Comment only, no runtime or type effect.Audit of hand edits
Every non-deleted file under
trackedFilesin.speakeasy/gen.lock(3,955 files) was hashed and compared with itspristine_git_object. Re-run in this round againstmainata709c011: the same five differ, nothing new.src/lib/config.tsOPENROUTER_BASE_URLfallback inserverURLFromOptions(#832)SDKInitHookinsrc/hooks/base-url-env.ts, registered insrc/hooks/registration.ts. Direct helper calls lose it (change 1 above).src/lib/env.tsOPENROUTER_BASE_URLinEnv/envSchema(#832)RUNTIMES.md.genignore.devcontainer/devcontainer.json.genignoretsconfig.jsonsrc/sdk/sdk.tsno longer differs from pristine: thecallModelregions were removed onmainin #1356.enableCustomCodeRegionsis a separate feature and stays on.Existing hooks are unaffected:
src/hooks/signal-hooks.tsandregistration.tsare not generator-owned, and the regen did not touch them. The new hook is registered beforeSignalTimeoutHook; the two do not interact.Commits
chore: disable Speakeasy persistent edits:persistentEdits.enabled: never,.genignore, the hook and its registration, tests,CLAUDE.md, and the regeneratedconfig.ts/env.ts/tsconfig.json. This is the one to review.chore: regenerate SDK without @generated-id headers: generator output only. 1,392 files lose the one header line,gen.lockdrops the matching tracking data, and Speakeasy removes one blank line from.gitignore. Nothing else.test: remove unused schema-test-helpers: a test helper left behind by SEC-768: refactor: remove the old callModel code that moved to @openrouter/agent #1356 that nothing imports.fix(url): reject "." and ".." path segments so URL resolution can't change the route:src/lib/url.ts, its.genignoreentry, thegen.lockentry removal, tests,CLAUDE.md.Version
Left at
1.5.85ingen.yaml,package.json,jsr.jsonandSDK_METADATA, exactly as onmain. Versioning isversioningStrategy: automaticand the Generate workflows runsdk-generation-actioninmode: pr, which bumps the patch when it regenerates.gen.lockhere keeps the olddocChecksumand has a newconfigChecksum, so the next release regen sees a change and bumps to the next patch.How it was regenerated
Pinned Speakeasy 1.803.2, local only:
with the two
registry:blocks removed from an uncommitted copy ofworkflow.yaml(code samples written to a local file), so nothing was uploaded or tagged.workflow.yamlandworkflow.lockare unchanged frommain.main'sin.openapi.yamlis one spec update (#1434) ahead of its generated code. To keep this diff to the persistent-edits change, generation used the spec revision the current generated code was built from. The pending spec changes are left for the normal Generate run.A run on the final tree (after commit 4) produced no diff apart from key ordering inside
package.json, which the generator shuffles on every run and which is not committed.src/lib/url.tshad the same hash before and after.Testing
pnpm run lint,pnpm run typecheck,pnpm run build: pass.pnpm test: 122/122 in 10 files, no type errors.tests/unit/path-param-dot-segments.test.ts(28 tests) runs three operations (containers.downloadContainerFileContent,batch.getBatches,presets.restore) against a local mock server and checks the route hit, that./..reject with the parameter named and send nothing, the unaffected values, numeric parameters, and the generator contract. 11 of them fail withmain'surl.ts.tests/unit/server-url-env.test.ts(10 tests) goes throughnew OpenRouter()and covers: unset, env applied, env applied tochat,serverURLover env,serverover env,serverURLoverserver, invalid value is aZodErrorwith the right path, invalid value still throws with explicitserverURL, empty value throws, and the direct-helper behaviour.esm/against a local HTTP stub: withOPENROUTER_BASE_URLset,chat.sendhit the stub at the env path; an explicitserverURLwon;server: "production"won; unset fell back to production.esm/built from the regenerated tree:containerId..and.,batch.getBatches({ id: ".." })andfiles.download({ fileId: "." })threw and the stub received nothing; a normal id and%2e%2ehit the container route.Dropped from the original PR
feat!/chore!, no "Migrating from v1" README section. The README onmainalready sayscallModelmoved to@openrouter/agent.mainvia SEC-768: refactor: remove the old callModel code that moved to @openrouter/agent #1356. Of the 37 files that commit deleted, two are still onmain:tests/utils/schema-test-helpers.ts(unreferenced, removed here) andsrc/models/claude-message.ts(kept: it is listed as hand-written inCLAUDE.mdand is reachable as a public deep import, so deleting it would be a consumer-visible removal).TypeErrorfor an invalidOPENROUTER_BASE_URL: reverted to the existingZodError.Notes
CLAUDE.mdis updated to say so.src/lib/url.tsis the exception, because it is in.genignore.