Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/api-keys/api-keys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
});
8 changes: 4 additions & 4 deletions src/api-keys/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class ApiKeys {
* @throws {NotFoundException} 404
*/
async deleteApiKey(id: string): Promise<void> {
await this.workos.delete(`/api_keys/${id}`);
await this.workos.delete(`/api_keys/${encodeURIComponent(id)}`);
Comment thread
gjtorikian marked this conversation as resolved.
}

/**
Expand All @@ -84,14 +84,14 @@ export class ApiKeys {
return new AutoPaginatable(
await fetchAndDeserialize<SerializedApiKey, ApiKey>(
this.workos,
`/organizations/${organizationId}/api_keys`,
`/organizations/${encodeURIComponent(organizationId)}/api_keys`,
deserializeApiKey,
paginationOptions,
),
(params) =>
fetchAndDeserialize<SerializedApiKey, ApiKey>(
this.workos,
`/organizations/${organizationId}/api_keys`,
`/organizations/${encodeURIComponent(organizationId)}/api_keys`,
deserializeApiKey,
params,
),
Expand Down Expand Up @@ -120,7 +120,7 @@ export class ApiKeys {
const { organizationId } = options;

const { data } = await this.workos.post<SerializedCreatedApiKey>(
`/organizations/${organizationId}/api_keys`,
`/organizations/${encodeURIComponent(organizationId)}/api_keys`,
serializeCreateOrganizationApiKeyOptions(options),
requestOptions,
);
Expand Down
32 changes: 32 additions & 0 deletions src/audit-logs/audit-logs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
7 changes: 4 additions & 3 deletions src/audit-logs/audit-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -158,7 +159,7 @@ export class AuditLogs {
*/
async getExport(auditLogExportId: string): Promise<AuditLogExport> {
const { data } = await this.workos.get<AuditLogExportResponse>(
`/audit_logs/exports/${auditLogExportId}`,
`/audit_logs/exports/${encodePathParameter(auditLogExportId)}`,
);

return deserializeAuditLogExport(data);
Expand All @@ -177,7 +178,7 @@ export class AuditLogs {
options: CreateAuditLogSchemaRequestOptions = {},
): Promise<AuditLogSchema> {
const { data } = await this.workos.post<CreateAuditLogSchemaResponse>(
`/audit_logs/actions/${schema.action}/schemas`,
`/audit_logs/actions/${encodePathParameter(schema.action)}/schemas`,
serializeCreateAuditLogSchemaOptions(schema),
options,
);
Expand All @@ -189,7 +190,7 @@ export class AuditLogs {
action: string,
options?: PaginationOptions,
): Promise<AutoPaginatable<AuditLogSchema, PaginationOptions>> {
const endpoint = `/audit_logs/actions/${action}/schemas`;
const endpoint = `/audit_logs/actions/${encodePathParameter(action)}/schemas`;

return new AutoPaginatable(
await fetchAndDeserialize<AuditLogSchemaResponse, AuditLogSchema>(
Expand Down
38 changes: 38 additions & 0 deletions src/authorization/authorization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading