diff --git a/src/api-keys/api-keys.spec.ts b/src/api-keys/api-keys.spec.ts index f7229a194..ffe6c07bf 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,55 @@ 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', + ); + }); + + 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/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, ); 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/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; 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)}`, ); }