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:
parent
abb2f0aa14
commit
64f7cdd09b
2 changed files with 157 additions and 18 deletions
116
src/shared/auth.test.ts
Normal file
116
src/shared/auth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue