Skip to content
Open
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
9 changes: 5 additions & 4 deletions src/api-keys/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from './interfaces/validate-api-key.interface';
import { deserializeValidateApiKeyResponse } from './serializers/validate-api-key.serializer';
import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';
import { encodePathParameter } from '../common/utils/encode-path-parameter';

export class ApiKeys {
constructor(private readonly workos: WorkOS) {}
Expand Down Expand Up @@ -60,7 +61,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/${encodePathParameter(id)}`);
}

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

const { data } = await this.workos.post<SerializedCreatedApiKey>(
`/organizations/${organizationId}/api_keys`,
`/organizations/${encodePathParameter(organizationId)}/api_keys`,
serializeCreateOrganizationApiKeyOptions(options),
requestOptions,
);
Expand Down
7 changes: 4 additions & 3 deletions src/audit-logs/audit-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
serializeCreateAuditLogEventOptions,
serializeCreateAuditLogSchemaOptions,
} from './serializers';
import { encodePathParameter } from '../common/utils/encode-path-parameter';

export class AuditLogs {
constructor(private readonly workos: WorkOS) {}
Expand Down Expand Up @@ -102,7 +103,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 @@ -121,7 +122,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 @@ -133,7 +134,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
84 changes: 45 additions & 39 deletions src/authorization/authorization.ts

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/common/utils/encode-path-parameter.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
41 changes: 41 additions & 0 deletions src/common/utils/encode-path-parameter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 5 additions & 4 deletions src/directory-sync/directory-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -69,7 +70,7 @@ export class DirectorySync {
*/
async getDirectory(id: string): Promise<Directory> {
const { data } = await this.workos.get<DirectoryResponse>(
`/directories/${id}`,
`/directories/${encodePathParameter(id)}`,
);

return deserializeDirectory(data);
Expand All @@ -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)}`);
}

/**
Expand Down Expand Up @@ -182,7 +183,7 @@ export class DirectorySync {
): Promise<DirectoryUserWithGroups<TCustomAttributes>> {
const { data } = await this.workos.get<
DirectoryUserWithGroupsResponse<TCustomAttributes>
>(`/directory_users/${user}`);
>(`/directory_users/${encodePathParameter(user)}`);

return deserializeDirectoryUserWithGroups(data);
}
Expand All @@ -201,7 +202,7 @@ export class DirectorySync {
*/
async getGroup(group: string): Promise<DirectoryGroup> {
const { data } = await this.workos.get<DirectoryGroupResponse>(
`/directory_groups/${group}`,
`/directory_groups/${encodePathParameter(group)}`,
);

return deserializeDirectoryGroup(data);
Expand Down
24 changes: 15 additions & 9 deletions src/feature-flags/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand Down Expand Up @@ -62,7 +63,7 @@ export class FeatureFlags {
*/
async getFeatureFlag(slug: string): Promise<FeatureFlag> {
const { data } = await this.workos.get<FeatureFlagResponse>(
`/feature-flags/${slug}`,
`/feature-flags/${encodePathParameter(slug)}`,
);

return deserializeFeatureFlag(data);
Expand All @@ -82,7 +83,7 @@ export class FeatureFlags {
*/
async enableFeatureFlag(slug: string): Promise<FeatureFlag> {
const { data } = await this.workos.put<FeatureFlagResponse>(
`/feature-flags/${slug}/enable`,
`/feature-flags/${encodePathParameter(slug)}/enable`,
{},
);

Expand All @@ -103,7 +104,7 @@ export class FeatureFlags {
*/
async disableFeatureFlag(slug: string): Promise<FeatureFlag> {
const { data } = await this.workos.put<FeatureFlagResponse>(
`/feature-flags/${slug}/disable`,
`/feature-flags/${encodePathParameter(slug)}/disable`,
{},
);

Expand All @@ -122,7 +123,10 @@ export class FeatureFlags {
*/
async addFlagTarget(options: AddFlagTargetOptions): Promise<void> {
const { slug, targetId } = options;
await this.workos.post(`/feature-flags/${slug}/targets/${targetId}`, {});
await this.workos.post(
`/feature-flags/${encodePathParameter(slug)}/targets/${encodePathParameter(targetId)}`,
{},
);
}

/**
Expand All @@ -137,7 +141,9 @@ export class FeatureFlags {
*/
async removeFlagTarget(options: RemoveFlagTargetOptions): Promise<void> {
const { slug, targetId } = options;
await this.workos.delete(`/feature-flags/${slug}/targets/${targetId}`);
await this.workos.delete(
`/feature-flags/${encodePathParameter(slug)}/targets/${encodePathParameter(targetId)}`,
);
}

/**
Expand All @@ -156,14 +162,14 @@ export class FeatureFlags {
return new AutoPaginatable(
await fetchAndDeserialize<FeatureFlagResponse, FeatureFlag>(
this.workos,
`/organizations/${organizationId}/feature-flags`,
`/organizations/${encodePathParameter(organizationId)}/feature-flags`,
deserializeFeatureFlag,
paginationOptions,
),
(params) =>
fetchAndDeserialize<FeatureFlagResponse, FeatureFlag>(
this.workos,
`/organizations/${organizationId}/feature-flags`,
`/organizations/${encodePathParameter(organizationId)}/feature-flags`,
deserializeFeatureFlag,
params,
),
Expand All @@ -186,14 +192,14 @@ export class FeatureFlags {
return new AutoPaginatable(
await fetchAndDeserialize<FeatureFlagResponse, FeatureFlag>(
this.workos,
`/user_management/users/${userId}/feature-flags`,
`/user_management/users/${encodePathParameter(userId)}/feature-flags`,
deserializeFeatureFlag,
paginationOptions,
),
(params) =>
fetchAndDeserialize<FeatureFlagResponse, FeatureFlag>(
this.workos,
`/user_management/users/${userId}/feature-flags`,
`/user_management/users/${encodePathParameter(userId)}/feature-flags`,
deserializeFeatureFlag,
params,
),
Expand Down
15 changes: 8 additions & 7 deletions src/multi-factor-auth/multi-factor-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Expand All @@ -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)}`);
}

/**
Expand All @@ -68,7 +69,7 @@ export class MultiFactorAuth {
*/
async getFactor(id: string): Promise<Factor> {
const { data } = await this.workos.get<FactorResponse>(
`/auth/factors/${id}`,
`/auth/factors/${encodePathParameter(id)}`,
);

return deserializeFactor(data);
Expand Down Expand Up @@ -119,7 +120,7 @@ export class MultiFactorAuth {
*/
async challengeFactor(options: ChallengeFactorOptions): Promise<Challenge> {
const { data } = await this.workos.post<ChallengeResponse>(
`/auth/factors/${options.authenticationFactorId}/challenge`,
`/auth/factors/${encodePathParameter(options.authenticationFactorId)}/challenge`,
{
sms_template:
'smsTemplate' in options ? options.smsTemplate : undefined,
Expand All @@ -143,7 +144,7 @@ export class MultiFactorAuth {
options: VerifyChallengeOptions,
): Promise<VerifyResponse> {
const { data } = await this.workos.post<VerifyResponseResponse>(
`/auth/challenges/${options.authenticationChallengeId}/verify`,
`/auth/challenges/${encodePathParameter(options.authenticationChallengeId)}/verify`,
{
code: options.code,
},
Expand All @@ -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),
);

Expand Down Expand Up @@ -197,14 +198,14 @@ export class MultiFactorAuth {
return new AutoPaginatable(
await fetchAndDeserialize<UMFactorResponse, UMFactor>(
this.workos,
`/user_management/users/${userId}/auth_factors`,
`/user_management/users/${encodePathParameter(userId)}/auth_factors`,
deserializeUMFactor,
restOfOptions,
),
(params) =>
fetchAndDeserialize<UMFactorResponse, UMFactor>(
this.workos,
`/user_management/users/${userId}/auth_factors`,
`/user_management/users/${encodePathParameter(userId)}/auth_factors`,
deserializeUMFactor,
params,
),
Expand Down
Loading