diff --git a/.changeset/fix-www-auth-substring.md b/.changeset/fix-www-auth-substring.md new file mode 100644 index 0000000000..5acad793c5 --- /dev/null +++ b/.changeset/fix-www-auth-substring.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix `WWW-Authenticate` parsing matching a field name inside another auth-param. The OAuth client extracted `resource_metadata`, `scope`, `error`, and `error_description` with a regex that searched for the field name anywhere in the header, so a different parameter whose name ended with the requested field (for example `error_scope` when reading `scope`) could shadow the real value, and a parameter like `x_resource_metadata` could supply a decoy resource metadata URL. The parameter name is now anchored to the header start or a separator. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ebc6fd251..564aec9c37 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1467,7 +1467,10 @@ function extractFieldFromWwwAuth(response: Response, fieldName: string): string return null; } - const pattern = new RegExp(String.raw`${fieldName}=(?:"([^"]+)"|([^\s,]+))`); + // Require the parameter name to start at the header start or after a separator (whitespace + // or comma) so it is not matched as a substring of another auth-param name (e.g. searching + // "scope" must not match the value of "error_scope"). + const pattern = new RegExp(String.raw`(?:^|[\s,])${fieldName}=(?:"([^"]+)"|([^\s,]+))`); const match = wwwAuthHeader.match(pattern); if (match) { diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..ecb0f28618 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -191,6 +191,26 @@ describe('OAuth Authorization', () => { errorDescription: 'needs admin' }); }); + + it('does not match a field name inside a longer auth-param name', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer error_scope="should-not-be-read", scope="read write"` : null)) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: 'read write' }); + }); + + it('does not match resource_metadata inside a longer auth-param name', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer x_resource_metadata="https://decoy.example.com"` : null)) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({}); + }); }); describe('computeScopeUnion', () => {