fix: encode caller-supplied path parameters in legacy modules - #1710
Merged
Merged
Conversation
The hand-written legacy modules (user-management, authorization,
organizations, feature-flags, multi-factor-auth, audit-logs,
directory-sync, organization-domains, sso, passwordless) interpolated
caller-supplied identifiers (user IDs, invitation tokens, MFA factor and
challenge IDs, organization IDs, external IDs, role and permission
slugs, waitlist IDs, IT contact IDs) directly into URL path templates.
HttpClient.getResourceURL then resolves the path with the WHATWG URL
parser, which collapses `../` dot-segments and honors `?` and `#`. A
value such as `../../user_management/users/user_01VICTIM` passed to
`mfa.deleteFactor()` therefore sent `DELETE /user_management/users/...`
with the integrator's API key, letting any method of a non-encoding
module be retargeted to an arbitrary same-verb endpoint.
Add a shared `encodePathParameter` helper and wrap every interpolated
path parameter in these modules with it. The helper is
`encodeURIComponent` with two deliberate differences:
- `:` is preserved so RBAC slugs such as `users:read` keep their exact
wire format. A colon is a valid path-segment character (RFC 3986
pchar) and can never be read as a URL scheme after a `/`-delimited
prefix.
- A bare `.` or `..` throws a TypeError before any request is made.
`encodeURIComponent` leaves those unchanged and the URL parser removes
them as relative segments even in their `%2e` forms, so for a
non-terminal template like `/feature-flags/${slug}/enable` a slug of
`..` would still climb and retarget the request. Neither is ever a
valid WorkOS identifier, so the helper fails closed.
Oagen-generated modules already wrap path parameters in
`encodeURIComponent` from the emitter and are not changed here.
`src/api-keys/api-keys.ts` is generated but still interpolates `id` and
`organizationId` raw; it is deliberately left untouched so that fix can
land in the oagen node emitter and be regenerated.
Each touched module's spec now asserts that a traversal payload stays a
single encoded path segment and that `..` is rejected before any fetch.
Resolves VULN-1216
Contributor
|
deleteApiKey, listOrganizationApiKeys and createOrganizationApiKey in the oagen-generated api-keys module still interpolated the caller- supplied `id` and `organizationId` raw into the request path, leaving the same traversal/retargeting primitive open that the previous commit closed in the legacy modules (VULN-1216 names deleteApiKey explicitly). Wrap the four sites with bare `encodeURIComponent(...)`, which is byte- for-byte the form the current oagen node emitter produces for path parameters (see src/pipes/pipes.ts), so the next regeneration yields an identical file with no drift. The "Do not edit" header, imports and the rest of the file are unchanged. Add traversal assertions to the api-keys spec. There is deliberately no TypeError test here: bare encodeURIComponent does not reject a bare `.` or `..`, and that residual dot-segment gap in generated modules belongs in the emitter, not in a hand edit.
Generated modules interpolate path parameters with bare
encodeURIComponent, which leaves a bare `.` or `..` unchanged. The
WHATWG URL parser in HttpClient.getResourceURL then collapses them, so
apiKeys.deleteApiKey('..') resolved `/api_keys/..` to `/` and an
organizationId of `..` turned `/organizations/../api_keys` into
`/api_keys`, retargeting the request.
Rather than hand-editing generated files beyond the emitter-exact form,
guard the one hand-maintained place that builds the URL: getResourceURL
now throws a TypeError if any path segment is `.`, `..`, or one of the
percent-encoded `%2e` forms the parser also treats as dots. Encoded
identifiers never trip the check because encodeURIComponent turns `%`
into `%25` and `/` into `%2F`. This covers every generated module and is
a second line of defense behind encodePathParameter for the legacy
modules.
Add a getResourceURL spec and extend the api-keys spec to assert that a
dot-only id or organizationId is rejected before any fetch. The WorkOS
request wrapper surfaces the guard's TypeError as the cause of its
generic "Unexpected error", so the tests assert on the message and
cause.
nicknisi
approved these changes
Sep 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Resolves VULN-1216
The hand-written legacy modules (
user-management,authorization,organizations,feature-flags,multi-factor-auth,audit-logs,directory-sync,organization-domains,sso,passwordless) interpolated caller-supplied identifiers straight into URL path templates.HttpClient.getResourceURLthen resolves the path with the WHATWGURLparser, which collapses../and honors?/#, soworkos.mfa.deleteFactor('../../user_management/users/user_01VICTIM')sentDELETE /user_management/users/user_01VICTIMwith the integrator's API key. Any method of a non-encoding module could be retargeted to any same-verb endpoint.src/common/utils/encode-path-parameter.tsand wrap every interpolated path parameter in the ten legacy modules with it (about 110 call sites, including the newer waitlist, IT contact, user API key, session, group and role-assignment paths added since July). This re-lands the approach from Encode caller-supplied path parameters in legacy modules #1662/Encode the userId path parameter in user API key methods #1664, which were closed unmerged.:so RBAC slugs such asusers:readkeep their exact wire format. A colon is a valid path-segment character and can never be read as a URL scheme after a/-delimited prefix. Existing slug tests are unchanged and an explicit test pins the/permissions/users:readpath..or..with aTypeErrorbefore any request is made.encodeURIComponentleaves them unchanged and the URL parser strips them as relative segments even in%2eform, so a slug of..in a non-terminal template like/feature-flags/${slug}/enablewould still climb. Neither is a valid WorkOS identifier, so the helper fails closed.src/api-keys/api-keys.ts(deleteApiKey,listOrganizationApiKeys,createOrganizationApiKey). This file carries the oagen header, so the fix is hand-applied with the exact bareencodeURIComponent(...)form the current node emitter emits (seesrc/pipes/pipes.ts), meaning the next regeneration produces the same output with no drift. No import or other change to the file.HttpClient.getResourceURLagainst dot segments. Generated modules use bareencodeURIComponent, which leaves a bare.or..unchanged, sodeleteApiKey('..')would still have resolved/api_keys/..to/and an organization ID of..would have turned/organizations/../api_keysinto/api_keys. The one hand-maintained place that callsnew URL()now rejects any path segment equal to.or..(including the%2eforms the WHATWG parser also collapses) with aTypeErrorbefore a request is built (theWorkOSrequest wrapper surfaces it as its usual "Unexpected error" with theTypeErrorascause). This closes the gap for every generated module without editing them beyond the emitter-exact form, and is a second line of defense behindencodePathParameterfor the legacy modules.getResourceURL, plus each touched module spec (api-keys included) asserts that a traversal payload stays a single encoded path segment and that..rejects beforefetchis called.Not changed
pipes,vault,webhooks,groups,agents,radar,connect,widgets) already wrap path parameters inencodeURIComponentfrom the emitter and are untouched.encodeURIComponentcall sites; thegetResourceURLguard covers their dot-segment case, so a matching emitter change is a parity follow-up rather than a safety requirement.encodeURIComponentanyway, so the change is compatible in direction.Documentation
Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.