diff --git a/.changeset/kind-rooms-report.md b/.changeset/kind-rooms-report.md new file mode 100644 index 00000000000..93e8ed53361 --- /dev/null +++ b/.changeset/kind-rooms-report.md @@ -0,0 +1,9 @@ +--- +'@clerk/backend': patch +--- + +Fix `URIError: URI malformed` being thrown while parsing the `Cookie` header. + +`ClerkRequest` decodes percent-escapes across the whole header rather than per value, so an invalid sequence in any cookie — including ones Clerk neither set nor reads, such as those written by analytics or third-party scripts — reached `decodeURIComponent` and threw. The decode runs from the constructor, so the error escaped `createClerkRequest` and failed the request before any auth logic ran. Because the offending value stays in the browser until it expires, every subsequent request from that client failed too. + +Escape sequences that cannot be decoded are now left as their raw text, and the rest of the header parses as before. diff --git a/packages/backend/src/tokens/__tests__/clerkRequest.test.ts b/packages/backend/src/tokens/__tests__/clerkRequest.test.ts index 1b75b88cd44..ec23ceda72d 100644 --- a/packages/backend/src/tokens/__tests__/clerkRequest.test.ts +++ b/packages/backend/src/tokens/__tests__/clerkRequest.test.ts @@ -97,6 +97,30 @@ describe('createClerkRequest', () => { expect(req.cookies.get('baz')).toBe('qux'); }); + // `%E2` is an incomplete UTF-8 lead byte, so `decodeURIComponent` throws on it. + // The decode runs over the whole header, so a cookie Clerk never set and never + // reads can fail the request before any auth logic runs (issue #9333). + it('does not throw when another cookie has a malformed percent-escape', () => { + const req = new Request('http://localhost:3000', { + headers: new Headers({ cookie: '__session=abc; analytics_id=%E2%9' }), + }); + expect(() => createClerkRequest(req)).not.toThrow(); + expect(createClerkRequest(req).cookies.get('__session')).toBe('abc'); + }); + + // A value that cannot be decoded is kept as-is, so callers still see whatever + // the client sent rather than an empty or dropped cookie. + it.each([ + ['truncated sequence', '%E2%9'], + ['lone continuation byte', '%98'], + ['overlong encoding', '%C0%80'], + ])('leaves a cookie with a %s undecoded', (_label, value) => { + const req = createClerkRequest( + new Request('http://localhost:3000', { headers: new Headers({ cookie: `analytics_id=${value}` }) }), + ); + expect(req.cookies.get('analytics_id')).toBe(value); + }); + it('should parse and return cookies even if no cookie header exists', () => { const req = createClerkRequest(new Request('http://localhost:3000', { headers: new Headers() })); expect(req.cookies.get('foo')).toBeUndefined(); diff --git a/packages/backend/src/tokens/clerkRequest.ts b/packages/backend/src/tokens/clerkRequest.ts index 8b02266c643..54163f13aff 100644 --- a/packages/backend/src/tokens/clerkRequest.ts +++ b/packages/backend/src/tokens/clerkRequest.ts @@ -99,7 +99,22 @@ class ClerkRequest extends Request { } private decodeCookieValue(str: string) { - return str ? str.replace(/(%[0-9A-Z]{2})+/g, decodeURIComponent) : str; + if (!str) { + return str; + } + + // The decode runs over the whole header rather than per value, so an escape + // in any cookie - including ones Clerk neither set nor reads - reaches + // `decodeURIComponent`, which throws on invalid UTF-8 (truncated, lone, or + // overlong sequences). This is called from the constructor, so that would + // fail the request before any auth logic runs. Leave such runs raw instead. + return str.replace(/(%[0-9A-Z]{2})+/g, match => { + try { + return decodeURIComponent(match); + } catch { + return match; + } + }); } }