fix(server/auth): RFC 8252 loopback port relaxation (#1738)

This commit is contained in:
Alice T'Poteat 2026-03-25 03:47:45 -07:00 • committed by GitHub
commit 897bc25bfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 148 additions and 2 deletions

View file

@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---
The authorization handler now applies RFC 8252 §7.3 loopback port relaxation when validating `redirect_uri` against a client's registered URIs. For `localhost`, `127.0.0.1`, and `[::1]` hosts, any port is accepted as long as scheme, host, path, and query match. This fixes native
clients that obtain an ephemeral port from the OS but register a portless loopback URI (e.g., via CIMD / SEP-991).

View file

@ -15,6 +15,39 @@ export type AuthorizationHandlerOptions = {
rateLimit?: Partial<RateLimitOptions> | false;
};
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
/**
* Validates a requested redirect_uri against a registered one.
*
* Per RFC 8252 §7.3 (OAuth 2.0 for Native Apps), authorization servers MUST
* allow any port for loopback redirect URIs (localhost, 127.0.0.1, [::1]) to
* accommodate native clients that obtain an ephemeral port from the OS. For
* non-loopback URIs, exact match is required.
*
* @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
*/
export function redirectUriMatches(requested: string, registered: string): boolean {
if (requested === registered) {
return true;
}
let req: URL, reg: URL;
try {
req = new URL(requested);
reg = new URL(registered);
} catch {
return false;
}
// Port relaxation only applies when both URIs target a loopback host.
if (!LOOPBACK_HOSTS.has(req.hostname) || !LOOPBACK_HOSTS.has(reg.hostname)) {
return false;
}
// RFC 8252 relaxes the port only — scheme, host, path, and query must
// still match exactly. Note: hostname must match exactly too (the RFC
// does not allow localhost↔127.0.0.1 cross-matching).
return req.protocol === reg.protocol && req.hostname === reg.hostname && req.pathname === reg.pathname && req.search === reg.search;
}
// Parameters that must be validated in order to issue redirects.
const ClientAuthorizationParamsSchema = z.object({
client_id: z.string(),
@ -78,7 +111,8 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
}
if (redirect_uri !== undefined) {
if (!client.redirect_uris.includes(redirect_uri)) {
const requested = redirect_uri;
if (!client.redirect_uris.some(registered => redirectUriMatches(requested, registered))) {
throw new InvalidRequestError('Unregistered redirect_uri');
}
} else if (client.redirect_uris.length === 1) {

View file

@ -1,4 +1,4 @@
import { authorizationHandler, AuthorizationHandlerOptions } from '../../../../src/server/auth/handlers/authorize.js';
import { authorizationHandler, AuthorizationHandlerOptions, redirectUriMatches } from '../../../../src/server/auth/handlers/authorize.js';
import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthTokens } from '../../../../src/shared/auth.js';
@ -23,6 +23,14 @@ describe('Authorization Handler', () => {
scope: 'profile email'
};
// Native app client with a portless loopback redirect (e.g., from CIMD / SEP-991)
const loopbackClient: OAuthClientInformationFull = {
client_id: 'loopback-client',
client_secret: 'valid-secret',
redirect_uris: ['http://localhost/callback', 'http://127.0.0.1/callback'],
scope: 'profile email'
};
// Mock client store
const mockClientStore: OAuthRegisteredClientsStore = {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
@ -30,6 +38,8 @@ describe('Authorization Handler', () => {
return validClient;
} else if (clientId === 'multi-redirect-client') {
return multiRedirectClient;
} else if (clientId === 'loopback-client') {
return loopbackClient;
}
return undefined;
}
@ -172,6 +182,102 @@ describe('Authorization Handler', () => {
const location = new URL(response.header.location);
expect(location.origin + location.pathname).toBe('https://example.com/callback');
});
// RFC 8252 §7.3: authorization servers MUST allow any port for loopback
// redirect URIs. Native apps obtain ephemeral ports from the OS.
it('accepts loopback redirect_uri with ephemeral port (RFC 8252)', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'loopback-client',
redirect_uri: 'http://localhost:53428/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
const location = new URL(response.header.location);
expect(location.hostname).toBe('localhost');
expect(location.port).toBe('53428');
expect(location.pathname).toBe('/callback');
});
it('accepts 127.0.0.1 loopback redirect_uri with ephemeral port (RFC 8252)', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'loopback-client',
redirect_uri: 'http://127.0.0.1:9000/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(302);
});
it('rejects loopback redirect_uri with different path', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'loopback-client',
redirect_uri: 'http://localhost:53428/evil',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(400);
});
it('does not relax port for non-loopback redirect_uri', async () => {
const response = await supertest(app).get('/authorize').query({
client_id: 'valid-client',
redirect_uri: 'https://example.com:8443/callback',
response_type: 'code',
code_challenge: 'challenge123',
code_challenge_method: 'S256'
});
expect(response.status).toBe(400);
});
});
describe('redirectUriMatches (RFC 8252 §7.3)', () => {
it('exact match passes', () => {
expect(redirectUriMatches('https://example.com/cb', 'https://example.com/cb')).toBe(true);
});
it('loopback: any port matches portless registration', () => {
expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost/callback')).toBe(true);
expect(redirectUriMatches('http://127.0.0.1:8080/callback', 'http://127.0.0.1/callback')).toBe(true);
expect(redirectUriMatches('http://[::1]:9000/cb', 'http://[::1]/cb')).toBe(true);
});
it('loopback: any port matches ported registration', () => {
expect(redirectUriMatches('http://localhost:53428/callback', 'http://localhost:3118/callback')).toBe(true);
});
it('loopback: different path rejected', () => {
expect(redirectUriMatches('http://localhost:53428/evil', 'http://localhost/callback')).toBe(false);
});
it('loopback: different scheme rejected', () => {
expect(redirectUriMatches('https://localhost:53428/callback', 'http://localhost/callback')).toBe(false);
});
it('loopback: localhost↔127.0.0.1 cross-match rejected', () => {
// RFC 8252 relaxes port only, not host
expect(redirectUriMatches('http://127.0.0.1:53428/callback', 'http://localhost/callback')).toBe(false);
});
it('non-loopback: port must match exactly', () => {
expect(redirectUriMatches('https://example.com:8443/cb', 'https://example.com/cb')).toBe(false);
});
it('non-loopback: no relaxation for private IPs', () => {
expect(redirectUriMatches('http://192.168.1.1:8080/cb', 'http://192.168.1.1/cb')).toBe(false);
});
it('malformed URIs rejected', () => {
expect(redirectUriMatches('not a url', 'http://localhost/cb')).toBe(false);
expect(redirectUriMatches('http://localhost/cb', 'not a url')).toBe(false);
});
});
describe('Authorization request validation', () => {