Skip to content

fix: encode caller-supplied path parameters in legacy modules - #1710

Merged
gjtorikian merged 3 commits into
mainfrom
vuln-1216/encode-legacy-path-params
Sep 19, 2026
Merged

gjtorikian merged 3 commits into
mainfrom
vuln-1216/encode-legacy-path-params

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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.getResourceURL then resolves the path with the WHATWG URL parser, which collapses ../ and honors ?/#, so workos.mfa.deleteFactor('../../user_management/users/user_01VICTIM') sent DELETE /user_management/users/user_01VICTIM with the integrator's API key. Any method of a non-encoding module could be retargeted to any same-verb endpoint.

  • Add src/common/utils/encode-path-parameter.ts and 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.
  • Preserve : so RBAC slugs such as users:read keep 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:read path.
  • Reject a bare . or .. with a TypeError before any request is made. encodeURIComponent leaves them unchanged and the URL parser strips them as relative segments even in %2e form, so a slug of .. in a non-terminal template like /feature-flags/${slug}/enable would still climb. Neither is a valid WorkOS identifier, so the helper fails closed.
  • Encode the four raw sites in the generated src/api-keys/api-keys.ts (deleteApiKey, listOrganizationApiKeys, createOrganizationApiKey). This file carries the oagen header, so the fix is hand-applied with the exact bare encodeURIComponent(...) form the current node emitter emits (see src/pipes/pipes.ts), meaning the next regeneration produces the same output with no drift. No import or other change to the file.
  • Guard HttpClient.getResourceURL against dot segments. Generated modules use bare encodeURIComponent, which leaves a bare . or .. unchanged, so deleteApiKey('..') would still have resolved /api_keys/.. to / and an organization ID of .. would have turned /organizations/../api_keys into /api_keys. The one hand-maintained place that calls new URL() now rejects any path segment equal to . or .. (including the %2e forms the WHATWG parser also collapses) with a TypeError before a request is built (the WorkOS request wrapper surfaces it as its usual "Unexpected error" with the TypeError as cause). This closes the gap for every generated module without editing them beyond the emitter-exact form, and is a second line of defense behind encodePathParameter for the legacy modules.
  • Tests: unit specs for the helper and for getResourceURL, plus each touched module spec (api-keys included) asserts that a traversal payload stays a single encoded path segment and that .. rejects before fetch is called.

Not changed

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[ ] Yes

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
@gjtorikian
gjtorikian requested review from a team as code owners September 17, 2026 14:09
@gjtorikian
gjtorikian requested a review from imkesin September 17, 2026 14:09
@linear-code

linear-code Bot commented Sep 17, 2026

Copy link
Copy Markdown

VULN-1216

@greptile-apps

greptile-apps Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the centralized guard fully addresses the previously reported API-key dot-segment retargeting issue, and no new actionable failures were identified.

Findings

  1. P1 Security Dot segments retarget requests
Fix with agent prompt
### Issue 1
src/api-keys/api-keys.ts:undefined-63
These API key paths use `encodeURIComponent`, which leaves bare `.` and `..` values unchanged. Since the HTTP client then passes the path through the WHATWG `URL` parser, `deleteApiKey('..')` normalizes `/api_keys/..` to `/`, while an organization ID of `..` normalizes `/organizations/../api_keys` to `/api_keys`. Caller-controlled identifiers can therefore retarget DELETE, GET, or POST requests instead of remaining in their intended path segment. The same issue occurs at lines 87, 94, and 123; these values need the dot-only rejection provided by the new shared helper.

**How this was verified:** The public methods accept unrestricted strings, and every resulting path passes through `new URL(...)`, which normalizes the unchanged dot segments before issuing the request.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR prevents caller-supplied identifiers from changing SDK request paths in legacy and API-key modules.

  • Encodes interpolated path parameters while preserving colons used in RBAC slugs.
  • Rejects standalone dot segments before URL normalization can retarget a request.
  • Adds module-level and HTTP-client tests covering traversal payloads and pre-request rejection.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Caller-supplied identifier] --> B[Encode path parameter]
    B --> C{Standalone dot segment?}
    C -- Yes --> D[Throw before request]
    C -- No --> E[Build request path]
    E --> F[HttpClient dot-segment guard]
    F --> G[WHATWG URL construction]
    G --> H[Send request to intended endpoint]
Loading

Reviews (3) · Last reviewed commit: "fix(http-client): reject dot segments in..."

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.
Comment thread src/api-keys/api-keys.ts
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.
@gjtorikian
gjtorikian merged commit 1fe77c6 into main Sep 19, 2026
7 checks passed
@gjtorikian
gjtorikian deleted the vuln-1216/encode-legacy-path-params branch September 19, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants