Skip to content

Commit 64f7cdd

Browse files
pcarletongithub-advanced-security[bot]claude
authored
restrict url schemes allowed in oauth metadata (#877)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Claude <[email protected]>
1 parent abb2f0a commit 64f7cdd

File tree

2 files changed

+157
-18
lines changed

2 files changed

+157
-18
lines changed

src/shared/auth.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, it, expect } from '@jest/globals';
2+
import {
3+
SafeUrlSchema,
4+
OAuthMetadataSchema,
5+
OpenIdProviderMetadataSchema,
6+
OAuthClientMetadataSchema,
7+
} from './auth.js';
8+
9+
describe('SafeUrlSchema', () => {
10+
it('accepts valid HTTPS URLs', () => {
11+
expect(SafeUrlSchema.parse('https://example.com')).toBe('https://example.com');
12+
expect(SafeUrlSchema.parse('https://auth.example.com/oauth/authorize')).toBe('https://auth.example.com/oauth/authorize');
13+
});
14+
15+
it('accepts valid HTTP URLs', () => {
16+
expect(SafeUrlSchema.parse('http://localhost:3000')).toBe('http://localhost:3000');
17+
});
18+
19+
it('rejects javascript: scheme URLs', () => {
20+
expect(() => SafeUrlSchema.parse('javascript:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
21+
expect(() => SafeUrlSchema.parse('JAVASCRIPT:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
22+
});
23+
24+
it('rejects invalid URLs', () => {
25+
expect(() => SafeUrlSchema.parse('not-a-url')).toThrow();
26+
expect(() => SafeUrlSchema.parse('')).toThrow();
27+
});
28+
29+
it('works with safeParse', () => {
30+
expect(() => SafeUrlSchema.safeParse('not-a-url')).not.toThrow();
31+
});
32+
});
33+
34+
describe('OAuthMetadataSchema', () => {
35+
it('validates complete OAuth metadata', () => {
36+
const metadata = {
37+
issuer: 'https://auth.example.com',
38+
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
39+
token_endpoint: 'https://auth.example.com/oauth/token',
40+
response_types_supported: ['code'],
41+
scopes_supported: ['read', 'write'],
42+
};
43+
44+
expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow();
45+
});
46+
47+
it('rejects metadata with javascript: URLs', () => {
48+
const metadata = {
49+
issuer: 'https://auth.example.com',
50+
authorization_endpoint: 'javascript:alert(1)',
51+
token_endpoint: 'https://auth.example.com/oauth/token',
52+
response_types_supported: ['code'],
53+
};
54+
55+
expect(() => OAuthMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
56+
});
57+
58+
it('requires mandatory fields', () => {
59+
const incompleteMetadata = {
60+
issuer: 'https://auth.example.com',
61+
};
62+
63+
expect(() => OAuthMetadataSchema.parse(incompleteMetadata)).toThrow();
64+
});
65+
});
66+
67+
describe('OpenIdProviderMetadataSchema', () => {
68+
it('validates complete OpenID Provider metadata', () => {
69+
const metadata = {
70+
issuer: 'https://auth.example.com',
71+
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
72+
token_endpoint: 'https://auth.example.com/oauth/token',
73+
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
74+
response_types_supported: ['code'],
75+
subject_types_supported: ['public'],
76+
id_token_signing_alg_values_supported: ['RS256'],
77+
};
78+
79+
expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow();
80+
});
81+
82+
it('rejects metadata with javascript: in jwks_uri', () => {
83+
const metadata = {
84+
issuer: 'https://auth.example.com',
85+
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
86+
token_endpoint: 'https://auth.example.com/oauth/token',
87+
jwks_uri: 'javascript:alert(1)',
88+
response_types_supported: ['code'],
89+
subject_types_supported: ['public'],
90+
id_token_signing_alg_values_supported: ['RS256'],
91+
};
92+
93+
expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
94+
});
95+
});
96+
97+
describe('OAuthClientMetadataSchema', () => {
98+
it('validates client metadata with safe URLs', () => {
99+
const metadata = {
100+
redirect_uris: ['https://app.example.com/callback'],
101+
client_name: 'Test App',
102+
client_uri: 'https://app.example.com',
103+
};
104+
105+
expect(() => OAuthClientMetadataSchema.parse(metadata)).not.toThrow();
106+
});
107+
108+
it('rejects client metadata with javascript: redirect URIs', () => {
109+
const metadata = {
110+
redirect_uris: ['javascript:alert(1)'],
111+
client_name: 'Test App',
112+
};
113+
114+
expect(() => OAuthClientMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
115+
});
116+
});

src/shared/auth.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
import { z } from "zod";
22

3+
/**
4+
* Reusable URL validation that disallows javascript: scheme
5+
*/
6+
export const SafeUrlSchema = z.string().url()
7+
.superRefine((val, ctx) => {
8+
if (!URL.canParse(val)) {
9+
ctx.addIssue({
10+
code: z.ZodIssueCode.custom,
11+
message: "URL must be parseable",
12+
fatal: true,
13+
});
14+
15+
return z.NEVER;
16+
}
17+
}).refine(
18+
(url) => {
19+
const u = new URL(url);
20+
return u.protocol !== 'javascript:' && u.protocol !== 'data:' && u.protocol !== 'vbscript:';
21+
},
22+
{ message: "URL cannot use javascript:, data:, or vbscript: scheme" }
23+
);
24+
25+
326
/**
427
* RFC 9728 OAuth Protected Resource Metadata
528
*/
629
export const OAuthProtectedResourceMetadataSchema = z
730
.object({
831
resource: z.string().url(),
9-
authorization_servers: z.array(z.string().url()).optional(),
32+
authorization_servers: z.array(SafeUrlSchema).optional(),
1033
jwks_uri: z.string().url().optional(),
1134
scopes_supported: z.array(z.string()).optional(),
1235
bearer_methods_supported: z.array(z.string()).optional(),
@@ -28,9 +51,9 @@ export const OAuthProtectedResourceMetadataSchema = z
2851
export const OAuthMetadataSchema = z
2952
.object({
3053
issuer: z.string(),
31-
authorization_endpoint: z.string(),
32-
token_endpoint: z.string(),
33-
registration_endpoint: z.string().optional(),
54+
authorization_endpoint: SafeUrlSchema,
55+
token_endpoint: SafeUrlSchema,
56+
registration_endpoint: SafeUrlSchema.optional(),
3457
scopes_supported: z.array(z.string()).optional(),
3558
response_types_supported: z.array(z.string()),
3659
response_modes_supported: z.array(z.string()).optional(),
@@ -39,8 +62,8 @@ export const OAuthMetadataSchema = z
3962
token_endpoint_auth_signing_alg_values_supported: z
4063
.array(z.string())
4164
.optional(),
42-
service_documentation: z.string().optional(),
43-
revocation_endpoint: z.string().optional(),
65+
service_documentation: SafeUrlSchema.optional(),
66+
revocation_endpoint: SafeUrlSchema.optional(),
4467
revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(),
4568
revocation_endpoint_auth_signing_alg_values_supported: z
4669
.array(z.string())
@@ -63,11 +86,11 @@ export const OAuthMetadataSchema = z
6386
export const OpenIdProviderMetadataSchema = z
6487
.object({
6588
issuer: z.string(),
66-
authorization_endpoint: z.string(),
67-
token_endpoint: z.string(),
68-
userinfo_endpoint: z.string().optional(),
69-
jwks_uri: z.string(),
70-
registration_endpoint: z.string().optional(),
89+
authorization_endpoint: SafeUrlSchema,
90+
token_endpoint: SafeUrlSchema,
91+
userinfo_endpoint: SafeUrlSchema.optional(),
92+
jwks_uri: SafeUrlSchema,
93+
registration_endpoint: SafeUrlSchema.optional(),
7194
scopes_supported: z.array(z.string()).optional(),
7295
response_types_supported: z.array(z.string()),
7396
response_modes_supported: z.array(z.string()).optional(),
@@ -101,8 +124,8 @@ export const OpenIdProviderMetadataSchema = z
101124
request_parameter_supported: z.boolean().optional(),
102125
request_uri_parameter_supported: z.boolean().optional(),
103126
require_request_uri_registration: z.boolean().optional(),
104-
op_policy_uri: z.string().optional(),
105-
op_tos_uri: z.string().optional(),
127+
op_policy_uri: SafeUrlSchema.optional(),
128+
op_tos_uri: SafeUrlSchema.optional(),
106129
})
107130
.passthrough();
108131

@@ -146,18 +169,18 @@ export const OAuthErrorResponseSchema = z
146169
* RFC 7591 OAuth 2.0 Dynamic Client Registration metadata
147170
*/
148171
export const OAuthClientMetadataSchema = z.object({
149-
redirect_uris: z.array(z.string()).refine((uris) => uris.every((uri) => URL.canParse(uri)), { message: "redirect_uris must contain valid URLs" }),
172+
redirect_uris: z.array(SafeUrlSchema),
150173
token_endpoint_auth_method: z.string().optional(),
151174
grant_types: z.array(z.string()).optional(),
152175
response_types: z.array(z.string()).optional(),
153176
client_name: z.string().optional(),
154-
client_uri: z.string().optional(),
155-
logo_uri: z.string().optional(),
177+
client_uri: SafeUrlSchema.optional(),
178+
logo_uri: SafeUrlSchema.optional(),
156179
scope: z.string().optional(),
157180
contacts: z.array(z.string()).optional(),
158-
tos_uri: z.string().optional(),
181+
tos_uri: SafeUrlSchema.optional(),
159182
policy_uri: z.string().optional(),
160-
jwks_uri: z.string().optional(),
183+
jwks_uri: SafeUrlSchema.optional(),
161184
jwks: z.any().optional(),
162185
software_id: z.string().optional(),
163186
software_version: z.string().optional(),

0 commit comments

Comments
 (0)