diff --git a/.changeset/fix-csrf-fetch-request-headers.md b/.changeset/fix-csrf-fetch-request-headers.md new file mode 100644 index 0000000..c4e14ac --- /dev/null +++ b/.changeset/fix-csrf-fetch-request-headers.md @@ -0,0 +1,10 @@ +--- +"@csrf-armor/nextjs": patch +"@csrf-armor/nuxt": patch +--- + +fix(client): preserve headers when `csrfFetch` is called with a `Request` object + +`csrfFetch` previously only read headers from the `init` argument, so when it was called with a full `Request` object (e.g. `csrfFetch(new Request(url, { headers }))`), the Request's headers were stripped. It now merges headers from the Request, then the `init` argument, then the CSRF headers (CSRF headers always take precedence), making `csrfFetch` a drop-in replacement for `fetch`. + +Fixes #49 diff --git a/packages/nextjs/src/client/client.ts b/packages/nextjs/src/client/client.ts index 440e769..94c5678 100644 --- a/packages/nextjs/src/client/client.ts +++ b/packages/nextjs/src/client/client.ts @@ -149,10 +149,13 @@ export function csrfFetch( init?: RequestInit, config?: CsrfClientConfig ): Promise { - const headers = new Headers(init?.headers); - const csrfHeaders = createCsrfHeaders(config); - - for (const [key, value] of Object.entries(csrfHeaders)) { + const headers = new Headers( + input instanceof Request ? input.headers : undefined + ); + for (const [key, value] of new Headers(init?.headers)) { + headers.set(key, value); + } + for (const [key, value] of Object.entries(createCsrfHeaders(config))) { headers.set(key, value); } diff --git a/packages/nextjs/tests/client.test.ts b/packages/nextjs/tests/client.test.ts index 8d6ec72..d079765 100644 --- a/packages/nextjs/tests/client.test.ts +++ b/packages/nextjs/tests/client.test.ts @@ -158,6 +158,40 @@ describe('Client utilities', () => { expect(headers.get('Content-Type')).toBe('application/json'); }); + it('should preserve headers when input is a Request object', async () => { + document.cookie = 'csrf-token=req-token'; + const req = new Request('https://example.com/api/data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Custom': 'keep-me', + }, + }); + await csrfFetch(req); + + const callArgs = (globalThis.fetch as ReturnType).mock + .calls[0]; + const headers = callArgs[1].headers as Headers; + expect(headers.get('x-csrf-token')).toBe('req-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-Custom')).toBe('keep-me'); + }); + + it('should let init headers override Request headers', async () => { + document.cookie = 'csrf-token=override-token'; + const req = new Request('https://example.com/api/data', { + method: 'POST', + headers: { 'X-Custom': 'original' }, + }); + await csrfFetch(req, { headers: { 'X-Custom': 'overridden' } }); + + const callArgs = (globalThis.fetch as ReturnType).mock + .calls[0]; + const headers = callArgs[1].headers as Headers; + expect(headers.get('X-Custom')).toBe('overridden'); + expect(headers.get('x-csrf-token')).toBe('override-token'); + }); + it('should work without a token', async () => { await csrfFetch('/api/data'); diff --git a/packages/nuxt/src/runtime/utils/client.ts b/packages/nuxt/src/runtime/utils/client.ts index f4d4f10..b4365dd 100644 --- a/packages/nuxt/src/runtime/utils/client.ts +++ b/packages/nuxt/src/runtime/utils/client.ts @@ -67,10 +67,13 @@ export function csrfFetch( init?: RequestInit, config?: CsrfClientConfig ): Promise { - const headers = new Headers(init?.headers); - const csrfHeaders = createCsrfHeaders(config); - - for (const [key, value] of Object.entries(csrfHeaders)) { + const headers = new Headers( + input instanceof Request ? input.headers : undefined + ); + for (const [key, value] of new Headers(init?.headers)) { + headers.set(key, value); + } + for (const [key, value] of Object.entries(createCsrfHeaders(config))) { headers.set(key, value); } diff --git a/packages/nuxt/test/client.test.ts b/packages/nuxt/test/client.test.ts new file mode 100644 index 0000000..1a49152 --- /dev/null +++ b/packages/nuxt/test/client.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment jsdom + * + * Note: `getCsrfToken` returns null in this test environment because + * `import.meta.client` is only defined by Nuxt at build time, so the CSRF + * header is not injected. These tests cover the Request/init header merge + * behavior that issue #49 exposed. The CSRF-precedence assertion is + * covered by the Next.js client tests, which exercise the same merge logic. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { csrfFetch } from '../src/runtime/utils/client'; + +describe('csrfFetch (nuxt)', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('ok', { status: 200 }) + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('preserves headers when input is a Request object', async () => { + const req = new Request('https://example.com/api/data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Custom': 'keep-me', + }, + }); + await csrfFetch(req); + + const callArgs = (globalThis.fetch as ReturnType).mock + .calls[0]; + const headers = callArgs[1].headers as Headers; + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-Custom')).toBe('keep-me'); + }); + + it('lets init headers override Request headers', async () => { + const req = new Request('https://example.com/api/data', { + method: 'POST', + headers: { 'X-Custom': 'original' }, + }); + await csrfFetch(req, { headers: { 'X-Custom': 'overridden' } }); + + const callArgs = (globalThis.fetch as ReturnType).mock + .calls[0]; + const headers = callArgs[1].headers as Headers; + expect(headers.get('X-Custom')).toBe('overridden'); + }); + + it('merges init headers alongside Request headers', async () => { + const req = new Request('https://example.com/api/data', { + method: 'POST', + headers: { 'X-From-Request': 'req' }, + }); + await csrfFetch(req, { headers: { 'X-From-Init': 'init' } }); + + const callArgs = (globalThis.fetch as ReturnType).mock + .calls[0]; + const headers = callArgs[1].headers as Headers; + expect(headers.get('X-From-Request')).toBe('req'); + expect(headers.get('X-From-Init')).toBe('init'); + }); +});