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 <noreply@anthropic.com>
This commit is contained in:
Paul Carleton 2025-08-19 15:39:21 +01:00 • committed by GitHub
commit 64f7cdd09b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 157 additions and 18 deletions

116
src/shared/auth.test.ts Normal file
View file

@ -0,0 +1,116 @@
import { describe, it, expect } from '@jest/globals';
import {
SafeUrlSchema,
OAuthMetadataSchema,
OpenIdProviderMetadataSchema,
OAuthClientMetadataSchema,
} from './auth.js';
describe('SafeUrlSchema', () => {
it('accepts valid HTTPS URLs', () => {
expect(SafeUrlSchema.parse('https://example.com')).toBe('https://example.com');
expect(SafeUrlSchema.parse('https://auth.example.com/oauth/authorize')).toBe('https://auth.example.com/oauth/authorize');
});
it('accepts valid HTTP URLs', () => {
expect(SafeUrlSchema.parse('http://localhost:3000')).toBe('http://localhost:3000');
});
it('rejects javascript: scheme URLs', () => {
expect(() => SafeUrlSchema.parse('javascript:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
expect(() => SafeUrlSchema.parse('JAVASCRIPT:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
});
it('rejects invalid URLs', () => {
expect(() => SafeUrlSchema.parse('not-a-url')).toThrow();
expect(() => SafeUrlSchema.parse('')).toThrow();
});
it('works with safeParse', () => {
expect(() => SafeUrlSchema.safeParse('not-a-url')).not.toThrow();
});
});
describe('OAuthMetadataSchema', () => {
it('validates complete OAuth metadata', () => {
const metadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
token_endpoint: 'https://auth.example.com/oauth/token',
response_types_supported: ['code'],
scopes_supported: ['read', 'write'],
};
expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow();
});
it('rejects metadata with javascript: URLs', () => {
const metadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'javascript:alert(1)',
token_endpoint: 'https://auth.example.com/oauth/token',
response_types_supported: ['code'],
};
expect(() => OAuthMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
});
it('requires mandatory fields', () => {
const incompleteMetadata = {
issuer: 'https://auth.example.com',
};
expect(() => OAuthMetadataSchema.parse(incompleteMetadata)).toThrow();
});
});
describe('OpenIdProviderMetadataSchema', () => {
it('validates complete OpenID Provider metadata', () => {
const metadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
token_endpoint: 'https://auth.example.com/oauth/token',
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow();
});
it('rejects metadata with javascript: in jwks_uri', () => {
const metadata = {
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
token_endpoint: 'https://auth.example.com/oauth/token',
jwks_uri: 'javascript:alert(1)',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
});
});
describe('OAuthClientMetadataSchema', () => {
it('validates client metadata with safe URLs', () => {
const metadata = {
redirect_uris: ['https://app.example.com/callback'],
client_name: 'Test App',
client_uri: 'https://app.example.com',
};
expect(() => OAuthClientMetadataSchema.parse(metadata)).not.toThrow();
});
it('rejects client metadata with javascript: redirect URIs', () => {
const metadata = {
redirect_uris: ['javascript:alert(1)'],
client_name: 'Test App',
};
expect(() => OAuthClientMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme');
});
});

View file

@ -1,12 +1,35 @@
import { z } from "zod";
/**
* Reusable URL validation that disallows javascript: scheme
*/
export const SafeUrlSchema = z.string().url()
.superRefine((val, ctx) => {
if (!URL.canParse(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "URL must be parseable",
fatal: true,
});
return z.NEVER;
}
}).refine(
(url) => {
const u = new URL(url);
return u.protocol !== 'javascript:' && u.protocol !== 'data:' && u.protocol !== 'vbscript:';
},
{ message: "URL cannot use javascript:, data:, or vbscript: scheme" }
);
/**
* RFC 9728 OAuth Protected Resource Metadata
*/
export const OAuthProtectedResourceMetadataSchema = z
.object({
resource: z.string().url(),
authorization_servers: z.array(z.string().url()).optional(),
authorization_servers: z.array(SafeUrlSchema).optional(),
jwks_uri: z.string().url().optional(),
scopes_supported: z.array(z.string()).optional(),
bearer_methods_supported: z.array(z.string()).optional(),
@ -28,9 +51,9 @@ export const OAuthProtectedResourceMetadataSchema = z
export const OAuthMetadataSchema = z
.object({
issuer: z.string(),
authorization_endpoint: z.string(),
token_endpoint: z.string(),
registration_endpoint: z.string().optional(),
authorization_endpoint: SafeUrlSchema,
token_endpoint: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
@ -39,8 +62,8 @@ export const OAuthMetadataSchema = z
token_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
.optional(),
service_documentation: z.string().optional(),
revocation_endpoint: z.string().optional(),
service_documentation: SafeUrlSchema.optional(),
revocation_endpoint: SafeUrlSchema.optional(),
revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(),
revocation_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
@ -63,11 +86,11 @@ export const OAuthMetadataSchema = z
export const OpenIdProviderMetadataSchema = z
.object({
issuer: z.string(),
authorization_endpoint: z.string(),
token_endpoint: z.string(),
userinfo_endpoint: z.string().optional(),
jwks_uri: z.string(),
registration_endpoint: z.string().optional(),
authorization_endpoint: SafeUrlSchema,
token_endpoint: SafeUrlSchema,
userinfo_endpoint: SafeUrlSchema.optional(),
jwks_uri: SafeUrlSchema,
registration_endpoint: SafeUrlSchema.optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
@ -101,8 +124,8 @@ export const OpenIdProviderMetadataSchema = z
request_parameter_supported: z.boolean().optional(),
request_uri_parameter_supported: z.boolean().optional(),
require_request_uri_registration: z.boolean().optional(),
op_policy_uri: z.string().optional(),
op_tos_uri: z.string().optional(),
op_policy_uri: SafeUrlSchema.optional(),
op_tos_uri: SafeUrlSchema.optional(),
})
.passthrough();
@ -146,18 +169,18 @@ export const OAuthErrorResponseSchema = z
* RFC 7591 OAuth 2.0 Dynamic Client Registration metadata
*/
export const OAuthClientMetadataSchema = z.object({
redirect_uris: z.array(z.string()).refine((uris) => uris.every((uri) => URL.canParse(uri)), { message: "redirect_uris must contain valid URLs" }),
redirect_uris: z.array(SafeUrlSchema),
token_endpoint_auth_method: z.string().optional(),
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
client_name: z.string().optional(),
client_uri: z.string().optional(),
logo_uri: z.string().optional(),
client_uri: SafeUrlSchema.optional(),
logo_uri: SafeUrlSchema.optional(),
scope: z.string().optional(),
contacts: z.array(z.string()).optional(),
tos_uri: z.string().optional(),
tos_uri: SafeUrlSchema.optional(),
policy_uri: z.string().optional(),
jwks_uri: z.string().optional(),
jwks_uri: SafeUrlSchema.optional(),
jwks: z.any().optional(),
software_id: z.string().optional(),
software_version: z.string().optional(),