From cd92a92aa8e5e74df94eed586c1770fcf54c2658 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 17 Sep 2026 10:08:02 -0400 Subject: [PATCH 1/3] fix: encode caller-supplied path parameters in legacy modules 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 --- src/audit-logs/audit-logs.spec.ts | 32 +++++++ src/audit-logs/audit-logs.ts | 7 +- src/authorization/authorization.spec.ts | 38 +++++++++ src/authorization/authorization.ts | 84 ++++++++++--------- .../utils/encode-path-parameter.spec.ts | 39 +++++++++ src/common/utils/encode-path-parameter.ts | 41 +++++++++ src/directory-sync/directory-sync.spec.ts | 24 ++++++ src/directory-sync/directory-sync.ts | 9 +- src/feature-flags/feature-flags.spec.ts | 23 +++++ src/feature-flags/feature-flags.ts | 24 ++++-- .../multi-factor-auth.spec.ts | 32 ++++++- src/multi-factor-auth/multi-factor-auth.ts | 15 ++-- .../organization-domains.spec.ts | 30 ++++++- .../organization-domains.ts | 9 +- src/organizations/organizations.spec.ts | 36 ++++++++ src/organizations/organizations.ts | 19 +++-- src/passwordless/passwordless.spec.ts | 32 ++++++- src/passwordless/passwordless.ts | 3 +- src/sso/sso.spec.ts | 24 ++++++ src/sso/sso.ts | 5 +- src/user-management/user-management.spec.ts | 62 ++++++++++++++ src/user-management/user-management.ts | 76 +++++++++-------- 22 files changed, 549 insertions(+), 115 deletions(-) create mode 100644 src/common/utils/encode-path-parameter.spec.ts create mode 100644 src/common/utils/encode-path-parameter.ts diff --git a/src/audit-logs/audit-logs.spec.ts b/src/audit-logs/audit-logs.spec.ts index 5b3c2e8f3..b58419041 100644 --- a/src/audit-logs/audit-logs.spec.ts +++ b/src/audit-logs/audit-logs.spec.ts @@ -1143,4 +1143,36 @@ describe('AuditLogs', () => { }); }); }); + + describe('path parameter encoding', () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + it('keeps a traversal payload inside a single path segment', async () => { + const timestamp = new Date().toISOString(); + fetchOnce({ + object: 'audit_log_export', + id: 'audit_log_export_1234', + state: 'pending', + created_at: timestamp, + updated_at: timestamp, + }); + + await workos.auditLogs.getExport( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('GET'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/audit_logs/exports/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect(workos.auditLogs.listSchemas('..')).rejects.toThrow( + TypeError, + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/audit-logs/audit-logs.ts b/src/audit-logs/audit-logs.ts index d2e3c4b96..de34dc8a9 100644 --- a/src/audit-logs/audit-logs.ts +++ b/src/audit-logs/audit-logs.ts @@ -36,6 +36,7 @@ import { serializeCreateAuditLogSchemaOptions, serializeUpdateAuditLogsRetention, } from './serializers'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class AuditLogs { constructor(private readonly workos: WorkOS) {} @@ -158,7 +159,7 @@ export class AuditLogs { */ async getExport(auditLogExportId: string): Promise { const { data } = await this.workos.get( - `/audit_logs/exports/${auditLogExportId}`, + `/audit_logs/exports/${encodePathParameter(auditLogExportId)}`, ); return deserializeAuditLogExport(data); @@ -177,7 +178,7 @@ export class AuditLogs { options: CreateAuditLogSchemaRequestOptions = {}, ): Promise { const { data } = await this.workos.post( - `/audit_logs/actions/${schema.action}/schemas`, + `/audit_logs/actions/${encodePathParameter(schema.action)}/schemas`, serializeCreateAuditLogSchemaOptions(schema), options, ); @@ -189,7 +190,7 @@ export class AuditLogs { action: string, options?: PaginationOptions, ): Promise> { - const endpoint = `/audit_logs/actions/${action}/schemas`; + const endpoint = `/audit_logs/actions/${encodePathParameter(action)}/schemas`; return new AutoPaginatable( await fetchAndDeserialize( diff --git a/src/authorization/authorization.spec.ts b/src/authorization/authorization.spec.ts index 0c264a80d..f4e9718ac 100644 --- a/src/authorization/authorization.spec.ts +++ b/src/authorization/authorization.spec.ts @@ -3006,4 +3006,42 @@ describe('Authorization', () => { expect(data).toEqual([]); }); }); + + describe('path parameter encoding', () => { + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce({}, { status: 204 }); + + await workos.authorization.deleteOrganizationRole( + testOrgId, + '../../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('DELETE'); + expect(new URL(String(fetchURL())).pathname).toBe( + `/authorization/organizations/${testOrgId}/roles/..%2F..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM`, + ); + }); + + it('preserves colons in permission slugs', async () => { + fetchOnce({}, { status: 204 }); + + await workos.authorization.removeOrganizationRolePermission( + testOrgId, + 'org-admin', + { permissionSlug: 'users:read' }, + ); + + expect(new URL(String(fetchURL())).pathname).toBe( + `/authorization/organizations/${testOrgId}/roles/org-admin/permissions/users:read`, + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect( + workos.authorization.getEnvironmentRole('..'), + ).rejects.toThrow(TypeError); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/authorization/authorization.ts b/src/authorization/authorization.ts index c76e69c51..abbe2c949 100644 --- a/src/authorization/authorization.ts +++ b/src/authorization/authorization.ts @@ -97,6 +97,7 @@ import { AuthorizationOrganizationMembershipResponse, } from '../user-management/interfaces/organization-membership.interface'; import { deserializeAuthorizationOrganizationMembership } from '../user-management/serializers/organization-membership.serializer'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class Authorization { constructor(private readonly workos: WorkOS) {} @@ -155,7 +156,7 @@ export class Authorization { */ async getEnvironmentRole(slug: string): Promise { const { data } = await this.workos.get( - `/authorization/roles/${slug}`, + `/authorization/roles/${encodePathParameter(slug)}`, ); return deserializeEnvironmentRole(data); } @@ -181,7 +182,7 @@ export class Authorization { options: UpdateEnvironmentRoleOptions, ): Promise { const { data } = await this.workos.patch( - `/authorization/roles/${slug}`, + `/authorization/roles/${encodePathParameter(slug)}`, serializeUpdateEnvironmentRoleOptions(options), ); return deserializeEnvironmentRole(data); @@ -208,7 +209,7 @@ export class Authorization { options: SetEnvironmentRolePermissionsOptions, ): Promise { const { data } = await this.workos.put( - `/authorization/roles/${slug}/permissions`, + `/authorization/roles/${encodePathParameter(slug)}/permissions`, { permissions: options.permissions }, ); return deserializeEnvironmentRole(data); @@ -235,7 +236,7 @@ export class Authorization { options: AddEnvironmentRolePermissionOptions, ): Promise { const { data } = await this.workos.post( - `/authorization/roles/${slug}/permissions`, + `/authorization/roles/${encodePathParameter(slug)}/permissions`, { slug: options.permissionSlug }, ); return deserializeEnvironmentRole(data); @@ -263,7 +264,7 @@ export class Authorization { options: CreateOrganizationRoleOptions, ): Promise { const { data } = await this.workos.post( - `/authorization/organizations/${organizationId}/roles`, + `/authorization/organizations/${encodePathParameter(organizationId)}/roles`, serializeCreateOrganizationRoleOptions(options), ); return deserializeOrganizationRole(data); @@ -284,7 +285,7 @@ export class Authorization { */ async listOrganizationRoles(organizationId: string): Promise { const { data } = await this.workos.get( - `/authorization/organizations/${organizationId}/roles`, + `/authorization/organizations/${encodePathParameter(organizationId)}/roles`, ); return { object: 'list', @@ -315,7 +316,7 @@ export class Authorization { slug: string, ): Promise { const { data } = await this.workos.get( - `/authorization/organizations/${organizationId}/roles/${slug}`, + `/authorization/organizations/${encodePathParameter(organizationId)}/roles/${encodePathParameter(slug)}`, ); return deserializeRole(data); } @@ -347,7 +348,7 @@ export class Authorization { options: UpdateOrganizationRoleOptions, ): Promise { const { data } = await this.workos.patch( - `/authorization/organizations/${organizationId}/roles/${slug}`, + `/authorization/organizations/${encodePathParameter(organizationId)}/roles/${encodePathParameter(slug)}`, serializeUpdateOrganizationRoleOptions(options), ); return deserializeOrganizationRole(data); @@ -378,7 +379,7 @@ export class Authorization { slug: string, ): Promise { await this.workos.delete( - `/authorization/organizations/${organizationId}/roles/${slug}`, + `/authorization/organizations/${encodePathParameter(organizationId)}/roles/${encodePathParameter(slug)}`, ); } @@ -408,7 +409,7 @@ export class Authorization { options: SetOrganizationRolePermissionsOptions, ): Promise { const { data } = await this.workos.put( - `/authorization/organizations/${organizationId}/roles/${slug}/permissions`, + `/authorization/organizations/${encodePathParameter(organizationId)}/roles/${encodePathParameter(slug)}/permissions`, { permissions: options.permissions }, ); return deserializeOrganizationRole(data); @@ -441,7 +442,7 @@ export class Authorization { options: AddOrganizationRolePermissionOptions, ): Promise { const { data } = await this.workos.post( - `/authorization/organizations/${organizationId}/roles/${slug}/permissions`, + `/authorization/organizations/${encodePathParameter(organizationId)}/roles/${encodePathParameter(slug)}/permissions`, { slug: options.permissionSlug }, ); return deserializeOrganizationRole(data); @@ -476,7 +477,7 @@ export class Authorization { options: RemoveOrganizationRolePermissionOptions, ): Promise { await this.workos.delete( - `/authorization/organizations/${organizationId}/roles/${slug}/permissions/${options.permissionSlug}`, + `/authorization/organizations/${encodePathParameter(organizationId)}/roles/${encodePathParameter(slug)}/permissions/${encodePathParameter(options.permissionSlug)}`, ); } @@ -544,7 +545,7 @@ export class Authorization { */ async getPermission(slug: string): Promise { const { data } = await this.workos.get( - `/authorization/permissions/${slug}`, + `/authorization/permissions/${encodePathParameter(slug)}`, ); return deserializePermission(data); } @@ -569,7 +570,7 @@ export class Authorization { options: UpdatePermissionOptions, ): Promise { const { data } = await this.workos.patch( - `/authorization/permissions/${slug}`, + `/authorization/permissions/${encodePathParameter(slug)}`, serializeUpdatePermissionOptions(options), ); return deserializePermission(data); @@ -589,7 +590,9 @@ export class Authorization { * @throws {NotFoundException} 404 */ async deletePermission(slug: string): Promise { - await this.workos.delete(`/authorization/permissions/${slug}`); + await this.workos.delete( + `/authorization/permissions/${encodePathParameter(slug)}`, + ); } /** @@ -608,7 +611,7 @@ export class Authorization { */ async getResource(resourceId: string): Promise { const { data } = await this.workos.get( - `/authorization/resources/${resourceId}`, + `/authorization/resources/${encodePathParameter(resourceId)}`, ); return deserializeAuthorizationResource(data); } @@ -651,7 +654,7 @@ export class Authorization { options: UpdateAuthorizationResourceOptions, ): Promise { const { data } = await this.workos.patch( - `/authorization/resources/${options.resourceId}`, + `/authorization/resources/${encodePathParameter(options.resourceId)}`, serializeUpdateResourceOptions(options), ); return deserializeAuthorizationResource(data); @@ -680,7 +683,10 @@ export class Authorization { ? { cascade_delete: cascadeDelete.toString() } : undefined; - await this.workos.delete(`/authorization/resources/${resourceId}`, query); + await this.workos.delete( + `/authorization/resources/${encodePathParameter(resourceId)}`, + query, + ); } /** @@ -749,7 +755,7 @@ export class Authorization { ): Promise { const { organizationId, resourceTypeSlug, externalId } = options; const { data } = await this.workos.get( - `/authorization/organizations/${organizationId}/resources/${resourceTypeSlug}/${externalId}`, + `/authorization/organizations/${encodePathParameter(organizationId)}/resources/${encodePathParameter(resourceTypeSlug)}/${encodePathParameter(externalId)}`, ); return deserializeAuthorizationResource(data); } @@ -786,7 +792,7 @@ export class Authorization { ): Promise { const { organizationId, resourceTypeSlug, externalId } = options; const { data } = await this.workos.patch( - `/authorization/organizations/${organizationId}/resources/${resourceTypeSlug}/${externalId}`, + `/authorization/organizations/${encodePathParameter(organizationId)}/resources/${encodePathParameter(resourceTypeSlug)}/${encodePathParameter(externalId)}`, serializeUpdateResourceByExternalIdOptions(options), ); return deserializeAuthorizationResource(data); @@ -829,7 +835,7 @@ export class Authorization { : undefined; await this.workos.delete( - `/authorization/organizations/${organizationId}/resources/${resourceTypeSlug}/${externalId}`, + `/authorization/organizations/${encodePathParameter(organizationId)}/resources/${encodePathParameter(resourceTypeSlug)}/${encodePathParameter(externalId)}`, query, ); } @@ -848,7 +854,7 @@ export class Authorization { options: AuthorizationCheckOptions, ): Promise { const { data } = await this.workos.post( - `/authorization/organization_memberships/${options.organizationMembershipId}/check`, + `/authorization/organization_memberships/${encodePathParameter(options.organizationMembershipId)}/check`, serializeAuthorizationCheckOptions(options), ); return data; @@ -872,7 +878,7 @@ export class Authorization { options: ListRoleAssignmentsOptions, ): Promise> { const { organizationMembershipId, ...queryOptions } = options; - const endpoint = `/authorization/organization_memberships/${organizationMembershipId}/role_assignments`; + const endpoint = `/authorization/organization_memberships/${encodePathParameter(organizationMembershipId)}/role_assignments`; const serializedOptions = serializeListRoleAssignmentsOptions(queryOptions); return new AutoPaginatable( await fetchAndDeserialize( @@ -910,7 +916,7 @@ export class Authorization { options: ListRoleAssignmentsForResourceOptions, ): Promise> { const { resourceId, ...queryOptions } = options; - const endpoint = `/authorization/resources/${resourceId}/role_assignments`; + const endpoint = `/authorization/resources/${encodePathParameter(resourceId)}/role_assignments`; const serializedOptions = serializeListRoleAssignmentsForResourceOptions(queryOptions); return new AutoPaginatable( @@ -960,7 +966,7 @@ export class Authorization { ): Promise> { const { organizationId, resourceTypeSlug, externalId, ...queryOptions } = options; - const endpoint = `/authorization/organizations/${organizationId}/resources/${resourceTypeSlug}/${externalId}/role_assignments`; + const endpoint = `/authorization/organizations/${encodePathParameter(organizationId)}/resources/${encodePathParameter(resourceTypeSlug)}/${encodePathParameter(externalId)}/role_assignments`; const serializedOptions = serializeListRoleAssignmentsForResourceOptions(queryOptions); return new AutoPaginatable( @@ -993,7 +999,7 @@ export class Authorization { */ async assignRole(options: AssignRoleOptions): Promise { const { data } = await this.workos.post( - `/authorization/organization_memberships/${options.organizationMembershipId}/role_assignments`, + `/authorization/organization_memberships/${encodePathParameter(options.organizationMembershipId)}/role_assignments`, serializeAssignRoleOptions(options), ); return deserializeRoleAssignment(data); @@ -1011,7 +1017,7 @@ export class Authorization { */ async removeRole(options: RemoveRoleOptions): Promise { await this.workos.deleteWithBody( - `/authorization/organization_memberships/${options.organizationMembershipId}/role_assignments`, + `/authorization/organization_memberships/${encodePathParameter(options.organizationMembershipId)}/role_assignments`, serializeRemoveRoleOptions(options), ); } @@ -1038,7 +1044,7 @@ export class Authorization { options: RemoveRoleAssignmentOptions, ): Promise { await this.workos.delete( - `/authorization/organization_memberships/${options.organizationMembershipId}/role_assignments/${options.roleAssignmentId}`, + `/authorization/organization_memberships/${encodePathParameter(options.organizationMembershipId)}/role_assignments/${encodePathParameter(options.roleAssignmentId)}`, ); } @@ -1060,7 +1066,7 @@ export class Authorization { options: ListGroupRoleAssignmentsOptions, ): Promise> { const { groupId, ...paginationOptions } = options; - const endpoint = `/authorization/groups/${groupId}/role_assignments`; + const endpoint = `/authorization/groups/${encodePathParameter(groupId)}/role_assignments`; return new AutoPaginatable( await fetchAndDeserialize< GroupRoleAssignmentResponse, @@ -1105,7 +1111,7 @@ export class Authorization { options: GetGroupRoleAssignmentOptions, ): Promise { const { data } = await this.workos.get( - `/authorization/groups/${options.groupId}/role_assignments/${options.roleAssignmentId}`, + `/authorization/groups/${encodePathParameter(options.groupId)}/role_assignments/${encodePathParameter(options.roleAssignmentId)}`, ); return deserializeGroupRoleAssignment(data); } @@ -1125,7 +1131,7 @@ export class Authorization { options: CreateGroupRoleAssignmentOptions, ): Promise { const { data } = await this.workos.post( - `/authorization/groups/${options.groupId}/role_assignments`, + `/authorization/groups/${encodePathParameter(options.groupId)}/role_assignments`, serializeCreateGroupRoleAssignmentOptions(options), ); return deserializeGroupRoleAssignment(data); @@ -1154,7 +1160,7 @@ export class Authorization { options: RemoveGroupRoleAssignmentOptions, ): Promise { await this.workos.delete( - `/authorization/groups/${options.groupId}/role_assignments/${options.roleAssignmentId}`, + `/authorization/groups/${encodePathParameter(options.groupId)}/role_assignments/${encodePathParameter(options.roleAssignmentId)}`, ); } @@ -1172,7 +1178,7 @@ export class Authorization { options: RemoveGroupRoleAssignmentsOptions, ): Promise { await this.workos.deleteWithBody( - `/authorization/groups/${options.groupId}/role_assignments`, + `/authorization/groups/${encodePathParameter(options.groupId)}/role_assignments`, serializeRemoveGroupRoleAssignmentsOptions(options), ); } @@ -1193,7 +1199,7 @@ export class Authorization { const { data } = await this.workos.put< ListResponse >( - `/authorization/groups/${options.groupId}/role_assignments`, + `/authorization/groups/${encodePathParameter(options.groupId)}/role_assignments`, serializeReplaceGroupRoleAssignmentsOptions(options), ); return deserializeList(data, deserializeGroupRoleAssignment); @@ -1221,7 +1227,7 @@ export class Authorization { options: ListResourcesForMembershipOptions, ): Promise> { const { organizationMembershipId } = options; - const endpoint = `/authorization/organization_memberships/${organizationMembershipId}/resources`; + const endpoint = `/authorization/organization_memberships/${encodePathParameter(organizationMembershipId)}/resources`; const serializedOptions = serializeListResourcesForMembershipOptions(options); return new AutoPaginatable( @@ -1258,7 +1264,7 @@ export class Authorization { options: ListMembershipsForResourceOptions, ): Promise> { const { resourceId } = options; - const endpoint = `/authorization/resources/${resourceId}/organization_memberships`; + const endpoint = `/authorization/resources/${encodePathParameter(resourceId)}/organization_memberships`; const serializedOptions = serializeListMembershipsForResourceOptions(options); return new AutoPaginatable( @@ -1315,7 +1321,7 @@ export class Authorization { options: ListMembershipsForResourceByExternalIdOptions, ): Promise> { const { organizationId, resourceTypeSlug, externalId } = options; - const endpoint = `/authorization/organizations/${organizationId}/resources/${resourceTypeSlug}/${externalId}/organization_memberships`; + const endpoint = `/authorization/organizations/${encodePathParameter(organizationId)}/resources/${encodePathParameter(resourceTypeSlug)}/${encodePathParameter(externalId)}/organization_memberships`; const serializedOptions = serializeListMembershipsForResourceOptions(options); return new AutoPaginatable( @@ -1366,7 +1372,7 @@ export class Authorization { options: ListEffectivePermissionsOptions, ): Promise> { const { organizationMembershipId, resourceId } = options; - const endpoint = `/authorization/resources/${resourceId}/organization_memberships/${organizationMembershipId}/permissions`; + const endpoint = `/authorization/resources/${encodePathParameter(resourceId)}/organization_memberships/${encodePathParameter(organizationMembershipId)}/permissions`; const serializedOptions = serializeListEffectivePermissionsOptions(options); return new AutoPaginatable( await fetchAndDeserialize( @@ -1400,7 +1406,7 @@ export class Authorization { options: ListEffectivePermissionsByExternalIdOptions, ): Promise> { const { organizationMembershipId, resourceTypeSlug, externalId } = options; - const endpoint = `/authorization/organization_memberships/${organizationMembershipId}/resources/${resourceTypeSlug}/${externalId}/permissions`; + const endpoint = `/authorization/organization_memberships/${encodePathParameter(organizationMembershipId)}/resources/${encodePathParameter(resourceTypeSlug)}/${encodePathParameter(externalId)}/permissions`; const serializedOptions = serializeListEffectivePermissionsOptions(options); return new AutoPaginatable( await fetchAndDeserialize( diff --git a/src/common/utils/encode-path-parameter.spec.ts b/src/common/utils/encode-path-parameter.spec.ts new file mode 100644 index 000000000..0dc72fc52 --- /dev/null +++ b/src/common/utils/encode-path-parameter.spec.ts @@ -0,0 +1,39 @@ +import { encodePathParameter } from './encode-path-parameter'; + +describe('encodePathParameter', () => { + it('leaves ordinary identifiers unchanged', () => { + expect(encodePathParameter('user_01ABC')).toBe('user_01ABC'); + expect(encodePathParameter('auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ')).toBe( + 'auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ', + ); + }); + + it('preserves colons used by RBAC slugs', () => { + expect(encodePathParameter('users:read')).toBe('users:read'); + expect(encodePathParameter('members:invite')).toBe('members:invite'); + }); + + it('encodes path traversal and metacharacters so one param stays one segment', () => { + expect(encodePathParameter('../../user_management/users/user_01ABC')).toBe( + '..%2F..%2Fuser_management%2Fusers%2Fuser_01ABC', + ); + expect(encodePathParameter('a/b')).toBe('a%2Fb'); + expect(encodePathParameter('a?b')).toBe('a%3Fb'); + expect(encodePathParameter('a#b')).toBe('a%23b'); + expect(encodePathParameter('%2e%2e')).toBe('%252e%252e'); + }); + + it('rejects dot-only segments the URL parser would collapse', () => { + // `encodeURIComponent` leaves these unchanged and `new URL()` removes them + // as relative path segments, so a non-terminal template (e.g. + // `/feature-flags/${slug}/enable`) would retarget the request. + expect(() => encodePathParameter('.')).toThrow(TypeError); + expect(() => encodePathParameter('..')).toThrow(TypeError); + }); + + it('allows segments that merely contain dots', () => { + expect(encodePathParameter('...')).toBe('...'); + expect(encodePathParameter('..foo')).toBe('..foo'); + expect(encodePathParameter('v1.2.3')).toBe('v1.2.3'); + }); +}); diff --git a/src/common/utils/encode-path-parameter.ts b/src/common/utils/encode-path-parameter.ts new file mode 100644 index 000000000..a524b4157 --- /dev/null +++ b/src/common/utils/encode-path-parameter.ts @@ -0,0 +1,41 @@ +/** + * Encode a caller-supplied identifier for safe interpolation into a single URL + * path segment. + * + * SDK path templates interpolate caller-supplied identifiers (user IDs, + * invitation tokens, MFA factor IDs, organization IDs, external IDs, role and + * permission slugs, ...) into the request path. Without encoding, a value such + * as `../../user_management/users/user_01ABC` would be resolved by the WHATWG + * `URL` parser (see `HttpClient.getResourceURL`) into a different API path, + * letting an attacker who influences one identifier retarget the request to an + * arbitrary same-verb endpoint. `encodeURIComponent` neutralizes this by + * percent-encoding the path/query/fragment metacharacters (`/`, `?`, `#`, ...) + * that enable the injection, keeping one parameter mapped to exactly one path + * segment. + * + * `encodeURIComponent` alone is not sufficient for the two dot-only segments + * `.` and `..`: it leaves them unchanged, and the WHATWG `URL` parser then + * removes them as relative path segments (even when a value like `..` is a + * single segment, a template such as `/feature-flags/${slug}/enable` supplies + * the trailing segment, so `..` still climbs and retargets the request). + * Percent-encoding the dots does not help because the parser also treats the + * `%2e` forms as dot segments. A `.` or `..` is never a valid WorkOS + * identifier, so we fail closed and throw rather than emit an ambiguous path. + * + * The one deviation from `encodeURIComponent` is that a literal `:` is kept + * unescaped. Colons are valid path-segment characters (RFC 3986 `pchar`) and + * are used by WorkOS RBAC slugs (e.g. `users:read`); an interpolated value is + * always preceded by a `/`-delimited segment, so a `:` can never be read as a + * URL scheme. Preserving it keeps the wire format identical for existing slugs. + */ +export function encodePathParameter(value: string): string { + const encoded = encodeURIComponent(value).replace(/%3A/gi, ':'); + + if (encoded === '.' || encoded === '..') { + throw new TypeError( + 'Invalid path parameter: a path parameter must not be "." or "..".', + ); + } + + return encoded; +} diff --git a/src/directory-sync/directory-sync.spec.ts b/src/directory-sync/directory-sync.spec.ts index 2f2b6cb17..76dabc2f3 100644 --- a/src/directory-sync/directory-sync.spec.ts +++ b/src/directory-sync/directory-sync.spec.ts @@ -3,6 +3,7 @@ import { fetchOnce, fetchURL, fetchSearchParams, + fetchMethod, } from '../common/utils/test-utils'; import { ListResponse } from '../common/interfaces/list.interface'; import { WorkOS } from '../workos'; @@ -442,4 +443,27 @@ describe('DirectorySync', () => { }); }); }); + + describe('path parameter encoding', () => { + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce(); + + await workos.directorySync.deleteDirectory( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('DELETE'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/directories/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect(workos.directorySync.getGroup('..')).rejects.toThrow( + TypeError, + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/directory-sync/directory-sync.ts b/src/directory-sync/directory-sync.ts index 021acf0e6..2952d2bbc 100644 --- a/src/directory-sync/directory-sync.ts +++ b/src/directory-sync/directory-sync.ts @@ -20,6 +20,7 @@ import { serializeListDirectoriesOptions, } from './serializers'; import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class DirectorySync { constructor(private readonly workos: WorkOS) {} @@ -69,7 +70,7 @@ export class DirectorySync { */ async getDirectory(id: string): Promise { const { data } = await this.workos.get( - `/directories/${id}`, + `/directories/${encodePathParameter(id)}`, ); return deserializeDirectory(data); @@ -88,7 +89,7 @@ export class DirectorySync { * @throws 403 response from the API. */ async deleteDirectory(id: string) { - await this.workos.delete(`/directories/${id}`); + await this.workos.delete(`/directories/${encodePathParameter(id)}`); } /** @@ -182,7 +183,7 @@ export class DirectorySync { ): Promise> { const { data } = await this.workos.get< DirectoryUserWithGroupsResponse - >(`/directory_users/${user}`); + >(`/directory_users/${encodePathParameter(user)}`); return deserializeDirectoryUserWithGroups(data); } @@ -201,7 +202,7 @@ export class DirectorySync { */ async getGroup(group: string): Promise { const { data } = await this.workos.get( - `/directory_groups/${group}`, + `/directory_groups/${encodePathParameter(group)}`, ); return deserializeDirectoryGroup(data); diff --git a/src/feature-flags/feature-flags.spec.ts b/src/feature-flags/feature-flags.spec.ts index 6703122a5..672c5e750 100644 --- a/src/feature-flags/feature-flags.spec.ts +++ b/src/feature-flags/feature-flags.spec.ts @@ -494,4 +494,27 @@ describe('FeatureFlags', () => { }); }); }); + + describe('path parameter encoding', () => { + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce(enableFeatureFlagFixture); + + await workos.featureFlags.enableFeatureFlag( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('PUT'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/feature-flags/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM/enable', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect(workos.featureFlags.enableFeatureFlag('..')).rejects.toThrow( + TypeError, + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/feature-flags/feature-flags.ts b/src/feature-flags/feature-flags.ts index e8e706389..707f72336 100644 --- a/src/feature-flags/feature-flags.ts +++ b/src/feature-flags/feature-flags.ts @@ -13,6 +13,7 @@ import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'; import { FeatureFlagsRuntimeClient } from './runtime-client'; import { ListOrganizationFeatureFlagsOptions } from '../organizations/interfaces/list-organization-feature-flags-options.interface'; import { ListUserFeatureFlagsOptions } from '../user-management/interfaces/list-user-feature-flags-options.interface'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class FeatureFlags { constructor(private readonly workos: WorkOS) {} @@ -62,7 +63,7 @@ export class FeatureFlags { */ async getFeatureFlag(slug: string): Promise { const { data } = await this.workos.get( - `/feature-flags/${slug}`, + `/feature-flags/${encodePathParameter(slug)}`, ); return deserializeFeatureFlag(data); @@ -82,7 +83,7 @@ export class FeatureFlags { */ async enableFeatureFlag(slug: string): Promise { const { data } = await this.workos.put( - `/feature-flags/${slug}/enable`, + `/feature-flags/${encodePathParameter(slug)}/enable`, {}, ); @@ -103,7 +104,7 @@ export class FeatureFlags { */ async disableFeatureFlag(slug: string): Promise { const { data } = await this.workos.put( - `/feature-flags/${slug}/disable`, + `/feature-flags/${encodePathParameter(slug)}/disable`, {}, ); @@ -122,7 +123,10 @@ export class FeatureFlags { */ async addFlagTarget(options: AddFlagTargetOptions): Promise { const { slug, targetId } = options; - await this.workos.post(`/feature-flags/${slug}/targets/${targetId}`, {}); + await this.workos.post( + `/feature-flags/${encodePathParameter(slug)}/targets/${encodePathParameter(targetId)}`, + {}, + ); } /** @@ -137,7 +141,9 @@ export class FeatureFlags { */ async removeFlagTarget(options: RemoveFlagTargetOptions): Promise { const { slug, targetId } = options; - await this.workos.delete(`/feature-flags/${slug}/targets/${targetId}`); + await this.workos.delete( + `/feature-flags/${encodePathParameter(slug)}/targets/${encodePathParameter(targetId)}`, + ); } /** @@ -156,14 +162,14 @@ export class FeatureFlags { return new AutoPaginatable( await fetchAndDeserialize( this.workos, - `/organizations/${organizationId}/feature-flags`, + `/organizations/${encodePathParameter(organizationId)}/feature-flags`, deserializeFeatureFlag, paginationOptions, ), (params) => fetchAndDeserialize( this.workos, - `/organizations/${organizationId}/feature-flags`, + `/organizations/${encodePathParameter(organizationId)}/feature-flags`, deserializeFeatureFlag, params, ), @@ -186,14 +192,14 @@ export class FeatureFlags { return new AutoPaginatable( await fetchAndDeserialize( this.workos, - `/user_management/users/${userId}/feature-flags`, + `/user_management/users/${encodePathParameter(userId)}/feature-flags`, deserializeFeatureFlag, paginationOptions, ), (params) => fetchAndDeserialize( this.workos, - `/user_management/users/${userId}/feature-flags`, + `/user_management/users/${encodePathParameter(userId)}/feature-flags`, deserializeFeatureFlag, params, ), diff --git a/src/multi-factor-auth/multi-factor-auth.spec.ts b/src/multi-factor-auth/multi-factor-auth.spec.ts index f2676d5f7..adb8fef50 100644 --- a/src/multi-factor-auth/multi-factor-auth.spec.ts +++ b/src/multi-factor-auth/multi-factor-auth.spec.ts @@ -1,5 +1,10 @@ import fetch from 'jest-fetch-mock'; -import { fetchOnce, fetchURL, fetchBody } from '../common/utils/test-utils'; +import { + fetchOnce, + fetchURL, + fetchBody, + fetchMethod, +} from '../common/utils/test-utils'; import { UnprocessableEntityException } from '../common/exceptions'; import { WorkOS } from '../workos'; @@ -549,4 +554,29 @@ describe('MFA', () => { }); }); }); + + describe('path parameter encoding', () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce(); + + await workos.multiFactorAuth.deleteFactor( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('DELETE'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/auth/factors/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect(workos.multiFactorAuth.deleteFactor('..')).rejects.toThrow( + TypeError, + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/multi-factor-auth/multi-factor-auth.ts b/src/multi-factor-auth/multi-factor-auth.ts index 006ac1222..396d9c4b1 100644 --- a/src/multi-factor-auth/multi-factor-auth.ts +++ b/src/multi-factor-auth/multi-factor-auth.ts @@ -34,6 +34,7 @@ import { import { serializeEnrollAuthFactorOptions } from '../user-management/serializers'; import { deserializeFactorWithSecrets as deserializeUMFactorWithSecrets } from '../user-management/serializers/authentication-factor.serializer'; import { deserializeFactor as deserializeUMFactor } from '../user-management/serializers/authentication-factor.serializer'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class MultiFactorAuth { constructor(private readonly workos: WorkOS) {} @@ -51,7 +52,7 @@ export class MultiFactorAuth { * @throws {NotFoundException} 404 */ async deleteFactor(id: string) { - await this.workos.delete(`/auth/factors/${id}`); + await this.workos.delete(`/auth/factors/${encodePathParameter(id)}`); } /** @@ -68,7 +69,7 @@ export class MultiFactorAuth { */ async getFactor(id: string): Promise { const { data } = await this.workos.get( - `/auth/factors/${id}`, + `/auth/factors/${encodePathParameter(id)}`, ); return deserializeFactor(data); @@ -119,7 +120,7 @@ export class MultiFactorAuth { */ async challengeFactor(options: ChallengeFactorOptions): Promise { const { data } = await this.workos.post( - `/auth/factors/${options.authenticationFactorId}/challenge`, + `/auth/factors/${encodePathParameter(options.authenticationFactorId)}/challenge`, { sms_template: 'smsTemplate' in options ? options.smsTemplate : undefined, @@ -143,7 +144,7 @@ export class MultiFactorAuth { options: VerifyChallengeOptions, ): Promise { const { data } = await this.workos.post( - `/auth/challenges/${options.authenticationChallengeId}/verify`, + `/auth/challenges/${encodePathParameter(options.authenticationChallengeId)}/verify`, { code: options.code, }, @@ -168,7 +169,7 @@ export class MultiFactorAuth { authentication_factor: UMFactorWithSecretsResponse; authentication_challenge: ChallengeResponse; }>( - `/user_management/users/${payload.userId}/auth_factors`, + `/user_management/users/${encodePathParameter(payload.userId)}/auth_factors`, serializeEnrollAuthFactorOptions(payload), ); @@ -197,14 +198,14 @@ export class MultiFactorAuth { return new AutoPaginatable( await fetchAndDeserialize( this.workos, - `/user_management/users/${userId}/auth_factors`, + `/user_management/users/${encodePathParameter(userId)}/auth_factors`, deserializeUMFactor, restOfOptions, ), (params) => fetchAndDeserialize( this.workos, - `/user_management/users/${userId}/auth_factors`, + `/user_management/users/${encodePathParameter(userId)}/auth_factors`, deserializeUMFactor, params, ), diff --git a/src/organization-domains/organization-domains.spec.ts b/src/organization-domains/organization-domains.spec.ts index b6ff09120..4cb3f5152 100644 --- a/src/organization-domains/organization-domains.spec.ts +++ b/src/organization-domains/organization-domains.spec.ts @@ -1,5 +1,10 @@ import fetch from 'jest-fetch-mock'; -import { fetchOnce, fetchURL, fetchBody } from '../common/utils/test-utils'; +import { + fetchOnce, + fetchURL, + fetchBody, + fetchMethod, +} from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import getOrganizationDomainPending from './fixtures/get-organization-domain-pending.json'; import getOrganizationDomainVerified from './fixtures/get-organization-domain-verified.json'; @@ -107,4 +112,27 @@ describe('OrganizationDomains', () => { ); }); }); + + describe('path parameter encoding', () => { + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce({}, { status: 204 }); + + await workos.organizationDomains.deleteOrganizationDomain( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('DELETE'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/organization_domains/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect( + workos.organizationDomains.verifyOrganizationDomain('..'), + ).rejects.toThrow(TypeError); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/organization-domains/organization-domains.ts b/src/organization-domains/organization-domains.ts index 67193ddcc..7af136bac 100644 --- a/src/organization-domains/organization-domains.ts +++ b/src/organization-domains/organization-domains.ts @@ -6,6 +6,7 @@ import { } from './interfaces'; import { serializeCreateOrganizationDomainOptions } from './serializers/create-organization-domain-options.serializer'; import { deserializeOrganizationDomain } from './serializers/organization-domain.serializer'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class OrganizationDomains { constructor(private readonly workos: WorkOS) {} @@ -24,7 +25,7 @@ export class OrganizationDomains { */ async getOrganizationDomain(id: string): Promise { const { data } = await this.workos.get( - `/organization_domains/${id}`, + `/organization_domains/${encodePathParameter(id)}`, ); return deserializeOrganizationDomain(data); @@ -44,7 +45,7 @@ export class OrganizationDomains { */ async verifyOrganizationDomain(id: string): Promise { const { data } = await this.workos.post( - `/organization_domains/${id}/verify`, + `/organization_domains/${encodePathParameter(id)}/verify`, {}, ); @@ -83,6 +84,8 @@ export class OrganizationDomains { * @throws {NotFoundException} 404 */ async deleteOrganizationDomain(id: string): Promise { - await this.workos.delete(`/organization_domains/${id}`); + await this.workos.delete( + `/organization_domains/${encodePathParameter(id)}`, + ); } } diff --git a/src/organizations/organizations.spec.ts b/src/organizations/organizations.spec.ts index cda48deba..5eeed4c18 100644 --- a/src/organizations/organizations.spec.ts +++ b/src/organizations/organizations.spec.ts @@ -464,4 +464,40 @@ describe('Organizations', () => { }); }); }); + + describe('path parameter encoding', () => { + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce({}, { status: 204 }); + + await workos.organizations.deleteOrganization( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('DELETE'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/organizations/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it('encodes identifiers passed through an options object', async () => { + fetchOnce({}, { status: 204 }); + + await workos.organizations.deleteItContact({ + organizationId: 'org_01EHT88Z8J8795GZNQ4ZP1J81T', + contactId: '../../../user_management/users/user_01VICTIM', + }); + + expect(new URL(String(fetchURL())).pathname).toBe( + '/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/it_contacts/..%2F..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect(workos.organizations.getOrganization('..')).rejects.toThrow( + TypeError, + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/organizations/organizations.ts b/src/organizations/organizations.ts index dc67c46e3..b32e9bf88 100644 --- a/src/organizations/organizations.ts +++ b/src/organizations/organizations.ts @@ -26,6 +26,7 @@ import { } from './serializers'; import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class Organizations { constructor(private readonly workos: WorkOS) {} @@ -95,7 +96,7 @@ export class Organizations { * @throws 403 response from the API. */ async deleteOrganization(id: string) { - await this.workos.delete(`/organizations/${id}`); + await this.workos.delete(`/organizations/${encodePathParameter(id)}`); } /** @@ -112,7 +113,7 @@ export class Organizations { */ async getOrganization(id: string): Promise { const { data } = await this.workos.get( - `/organizations/${id}`, + `/organizations/${encodePathParameter(id)}`, ); return deserializeOrganization(data); @@ -132,7 +133,7 @@ export class Organizations { */ async getOrganizationByExternalId(externalId: string): Promise { const { data } = await this.workos.get( - `/organizations/external_id/${externalId}`, + `/organizations/external_id/${encodePathParameter(externalId)}`, ); return deserializeOrganization(data); @@ -156,7 +157,7 @@ export class Organizations { const { organization: organizationId, ...payload } = options; const { data } = await this.workos.put( - `/organizations/${organizationId}`, + `/organizations/${encodePathParameter(organizationId)}`, serializeUpdateOrganizationOptions(payload), ); @@ -178,7 +179,7 @@ export class Organizations { const { organizationId } = options; const { data } = await this.workos.get>( - `/organizations/${organizationId}/it_contacts`, + `/organizations/${encodePathParameter(organizationId)}/it_contacts`, ); return { @@ -208,7 +209,7 @@ export class Organizations { const { organizationId, ...payload } = options; const { data } = await this.workos.post( - `/organizations/${organizationId}/it_contacts`, + `/organizations/${encodePathParameter(organizationId)}/it_contacts`, serializeCreateItContactOptions(payload), ); @@ -229,7 +230,7 @@ export class Organizations { const { organizationId, contactId } = options; await this.workos.delete( - `/organizations/${organizationId}/it_contacts/${contactId}`, + `/organizations/${encodePathParameter(organizationId)}/it_contacts/${encodePathParameter(contactId)}`, ); } @@ -249,7 +250,7 @@ export class Organizations { const { organizationId, contactId, ...payload } = options; await this.workos.post( - `/organizations/${organizationId}/it_contacts/${contactId}/invite`, + `/organizations/${encodePathParameter(organizationId)}/it_contacts/${encodePathParameter(contactId)}/invite`, serializeInviteItContactOptions(payload), ); } @@ -267,7 +268,7 @@ export class Organizations { const { organizationId, contactId } = options; await this.workos.post( - `/organizations/${organizationId}/it_contacts/${contactId}/revoke`, + `/organizations/${encodePathParameter(organizationId)}/it_contacts/${encodePathParameter(contactId)}/revoke`, {}, ); } diff --git a/src/passwordless/passwordless.spec.ts b/src/passwordless/passwordless.spec.ts index 0038b3b4f..3cb508815 100644 --- a/src/passwordless/passwordless.spec.ts +++ b/src/passwordless/passwordless.spec.ts @@ -1,5 +1,10 @@ import fetch from 'jest-fetch-mock'; -import { fetchOnce, fetchURL, fetchBody } from '../common/utils/test-utils'; +import { + fetchOnce, + fetchURL, + fetchBody, + fetchMethod, +} from '../common/utils/test-utils'; import createSession from './fixtures/create-session.json'; import { WorkOS } from '../workos'; @@ -48,4 +53,29 @@ describe('Passwordless', () => { }); }); }); + + describe('path parameter encoding', () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce({ success: true }); + + await workos.passwordless.sendSession( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('POST'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/passwordless/sessions/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM/send', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect(workos.passwordless.sendSession('..')).rejects.toThrow( + TypeError, + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/passwordless/passwordless.ts b/src/passwordless/passwordless.ts index 6f515940f..e420ac93d 100644 --- a/src/passwordless/passwordless.ts +++ b/src/passwordless/passwordless.ts @@ -8,6 +8,7 @@ import { SerializedCreatePasswordlessSessionOptions, } from './interfaces'; import { deserializePasswordlessSession } from './serializers/passwordless-session.serializer'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class Passwordless { constructor(private readonly workos: WorkOS) {} @@ -31,7 +32,7 @@ export class Passwordless { async sendSession(sessionId: string): Promise { const { data } = await this.workos.post( - `/passwordless/sessions/${sessionId}/send`, + `/passwordless/sessions/${encodePathParameter(sessionId)}/send`, {}, ); return data; diff --git a/src/sso/sso.spec.ts b/src/sso/sso.spec.ts index c4cad3bd1..aaea40c1b 100644 --- a/src/sso/sso.spec.ts +++ b/src/sso/sso.spec.ts @@ -5,6 +5,7 @@ import { fetchHeaders, fetchBody, fetchSearchParams, + fetchMethod, } from '../common/utils/test-utils'; import { WorkOS } from '../workos'; @@ -744,4 +745,27 @@ describe('SSO', () => { }); }); }); + + describe('path parameter encoding', () => { + const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce(); + + await workos.sso.deleteConnection( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('DELETE'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/connections/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect(workos.sso.getConnection('..')).rejects.toThrow(TypeError); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/sso/sso.ts b/src/sso/sso.ts index 5ec2e8ebb..79167b2df 100644 --- a/src/sso/sso.ts +++ b/src/sso/sso.ts @@ -23,6 +23,7 @@ import { deserializeProfileAndToken, serializeListConnectionsOptions, } from './serializers'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class SSO { constructor(private readonly workos: WorkOS) {} @@ -70,7 +71,7 @@ export class SSO { * @throws {NotFoundException} 404 */ async deleteConnection(id: string) { - await this.workos.delete(`/connections/${id}`); + await this.workos.delete(`/connections/${encodePathParameter(id)}`); } // @oagen-ignore-start @@ -206,7 +207,7 @@ export class SSO { */ async getConnection(id: string): Promise { const { data } = await this.workos.get( - `/connections/${id}`, + `/connections/${encodePathParameter(id)}`, ); return deserializeConnection(data); diff --git a/src/user-management/user-management.spec.ts b/src/user-management/user-management.spec.ts index 726954064..d8d703177 100644 --- a/src/user-management/user-management.spec.ts +++ b/src/user-management/user-management.spec.ts @@ -2,6 +2,7 @@ import fetch from 'jest-fetch-mock'; import { fetchBody, fetchHeaders, + fetchMethod, fetchOnce, fetchSearchParams, fetchURL, @@ -2028,6 +2029,20 @@ describe('UserManagement', () => { }); }); + it('encodes the userId so it cannot escape the route template', async () => { + fetchOnce(userFixture); + + await workos.userManagement.updateUser({ + userId: '../../organizations/org_01TARGET?', + firstName: 'Dane', + }); + + const url = new URL(fetchURL() as string); + expect(url.pathname).toBe( + '/user_management/users/..%2F..%2Forganizations%2Forg_01TARGET%3F', + ); + }); + describe('when only one property is provided', () => { it('sends a updateUser request', async () => { fetchOnce(userFixture); @@ -3480,4 +3495,51 @@ describe('UserManagement', () => { }).toThrow(TypeError); }); }); + + describe('path parameter encoding', () => { + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce(); + + await workos.userManagement.deleteUser( + '../../organizations/org_01VICTIM', + ); + + expect(fetchMethod()).toBe('DELETE'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/user_management/users/..%2F..%2Forganizations%2Forg_01VICTIM', + ); + }); + + it('encodes the user id of nested user resources', async () => { + fetchOnce(listUserApiKeysFixture); + + await workos.userManagement.listUserApiKeys( + '../../organizations/org_01VICTIM', + ); + + expect(new URL(String(fetchURL())).pathname).toBe( + '/user_management/users/..%2F..%2Forganizations%2Forg_01VICTIM/api_keys', + ); + }); + + it('encodes waitlist entry identifiers', async () => { + fetchOnce({}, { status: 204 }); + + await workos.userManagement.deleteWaitlistEntry( + '../../organizations/org_01VICTIM', + ); + + expect(new URL(String(fetchURL())).pathname).toBe( + '/user_management/waitlist_entries/..%2F..%2Forganizations%2Forg_01VICTIM', + ); + }); + + it('rejects a dot-only segment before sending a request', async () => { + await expect( + workos.userManagement.findInvitationByToken('..'), + ).rejects.toThrow(TypeError); + + expect(fetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/user-management/user-management.ts b/src/user-management/user-management.ts index 6bd954b4c..b095ee115 100644 --- a/src/user-management/user-management.ts +++ b/src/user-management/user-management.ts @@ -194,6 +194,7 @@ import { CookieSession } from './session'; import { getJose } from '../utils/jose'; import { Group, GroupResponse } from '../groups/interfaces'; import { deserializeGroup } from '../groups/serializers'; +import { encodePathParameter } from '../common/utils/encode-path-parameter'; export class UserManagement { // @oagen-ignore-start @@ -267,7 +268,7 @@ export class UserManagement { */ async getUser(userId: string): Promise { const { data } = await this.workos.get( - `/user_management/users/${userId}`, + `/user_management/users/${encodePathParameter(userId)}`, ); return deserializeUser(data); @@ -287,7 +288,7 @@ export class UserManagement { */ async getUserByExternalId(externalId: string): Promise { const { data } = await this.workos.get( - `/user_management/users/external_id/${externalId}`, + `/user_management/users/external_id/${encodePathParameter(externalId)}`, ); return deserializeUser(data); @@ -855,7 +856,7 @@ export class UserManagement { emailVerificationId: string, ): Promise { const { data } = await this.workos.get( - `/user_management/email_verification/${emailVerificationId}`, + `/user_management/email_verification/${encodePathParameter(emailVerificationId)}`, ); return deserializeEmailVerification(data); @@ -874,7 +875,7 @@ export class UserManagement { userId, }: SendVerificationEmailOptions): Promise<{ user: User }> { const { data } = await this.workos.post<{ user: UserResponse }>( - `/user_management/users/${userId}/email_verification/send`, + `/user_management/users/${encodePathParameter(userId)}/email_verification/send`, {}, ); @@ -890,7 +891,7 @@ export class UserManagement { */ async getMagicAuth(magicAuthId: string): Promise { const { data } = await this.workos.get( - `/user_management/magic_auth/${magicAuthId}`, + `/user_management/magic_auth/${encodePathParameter(magicAuthId)}`, ); return deserializeMagicAuth(data); @@ -945,9 +946,12 @@ export class UserManagement { const { data } = await this.workos.post< { user: UserResponse }, SerializedVerifyEmailOptions - >(`/user_management/users/${userId}/email_verification/confirm`, { - code, - }); + >( + `/user_management/users/${encodePathParameter(userId)}/email_verification/confirm`, + { + code, + }, + ); return { user: deserializeUser(data.user) }; } @@ -961,7 +965,7 @@ export class UserManagement { */ async getPasswordReset(passwordResetId: string): Promise { const { data } = await this.workos.get( - `/user_management/password_reset/${passwordResetId}`, + `/user_management/password_reset/${encodePathParameter(passwordResetId)}`, ); return deserializePasswordReset(data); @@ -1019,7 +1023,7 @@ export class UserManagement { */ async updateUser(payload: UpdateUserOptions): Promise { const { data } = await this.workos.put( - `/user_management/users/${payload.userId}`, + `/user_management/users/${encodePathParameter(payload.userId)}`, serializeUpdateUserOptions(payload), ); @@ -1042,14 +1046,14 @@ export class UserManagement { return new AutoPaginatable( await fetchAndDeserialize( this.workos, - `/user_management/users/${userId}/sessions`, + `/user_management/users/${encodePathParameter(userId)}/sessions`, deserializeSession, options ? serializeListSessionsOptions(options) : undefined, ), (params) => fetchAndDeserialize( this.workos, - `/user_management/users/${userId}/sessions`, + `/user_management/users/${encodePathParameter(userId)}/sessions`, deserializeSession, params, ), @@ -1065,7 +1069,9 @@ export class UserManagement { * @throws {NotFoundException} 404 */ async deleteUser(userId: string) { - await this.workos.delete(`/user_management/users/${userId}`); + await this.workos.delete( + `/user_management/users/${encodePathParameter(userId)}`, + ); } /** @@ -1088,14 +1094,14 @@ export class UserManagement { return new AutoPaginatable( await fetchAndDeserialize( this.workos, - `/user_management/users/${userId}/api_keys`, + `/user_management/users/${encodePathParameter(userId)}/api_keys`, deserializeUserApiKey, serializedOptions, ), (params) => fetchAndDeserialize( this.workos, - `/user_management/users/${userId}/api_keys`, + `/user_management/users/${encodePathParameter(userId)}/api_keys`, deserializeUserApiKey, params, ), @@ -1123,7 +1129,7 @@ export class UserManagement { SerializedUserApiKeyWithValue, ReturnType >( - `/user_management/users/${userId}/api_keys`, + `/user_management/users/${encodePathParameter(userId)}/api_keys`, serializeCreateUserApiKeyOptions(options), requestOptions, ); @@ -1144,7 +1150,7 @@ export class UserManagement { } const { data } = await this.workos.get( - `/user_management/users/${userId}/identities`, + `/user_management/users/${encodePathParameter(userId)}/identities`, ); return deserializeIdentities(data); @@ -1161,7 +1167,7 @@ export class UserManagement { organizationMembershipId: string, ): Promise { const { data } = await this.workos.get( - `/user_management/organization_memberships/${organizationMembershipId}`, + `/user_management/organization_memberships/${encodePathParameter(organizationMembershipId)}`, ); return deserializeOrganizationMembership(data); @@ -1255,7 +1261,7 @@ export class UserManagement { OrganizationMembershipResponse, SerializedUpdateOrganizationMembershipOptions >( - `/user_management/organization_memberships/${organizationMembershipId}`, + `/user_management/organization_memberships/${encodePathParameter(organizationMembershipId)}`, serializeUpdateOrganizationMembershipOptions(options), ); @@ -1273,7 +1279,7 @@ export class UserManagement { organizationMembershipId: string, ): Promise { await this.workos.delete( - `/user_management/organization_memberships/${organizationMembershipId}`, + `/user_management/organization_memberships/${encodePathParameter(organizationMembershipId)}`, ); } @@ -1295,7 +1301,7 @@ export class UserManagement { organizationMembershipId: string, ): Promise { const { data } = await this.workos.put( - `/user_management/organization_memberships/${organizationMembershipId}/deactivate`, + `/user_management/organization_memberships/${encodePathParameter(organizationMembershipId)}/deactivate`, {}, ); @@ -1320,7 +1326,7 @@ export class UserManagement { organizationMembershipId: string, ): Promise { const { data } = await this.workos.put( - `/user_management/organization_memberships/${organizationMembershipId}/reactivate`, + `/user_management/organization_memberships/${encodePathParameter(organizationMembershipId)}/reactivate`, {}, ); @@ -1331,7 +1337,7 @@ export class UserManagement { options: ListGroupsForOrganizationMembershipOptions, ): Promise> { const { organizationMembershipId, ...paginationOptions } = options; - const endpoint = `/user_management/organization_memberships/${organizationMembershipId}/groups`; + const endpoint = `/user_management/organization_memberships/${encodePathParameter(organizationMembershipId)}/groups`; return new AutoPaginatable( await fetchAndDeserialize( @@ -1353,7 +1359,7 @@ export class UserManagement { async getInvitation(invitationId: string): Promise { const { data } = await this.workos.get( - `/user_management/invitations/${invitationId}`, + `/user_management/invitations/${encodePathParameter(invitationId)}`, ); return deserializeInvitation(data); @@ -1368,7 +1374,7 @@ export class UserManagement { */ async findInvitationByToken(invitationToken: string): Promise { const { data } = await this.workos.get( - `/user_management/invitations/by_token/${invitationToken}`, + `/user_management/invitations/by_token/${encodePathParameter(invitationToken)}`, ); return deserializeInvitation(data); @@ -1437,7 +1443,7 @@ export class UserManagement { */ async acceptInvitation(invitationId: string): Promise { const { data } = await this.workos.post( - `/user_management/invitations/${invitationId}/accept`, + `/user_management/invitations/${encodePathParameter(invitationId)}/accept`, null, ); @@ -1453,7 +1459,7 @@ export class UserManagement { */ async revokeInvitation(invitationId: string): Promise { const { data } = await this.workos.post( - `/user_management/invitations/${invitationId}/revoke`, + `/user_management/invitations/${encodePathParameter(invitationId)}/revoke`, null, ); @@ -1478,7 +1484,7 @@ export class UserManagement { InvitationResponse, SerializedResendInvitationOptions >( - `/user_management/invitations/${invitationId}/resend`, + `/user_management/invitations/${encodePathParameter(invitationId)}/resend`, options ? serializeResendInvitationOptions(options) : {}, ); @@ -1510,7 +1516,7 @@ export class UserManagement { */ async getWaitlist(waitlistId: string): Promise { const { data } = await this.workos.get( - `/user_management/waitlists/${waitlistId}`, + `/user_management/waitlists/${encodePathParameter(waitlistId)}`, ); return deserializeWaitlist(data); @@ -1537,14 +1543,14 @@ export class UserManagement { return new AutoPaginatable( await fetchAndDeserialize( this.workos, - `/user_management/waitlists/${waitlistId}/entries`, + `/user_management/waitlists/${encodePathParameter(waitlistId)}/entries`, deserializeWaitlistEntry, options ? serializeListWaitlistEntriesOptions(options) : undefined, ), (params) => fetchAndDeserialize( this.workos, - `/user_management/waitlists/${waitlistId}/entries`, + `/user_management/waitlists/${encodePathParameter(waitlistId)}/entries`, deserializeWaitlistEntry, params, ), @@ -1573,7 +1579,7 @@ export class UserManagement { WaitlistEntryResponse, SerializedCreateWaitlistEntryOptions >( - `/user_management/waitlists/${waitlistId}/entries`, + `/user_management/waitlists/${encodePathParameter(waitlistId)}/entries`, serializeCreateWaitlistEntryOptions(payload), ); @@ -1591,7 +1597,7 @@ export class UserManagement { */ async approveWaitlistEntry(waitlistEntryId: string): Promise { const { data } = await this.workos.post( - `/user_management/waitlist_entries/${waitlistEntryId}/approve`, + `/user_management/waitlist_entries/${encodePathParameter(waitlistEntryId)}/approve`, null, ); @@ -1608,7 +1614,7 @@ export class UserManagement { */ async denyWaitlistEntry(waitlistEntryId: string): Promise { const { data } = await this.workos.post( - `/user_management/waitlist_entries/${waitlistEntryId}/deny`, + `/user_management/waitlist_entries/${encodePathParameter(waitlistEntryId)}/deny`, null, ); @@ -1626,7 +1632,7 @@ export class UserManagement { */ async deleteWaitlistEntry(waitlistEntryId: string): Promise { await this.workos.delete( - `/user_management/waitlist_entries/${waitlistEntryId}`, + `/user_management/waitlist_entries/${encodePathParameter(waitlistEntryId)}`, ); } From c31fd9b3605b1d449af190ae98f25cd88ebef2ec Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 17 Sep 2026 10:17:00 -0400 Subject: [PATCH 2/3] fix(api-keys): encode path parameters in generated api-keys module 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. --- src/api-keys/api-keys.spec.ts | 28 ++++++++++++++++++++++++++++ src/api-keys/api-keys.ts | 8 ++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/api-keys/api-keys.spec.ts b/src/api-keys/api-keys.spec.ts index f7229a194..cbd852c0c 100644 --- a/src/api-keys/api-keys.spec.ts +++ b/src/api-keys/api-keys.spec.ts @@ -5,6 +5,7 @@ import { fetchSearchParams, fetchHeaders, fetchBody, + fetchMethod, } from '../common/utils/test-utils'; import { WorkOS } from '../workos'; import validateApiKeyFixture from './fixtures/validate-api-key.json'; @@ -277,4 +278,31 @@ describe('ApiKeys', () => { }); }); }); + + describe('path parameter encoding', () => { + it('keeps a traversal payload inside a single path segment', async () => { + fetchOnce({}, { status: 204 }); + + await workos.apiKeys.deleteApiKey( + '../../user_management/users/user_01VICTIM', + ); + + expect(fetchMethod()).toBe('DELETE'); + expect(new URL(String(fetchURL())).pathname).toBe( + '/api_keys/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it('encodes the organization id in the API keys path', async () => { + fetchOnce(listOrganizationApiKeysFixture); + + await workos.apiKeys.listOrganizationApiKeys({ + organizationId: '../../user_management/users/user_01VICTIM', + }); + + expect(new URL(String(fetchURL())).pathname).toBe( + '/organizations/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM/api_keys', + ); + }); + }); }); diff --git a/src/api-keys/api-keys.ts b/src/api-keys/api-keys.ts index 78b074996..146dbc755 100644 --- a/src/api-keys/api-keys.ts +++ b/src/api-keys/api-keys.ts @@ -60,7 +60,7 @@ export class ApiKeys { * @throws {NotFoundException} 404 */ async deleteApiKey(id: string): Promise { - await this.workos.delete(`/api_keys/${id}`); + await this.workos.delete(`/api_keys/${encodeURIComponent(id)}`); } /** @@ -84,14 +84,14 @@ export class ApiKeys { return new AutoPaginatable( await fetchAndDeserialize( this.workos, - `/organizations/${organizationId}/api_keys`, + `/organizations/${encodeURIComponent(organizationId)}/api_keys`, deserializeApiKey, paginationOptions, ), (params) => fetchAndDeserialize( this.workos, - `/organizations/${organizationId}/api_keys`, + `/organizations/${encodeURIComponent(organizationId)}/api_keys`, deserializeApiKey, params, ), @@ -120,7 +120,7 @@ export class ApiKeys { const { organizationId } = options; const { data } = await this.workos.post( - `/organizations/${organizationId}/api_keys`, + `/organizations/${encodeURIComponent(organizationId)}/api_keys`, serializeCreateOrganizationApiKeyOptions(options), requestOptions, ); From 18841320b06bbdc9688d895b96b934ae21cd3b9b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 17 Sep 2026 10:24:53 -0400 Subject: [PATCH 3/3] fix(http-client): reject dot segments in request paths 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. --- src/api-keys/api-keys.spec.ts | 24 +++++++++++ src/common/net/http-client.spec.ts | 66 ++++++++++++++++++++++++++++++ src/common/net/http-client.ts | 30 ++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 src/common/net/http-client.spec.ts diff --git a/src/api-keys/api-keys.spec.ts b/src/api-keys/api-keys.spec.ts index cbd852c0c..ffe6c07bf 100644 --- a/src/api-keys/api-keys.spec.ts +++ b/src/api-keys/api-keys.spec.ts @@ -304,5 +304,29 @@ describe('ApiKeys', () => { '/organizations/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM/api_keys', ); }); + + it('rejects a dot-only API key id before sending a request', async () => { + // Bare `encodeURIComponent` leaves `..` unchanged; the HttpClient guard + // refuses to build the URL, and the WorkOS request wrapper surfaces that + // TypeError as the cause of its generic error. + const request = workos.apiKeys.deleteApiKey('..'); + + await expect(request).rejects.toThrow( + 'a path segment must not be "." or ".."', + ); + await expect(request).rejects.toMatchObject({ + cause: expect.any(TypeError), + }); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects a dot-only organization id before sending a request', async () => { + await expect( + workos.apiKeys.listOrganizationApiKeys({ organizationId: '..' }), + ).rejects.toThrow('a path segment must not be "." or ".."'); + + expect(fetch).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/common/net/http-client.spec.ts b/src/common/net/http-client.spec.ts new file mode 100644 index 000000000..a7b3e9f0e --- /dev/null +++ b/src/common/net/http-client.spec.ts @@ -0,0 +1,66 @@ +import { HttpClient } from './http-client'; + +describe('HttpClient', () => { + describe('getResourceURL', () => { + const baseURL = 'https://api.workos.com'; + + it('joins the path and query string onto the base URL', () => { + expect( + HttpClient.getResourceURL(baseURL, '/organizations/org_01ABC', { + limit: 10, + after: undefined, + }), + ).toBe('https://api.workos.com/organizations/org_01ABC?limit=10'); + }); + + it('keeps an encoded identifier inside its own segment', () => { + expect( + HttpClient.getResourceURL( + baseURL, + `/api_keys/${encodeURIComponent('../../user_management/users/user_01VICTIM')}`, + ), + ).toBe( + 'https://api.workos.com/api_keys/..%2F..%2Fuser_management%2Fusers%2Fuser_01VICTIM', + ); + }); + + it.each(['.', '..', '%2e', '%2E%2e', '.%2e', '%2e.'])( + 'rejects the dot segment "%s" that the URL parser would collapse', + (segment) => { + // A bare `encodeURIComponent` leaves `.` and `..` unchanged, so a + // generated module would otherwise send `/organizations/../api_keys` + // and have the URL parser resolve it to `/api_keys`. + expect(() => + HttpClient.getResourceURL( + baseURL, + `/organizations/${segment}/api_keys`, + ), + ).toThrow(TypeError); + expect(() => + HttpClient.getResourceURL(baseURL, `/api_keys/${segment}`), + ).toThrow(TypeError); + }, + ); + + it('allows segments that merely contain dots or encoded dots', () => { + expect(() => + HttpClient.getResourceURL(baseURL, '/feature-flags/..foo/enable'), + ).not.toThrow(); + expect(() => + HttpClient.getResourceURL(baseURL, '/feature-flags/v1.2.3'), + ).not.toThrow(); + expect(() => + HttpClient.getResourceURL(baseURL, '/api_keys/%252e%252e'), + ).not.toThrow(); + expect(() => + HttpClient.getResourceURL(baseURL, '/api_keys/a%2F..'), + ).not.toThrow(); + }); + + it('only inspects the path portion, not the query string', () => { + expect(() => + HttpClient.getResourceURL(baseURL, '/audit_logs/events?after=..'), + ).not.toThrow(); + }); + }); +}); diff --git a/src/common/net/http-client.ts b/src/common/net/http-client.ts index ea696acac..92538d25d 100644 --- a/src/common/net/http-client.ts +++ b/src/common/net/http-client.ts @@ -79,11 +79,41 @@ export abstract class HttpClient implements HttpClientInterface { path: string, params?: Record, ) { + HttpClient.assertNoDotSegments(path); const queryString = HttpClient.getQueryString(params); const url = new URL([path, queryString].filter(Boolean).join('?'), baseURL); return url.toString(); } + /** + * Reject request paths that contain a single-dot or double-dot segment. + * + * SDK path templates never contain such segments themselves, but a + * caller-supplied identifier interpolated with bare `encodeURIComponent` + * (the form the generated modules use) passes `.` and `..` through + * unchanged, and the WHATWG `URL` parser then collapses them, retargeting + * the request to a different endpoint (for example + * `/organizations/../api_keys` resolves to `/api_keys`). The parser also + * treats the percent-encoded forms (`%2e`, case-insensitive) as dots, so + * those are matched as well. Encoded values never trip this check because + * `encodeURIComponent` turns `%` into `%25` and `/` into `%2F`, keeping + * each identifier inside its own segment. Fail closed before any request + * is built. + */ + private static assertNoDotSegments(path: string): void { + const pathOnly = path.split(/[?#]/, 1)[0]; + + for (const segment of pathOnly.split('/')) { + const normalized = segment.replace(/%2e/gi, '.'); + + if (normalized === '.' || normalized === '..') { + throw new TypeError( + `Invalid request path "${path}": a path segment must not be "." or "..".`, + ); + } + } + } + static getQueryString(queryObj?: Record) { if (!queryObj) return undefined;