From 948aa09ab5e2d75eac1aed33a3e0eb616841f8f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 10:24:57 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test:=20improve=20test=20coverage=20across?= =?UTF-8?q?=20all=20packages=20(95=20=E2=86=92=20198=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive tests for previously untested code paths: - Core: CsrfProtection.protect(), error classes, timingSafeEqual, generateSecureSecret, config merging, path exclusion, strategy routing - Express: getTokenFromRequest() priority chain, HTTP method coverage, signed-token strategy validation - Next.js: client utilities (getCsrfToken, csrfFetch, refreshCsrfToken), signed-token and hybrid strategy middleware tests https://claude.ai/code/session_01JPhEZP5jGsAtWYvCjC8Ek9 --- packages/core/tests/crypto.test.ts | 59 +++ packages/core/tests/csrf.test.ts | 488 ++++++++++++++++++++++ packages/core/tests/errors.test.ts | 102 +++++ packages/core/tests/validation.test.ts | 190 +++++++++ packages/express/tests/adapter.test.ts | 159 ++++++- packages/express/tests/middleware.test.ts | 180 ++++++++ packages/nextjs/tests/client.test.ts | 244 +++++++++++ packages/nextjs/tests/middleware.test.ts | 171 ++++++++ 8 files changed, 1592 insertions(+), 1 deletion(-) create mode 100644 packages/core/tests/csrf.test.ts create mode 100644 packages/core/tests/errors.test.ts create mode 100644 packages/nextjs/tests/client.test.ts diff --git a/packages/core/tests/crypto.test.ts b/packages/core/tests/crypto.test.ts index 7228375..3e783ee 100644 --- a/packages/core/tests/crypto.test.ts +++ b/packages/core/tests/crypto.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { generateNonce, + generateSecureSecret, generateSignedToken, parseSignedToken, signUnsignedToken, + timingSafeEqual, verifySignedToken, } from '../src'; import { TokenExpiredError, TokenInvalidError } from '../src'; @@ -133,4 +135,61 @@ describe('Crypto utilities', () => { ); }); }); + + describe('timingSafeEqual', () => { + it('should return true for equal strings', () => { + expect(timingSafeEqual('hello', 'hello')).toBe(true); + }); + + it('should return false for unequal strings', () => { + expect(timingSafeEqual('hello', 'world')).toBe(false); + }); + + it('should return false for strings of different lengths', () => { + expect(timingSafeEqual('short', 'longer-string')).toBe(false); + }); + + it('should return true for empty strings', () => { + expect(timingSafeEqual('', '')).toBe(true); + }); + + it('should detect a single character difference', () => { + expect(timingSafeEqual('abcde', 'abcdf')).toBe(false); + }); + }); + + describe('generateSecureSecret', () => { + it('should return a non-empty string', () => { + const secret = generateSecureSecret(); + expect(secret).toBeTruthy(); + expect(secret.length).toBeGreaterThan(0); + }); + + it('should return a base64-encoded string', () => { + const secret = generateSecureSecret(); + expect(secret).toMatch(/^[A-Za-z0-9+/=]+$/); + }); + + it('should generate unique values on each call', () => { + const secret1 = generateSecureSecret(); + const secret2 = generateSecureSecret(); + expect(secret1).not.toBe(secret2); + }); + + it('should return a string of 44 characters (32 bytes base64-encoded)', () => { + const secret = generateSecureSecret(); + expect(secret).toHaveLength(44); + }); + }); + + describe('parseSignedToken (edge cases)', () => { + it('should throw TokenInvalidError with "Invalid expiration timestamp" for non-numeric exp', async () => { + // "abc" is the expStr — parseInt("abc") is NaN, which is checked BEFORE + // signature verification in parseSignedToken, so this always throws + // regardless of the nonce or signature values. + await expect( + parseSignedToken('abc.nonce.signature', 'secret') + ).rejects.toThrow(new TokenInvalidError('Invalid expiration timestamp')); + }); + }); }); diff --git a/packages/core/tests/csrf.test.ts b/packages/core/tests/csrf.test.ts new file mode 100644 index 0000000..853b81f --- /dev/null +++ b/packages/core/tests/csrf.test.ts @@ -0,0 +1,488 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { CsrfProtection, createCsrfProtection } from '../src/csrf.js'; +import type { + CsrfAdapter, + CsrfRequest, + CsrfResponse, + RequiredCsrfConfig, +} from '../src'; + +// --------------------------------------------------------------------------- +// Mock adapter +// --------------------------------------------------------------------------- + +class MockAdapter implements CsrfAdapter> { + extractRequest(req: CsrfRequest): CsrfRequest { + return req; + } + + applyResponse( + res: Record, + csrfResponse: CsrfResponse + ): Record { + return { ...res, csrfResponse }; + } + + async getTokenFromRequest( + req: CsrfRequest, + config: RequiredCsrfConfig + ): Promise { + const headers = + req.headers instanceof Map + ? req.headers + : new Map(Object.entries(req.headers)); + return headers.get(config.token.headerName.toLowerCase()); + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const TEST_SECRET = 'test-secret-for-csrf-tests-1234'; + +/** Builds a minimal GET request. */ +function makeRequest( + overrides: Partial & { method: string; url?: string } +): CsrfRequest { + return { + url: 'http://localhost/api/data', + headers: new Map(), + cookies: new Map(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// createCsrfProtection factory +// --------------------------------------------------------------------------- + +describe('createCsrfProtection', () => { + it('returns an instance of CsrfProtection', () => { + const adapter = new MockAdapter(); + const instance = createCsrfProtection(adapter, { + secret: TEST_SECRET, + strategy: 'double-submit', + }); + expect(instance).toBeInstanceOf(CsrfProtection); + }); +}); + +// --------------------------------------------------------------------------- +// Safe HTTP methods (GET / HEAD / OPTIONS) +// --------------------------------------------------------------------------- + +describe('CsrfProtection – safe methods', () => { + let csrf: CsrfProtection>; + + beforeEach(() => { + csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + }); + }); + + it('GET request succeeds and returns a token', async () => { + const req = makeRequest({ method: 'GET' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + expect(typeof result.token).toBe('string'); + expect(result.token!.length).toBeGreaterThan(0); + }); + + it('HEAD request succeeds and returns a token', async () => { + const req = makeRequest({ method: 'HEAD' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + expect(typeof result.token).toBe('string'); + }); + + it('OPTIONS request succeeds and returns a token', async () => { + const req = makeRequest({ method: 'OPTIONS' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + expect(typeof result.token).toBe('string'); + }); + + it('safe-method response includes csrf headers and cookies via adapter', async () => { + const req = makeRequest({ method: 'GET' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + // The mock adapter merges csrfResponse into the returned response object + const response = result.response as Record; + expect(response).toHaveProperty('csrfResponse'); + + const csrfResponse = response.csrfResponse as CsrfResponse; + const headers = + csrfResponse.headers instanceof Map + ? csrfResponse.headers + : new Map(Object.entries(csrfResponse.headers)); + + expect(headers.has('x-csrf-token')).toBe(true); + expect(headers.has('x-csrf-strategy')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Unsafe HTTP methods without a valid token +// --------------------------------------------------------------------------- + +describe('CsrfProtection – unsafe methods without token', () => { + let csrf: CsrfProtection>; + + beforeEach(() => { + csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + }); + }); + + it('POST without token returns success=false', async () => { + const req = makeRequest({ method: 'POST' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it('PUT without token returns success=false', async () => { + const req = makeRequest({ method: 'PUT' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(false); + }); + + it('DELETE without token returns success=false', async () => { + const req = makeRequest({ method: 'DELETE' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// excludePaths +// --------------------------------------------------------------------------- + +describe('CsrfProtection – excludePaths', () => { + it('POST to an excluded path returns success=true without requiring a token', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + excludePaths: ['/api/public'], + }); + + const originalResponse = { marker: 'original' }; + const req = makeRequest({ + method: 'POST', + url: 'http://localhost/api/public', + }); + const result = await csrf.protect(req, originalResponse); + + expect(result.success).toBe(true); + // For excluded paths, protect() returns the original response unchanged + expect(result.response).toBe(originalResponse); + }); + + it('POST to a non-excluded path still requires validation', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + excludePaths: ['/api/public'], + }); + + const req = makeRequest({ + method: 'POST', + url: 'http://localhost/api/private', + }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(false); + }); + + it('prefix matching: /api/public matches /api/public/foo', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + excludePaths: ['/api/public'], + }); + + const originalResponse = { marker: 'original' }; + const req = makeRequest({ + method: 'POST', + url: 'http://localhost/api/public/foo', + }); + const result = await csrf.protect(req, originalResponse); + + expect(result.success).toBe(true); + expect(result.response).toBe(originalResponse); + }); + + it('URL with query string: pathname is correctly extracted for excludePaths matching', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + excludePaths: ['/api/public'], + }); + + const originalResponse = { marker: 'original' }; + // Relative URL with query string — falls back to manual parsing in extractPathname + const req = makeRequest({ + method: 'POST', + url: '/api/public?foo=bar', + }); + const result = await csrf.protect(req, originalResponse); + + expect(result.success).toBe(true); + expect(result.response).toBe(originalResponse); + }); +}); + +// --------------------------------------------------------------------------- +// skipContentTypes +// --------------------------------------------------------------------------- + +describe('CsrfProtection – skipContentTypes', () => { + it('POST with a skipped content-type returns success=true without requiring a token', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + skipContentTypes: ['text/plain'], + }); + + const req = makeRequest({ + method: 'POST', + headers: new Map([['content-type', 'text/plain']]), + }); + const originalResponse = { marker: 'original' }; + const result = await csrf.protect(req, originalResponse); + + expect(result.success).toBe(true); + // Excluded via skipContentTypes returns the original response unchanged + expect(result.response).toBe(originalResponse); + }); + + it('POST with a non-skipped content-type still requires validation', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + skipContentTypes: ['text/plain'], + }); + + const req = makeRequest({ + method: 'POST', + headers: new Map([['content-type', 'application/json']]), + }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Token generation shapes per strategy +// --------------------------------------------------------------------------- + +describe('CsrfProtection – token generation shapes', () => { + it('double-submit: generates a hex nonce (no dots) as clientToken', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + }); + + const req = makeRequest({ method: 'GET' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + // Pure hex nonce — no dots + expect(result.token).toMatch(/^[a-f0-9]+$/); + }); + + it('signed-double-submit: generates an unsigned hex nonce as clientToken (no dots)', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'signed-double-submit', + }); + + const req = makeRequest({ method: 'GET' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + // Client token is the raw unsigned nonce + expect(result.token).toMatch(/^[a-f0-9]+$/); + }); + + it('signed-double-submit: response cookies include both client cookie and signed server cookie', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'signed-double-submit', + }); + + const req = makeRequest({ method: 'GET' }); + const result = await csrf.protect(req, {}); + const response = result.response as Record; + const csrfResponse = response.csrfResponse as CsrfResponse; + + const cookies = + csrfResponse.cookies instanceof Map + ? csrfResponse.cookies + : new Map(Object.entries(csrfResponse.cookies)); + + // Client cookie (unsigned) + expect(cookies.has('csrf-token')).toBe(true); + // Server cookie (signed, httpOnly) + expect(cookies.has('csrf-token-server')).toBe(true); + + const serverCookie = cookies.get('csrf-token-server')!; + // Signed token has exactly one dot: {unsignedToken}.{signature} + expect(serverCookie.value.split('.').length).toBe(2); + }); + + it('signed-token: generates a signed token with dots (3 parts)', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'signed-token', + }); + + const req = makeRequest({ method: 'GET' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + // Signed token format: {exp}.{nonce}.{signature} + expect(result.token!.split('.').length).toBe(3); + }); + + it('origin-check: generates a nonce token (no dots)', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'origin-check', + }); + + const req = makeRequest({ method: 'GET' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + expect(result.token).toMatch(/^[a-f0-9]+$/); + }); + + it('hybrid: generates a signed token (3 parts, same as signed-token)', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'hybrid', + }); + + const req = makeRequest({ method: 'GET' }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + expect(result.token!.split('.').length).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// double-submit validation +// --------------------------------------------------------------------------- + +describe('CsrfProtection – double-submit validation', () => { + let csrf: CsrfProtection>; + + beforeEach(() => { + csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'double-submit', + }); + }); + + it('POST with matching header token and cookie token succeeds', async () => { + // First, obtain a token via GET + const getReq = makeRequest({ method: 'GET' }); + const getResult = await csrf.protect(getReq, {}); + const token = getResult.token!; + + // Then submit it in a POST with matching cookie + const postReq = makeRequest({ + method: 'POST', + headers: new Map([['x-csrf-token', token]]), + cookies: new Map([['csrf-token', token]]), + }); + const postResult = await csrf.protect(postReq, {}); + + expect(postResult.success).toBe(true); + }); + + it('POST with mismatched header token and cookie token fails', async () => { + const getReq = makeRequest({ method: 'GET' }); + const getResult = await csrf.protect(getReq, {}); + const correctToken = getResult.token!; + + const postReq = makeRequest({ + method: 'POST', + headers: new Map([['x-csrf-token', 'wrong-token']]), + cookies: new Map([['csrf-token', correctToken]]), + }); + const postResult = await csrf.protect(postReq, {}); + + expect(postResult.success).toBe(false); + expect(postResult.reason).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// origin-check strategy validation +// --------------------------------------------------------------------------- + +describe('CsrfProtection – origin-check validation', () => { + it('POST with an allowed origin succeeds', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'origin-check', + allowedOrigins: ['http://localhost:3000'], + }); + + const req = makeRequest({ + method: 'POST', + headers: new Map([['origin', 'http://localhost:3000']]), + }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(true); + }); + + it('POST with a disallowed origin fails', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'origin-check', + allowedOrigins: ['http://localhost:3000'], + }); + + const req = makeRequest({ + method: 'POST', + headers: new Map([['origin', 'http://evil.example.com']]), + }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(false); + expect(result.reason).toContain('not allowed'); + }); + + it('POST with no origin header fails with missing origin reason', async () => { + const csrf = new CsrfProtection(new MockAdapter(), { + secret: TEST_SECRET, + strategy: 'origin-check', + allowedOrigins: ['http://localhost:3000'], + }); + + const req = makeRequest({ + method: 'POST', + headers: new Map(), + }); + const result = await csrf.protect(req, {}); + + expect(result.success).toBe(false); + expect(result.reason).toBeDefined(); + }); +}); diff --git a/packages/core/tests/errors.test.ts b/packages/core/tests/errors.test.ts new file mode 100644 index 0000000..268cd4d --- /dev/null +++ b/packages/core/tests/errors.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { CsrfError, TokenExpiredError, TokenInvalidError, OriginMismatchError } from '../src'; + +describe('CsrfError', () => { + it('instantiates with message, code, and statusCode', () => { + const err = new CsrfError('Something went wrong', 'SOME_CODE', 400); + expect(err.message).toBe('Something went wrong'); + expect(err.code).toBe('SOME_CODE'); + expect(err.statusCode).toBe(400); + }); + + it('sets name to "CsrfError"', () => { + const err = new CsrfError('msg', 'CODE'); + expect(err.name).toBe('CsrfError'); + }); + + it('defaults statusCode to 403', () => { + const err = new CsrfError('msg', 'CODE'); + expect(err.statusCode).toBe(403); + }); + + it('is an instance of Error', () => { + const err = new CsrfError('msg', 'CODE'); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe('TokenExpiredError', () => { + it('has the correct message', () => { + const err = new TokenExpiredError(); + expect(err.message).toBe('CSRF token has expired'); + }); + + it('has the correct code', () => { + const err = new TokenExpiredError(); + expect(err.code).toBe('TOKEN_EXPIRED'); + }); + + it('has the default statusCode of 403', () => { + const err = new TokenExpiredError(); + expect(err.statusCode).toBe(403); + }); + + it('is an instance of CsrfError', () => { + const err = new TokenExpiredError(); + expect(err).toBeInstanceOf(CsrfError); + }); + + it('is an instance of Error', () => { + const err = new TokenExpiredError(); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe('TokenInvalidError', () => { + it('uses the default reason when none is provided', () => { + const err = new TokenInvalidError(); + expect(err.message).toBe('CSRF token is invalid: Invalid token format'); + }); + + it('uses a custom reason when provided', () => { + const err = new TokenInvalidError('signature mismatch'); + expect(err.message).toBe('CSRF token is invalid: signature mismatch'); + }); + + it('has the correct code', () => { + const err = new TokenInvalidError(); + expect(err.code).toBe('TOKEN_INVALID'); + }); + + it('is an instance of CsrfError', () => { + const err = new TokenInvalidError(); + expect(err).toBeInstanceOf(CsrfError); + }); + + it('is an instance of Error', () => { + const err = new TokenInvalidError(); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe('OriginMismatchError', () => { + it('includes the origin in the message', () => { + const err = new OriginMismatchError('https://evil.example.com'); + expect(err.message).toBe('Origin "https://evil.example.com" is not allowed'); + }); + + it('has the correct code', () => { + const err = new OriginMismatchError('https://evil.example.com'); + expect(err.code).toBe('ORIGIN_MISMATCH'); + }); + + it('is an instance of CsrfError', () => { + const err = new OriginMismatchError('https://evil.example.com'); + expect(err).toBeInstanceOf(CsrfError); + }); + + it('is an instance of Error', () => { + const err = new OriginMismatchError('https://evil.example.com'); + expect(err).toBeInstanceOf(Error); + }); +}); diff --git a/packages/core/tests/validation.test.ts b/packages/core/tests/validation.test.ts index 8f6a3ca..ecb77a2 100644 --- a/packages/core/tests/validation.test.ts +++ b/packages/core/tests/validation.test.ts @@ -71,6 +71,43 @@ describe('Validation', () => { expect(result.isValid).toBe(false); expect(result.reason).toContain('not allowed'); }); + + it('should use referer header as origin fallback when origin is missing', () => { + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map([['referer', 'http://localhost/page']]), + cookies: new Map(), + }; + + const result = validateOrigin(request, TEST_CONFIG); + expect(result.isValid).toBe(true); + }); + + it('should return invalid with reason when both origin and referer are missing on POST', () => { + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map(), + cookies: new Map(), + }; + + const result = validateOrigin(request, TEST_CONFIG); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('Missing origin and referer headers'); + }); + + it('should derive origin from referer when no explicit origin header is present', () => { + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map([['referer', 'http://localhost/some/deep/path?query=1']]), + cookies: new Map(), + }; + + const result = validateOrigin(request, TEST_CONFIG); + expect(result.isValid).toBe(true); + }); }); describe('validateSignedToken', () => { @@ -150,6 +187,40 @@ describe('Validation', () => { expect(result.isValid).toBe(false); expect(result.reason).toBe('Token mismatch'); }); + + it('should return reason when CSRF cookie is missing', async () => { + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map([['x-csrf-token', 'some-token']]), + cookies: new Map(), + }; + + const result = await validateDoubleSubmit( + request, + TEST_CONFIG, + mockGetTokenFromRequest + ); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('No CSRF cookie found'); + }); + + it('should return reason when submitted token is missing', async () => { + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map(), + cookies: new Map([['csrf-token', 'some-token']]), + }; + + const result = await validateDoubleSubmit( + request, + TEST_CONFIG, + mockGetTokenFromRequest + ); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('No CSRF token submitted'); + }); }); // UPDATED TESTS - New signed-double-submit behavior @@ -410,5 +481,124 @@ describe('Validation', () => { ); expect(result.isValid).toBe(true); }); + + it('should route origin-check strategy to validateOrigin', async () => { + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map([['origin', 'http://localhost']]), + cookies: new Map(), + }; + + const config = { + ...TEST_CONFIG, + strategy: 'origin-check' as const, + }; + + const result = await validateRequest( + request, + config, + mockGetTokenFromRequest + ); + expect(result.isValid).toBe(true); + }); + + it('should route signed-token strategy to validateSignedToken', async () => { + const token = await generateSignedToken(TEST_CONFIG.secret, 3600); + + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map([['x-csrf-token', token]]), + cookies: new Map(), + }; + + const config = { + ...TEST_CONFIG, + strategy: 'signed-token' as const, + }; + + const result = await validateRequest( + request, + config, + mockGetTokenFromRequest + ); + expect(result.isValid).toBe(true); + }); + + it('should validate hybrid strategy when both origin and signed token are valid', async () => { + const token = await generateSignedToken(TEST_CONFIG.secret, 3600); + + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map([ + ['origin', 'http://localhost'], + ['x-csrf-token', token], + ]), + cookies: new Map(), + }; + + const config = { + ...TEST_CONFIG, + strategy: 'hybrid' as const, + }; + + const result = await validateRequest( + request, + config, + mockGetTokenFromRequest + ); + expect(result.isValid).toBe(true); + }); + + it('should fail hybrid strategy when origin is invalid even with a valid token', async () => { + const token = await generateSignedToken(TEST_CONFIG.secret, 3600); + + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map([ + ['origin', 'http://evil.com'], + ['x-csrf-token', token], + ]), + cookies: new Map(), + }; + + const config = { + ...TEST_CONFIG, + strategy: 'hybrid' as const, + }; + + const result = await validateRequest( + request, + config, + mockGetTokenFromRequest + ); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('not allowed'); + }); + + it('should return invalid with reason for an unknown strategy', async () => { + const request: CsrfRequest = { + method: 'POST', + url: 'http://localhost/api', + headers: new Map(), + cookies: new Map(), + }; + + const config = { + ...TEST_CONFIG, + strategy: 'unknown-strategy' as never, + }; + + const result = await validateRequest( + request, + config, + mockGetTokenFromRequest + ); + expect(result.isValid).toBe(false); + expect(result.reason).toBe('Invalid strategy'); + }); }); }); diff --git a/packages/express/tests/adapter.test.ts b/packages/express/tests/adapter.test.ts index 03f9f01..6fdc716 100644 --- a/packages/express/tests/adapter.test.ts +++ b/packages/express/tests/adapter.test.ts @@ -1,8 +1,29 @@ -import type { CsrfResponse } from '@csrf-armor/core'; +import type { CsrfRequest, CsrfResponse, RequiredCsrfConfig } from '@csrf-armor/core'; import type { Request, Response } from 'express'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ExpressAdapter } from '../src/adapter.js'; +const TEST_CONFIG: RequiredCsrfConfig = { + strategy: 'signed-double-submit', + secret: 'test-secret', + token: { + expiry: 3600, + headerName: 'X-CSRF-Token', + fieldName: 'csrf_token', + reissueThreshold: 500, + }, + cookie: { + name: 'csrf-token', + secure: true, + httpOnly: false, + sameSite: 'lax', + path: '/', + }, + allowedOrigins: [], + excludePaths: [], + skipContentTypes: [], +}; + describe('ExpressAdapter', () => { let adapter: ExpressAdapter; @@ -185,4 +206,140 @@ describe('ExpressAdapter', () => { expect(mockResponse.cookie).not.toHaveBeenCalled(); }); }); + + describe('getTokenFromRequest', () => { + it('should extract token from header', async () => { + const request: CsrfRequest = { + method: 'POST', + url: '/api/data', + headers: new Map([['x-csrf-token', 'header-token']]), + cookies: new Map(), + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('header-token'); + }); + + it('should extract token from cookie when header is missing', async () => { + const request: CsrfRequest = { + method: 'POST', + url: '/api/data', + headers: new Map(), + cookies: new Map([['csrf-token', 'cookie-token']]), + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('cookie-token'); + }); + + it('should extract token from query parameter when header and cookie are missing', async () => { + const request: CsrfRequest = { + method: 'GET', + url: '/api/data?csrf_token=query-token', + headers: new Map(), + cookies: new Map(), + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('query-token'); + }); + + it('should extract token from form body when all others are missing', async () => { + const request: CsrfRequest = { + method: 'POST', + url: '/api/data', + headers: new Map(), + cookies: new Map(), + body: { csrf_token: 'body-token' }, + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('body-token'); + }); + + it('should return undefined when token is not found anywhere', async () => { + const request: CsrfRequest = { + method: 'POST', + url: '/api/data', + headers: new Map(), + cookies: new Map(), + body: { other_field: 'some-value' }, + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBeUndefined(); + }); + + it('should give header priority over cookie', async () => { + const request: CsrfRequest = { + method: 'POST', + url: '/api/data', + headers: new Map([['x-csrf-token', 'header-token']]), + cookies: new Map([['csrf-token', 'cookie-token']]), + body: { csrf_token: 'body-token' }, + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('header-token'); + }); + + it('should give cookie priority over query parameter', async () => { + const request: CsrfRequest = { + method: 'GET', + url: '/api/data?csrf_token=query-token', + headers: new Map(), + cookies: new Map([['csrf-token', 'cookie-token']]), + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('cookie-token'); + }); + + it('should give query parameter priority over body', async () => { + const request: CsrfRequest = { + method: 'POST', + url: '/api/data?csrf_token=query-token', + headers: new Map(), + cookies: new Map(), + body: { csrf_token: 'body-token' }, + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('query-token'); + }); + + it('should handle object-format headers (not Map)', async () => { + const request: CsrfRequest = { + method: 'POST', + url: '/api/data', + headers: { 'x-csrf-token': 'header-token' }, + cookies: new Map(), + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('header-token'); + }); + + it('should handle object-format cookies (not Map)', async () => { + const request: CsrfRequest = { + method: 'POST', + url: '/api/data', + headers: new Map(), + cookies: { 'csrf-token': 'cookie-token' }, + }; + + const result = await adapter.getTokenFromRequest(request, TEST_CONFIG); + + expect(result).toBe('cookie-token'); + }); + }); }); diff --git a/packages/express/tests/middleware.test.ts b/packages/express/tests/middleware.test.ts index d1f48bc..40a9c46 100644 --- a/packages/express/tests/middleware.test.ts +++ b/packages/express/tests/middleware.test.ts @@ -491,4 +491,184 @@ describe('CSRF Middleware', () => { expect(mockNext).not.toHaveBeenCalled(); }); }); + + describe('HTTP method coverage', () => { + const createMockReq = (method: string, extras: Partial = {}) => + ({ + method, + url: '/api/data', + headers: {}, + cookies: {}, + get: vi.fn(), + header: vi.fn(), + accepts: vi.fn(), + acceptsCharsets: vi.fn(), + acceptsEncodings: vi.fn(), + acceptsLanguages: vi.fn(), + param: vi.fn(), + is: vi.fn(), + app: {}, + route: {}, + ...extras, + }) as unknown as Request; + + const createMockRes = () => + ({ + setHeader: vi.fn(), + cookie: vi.fn(), + }) as unknown as Response; + + it('should allow HEAD requests without validation', async () => { + const middleware = csrfMiddleware({ + secret: 'test-secret-key-32-chars-long-good', + strategy: 'double-submit', + }); + + const req = createMockReq('HEAD'); + const res = createMockRes(); + await middleware(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should allow OPTIONS requests without validation', async () => { + const middleware = csrfMiddleware({ + secret: 'test-secret-key-32-chars-long-good', + strategy: 'double-submit', + }); + + const req = createMockReq('OPTIONS'); + const res = createMockRes(); + await middleware(req, res, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should reject PUT requests without valid token', async () => { + const middleware = csrfMiddleware({ + secret: 'test-secret-key-32-chars-long-good', + strategy: 'signed-double-submit', + }); + + const req = createMockReq('PUT', { + cookies: { 'csrf-token': 'invalid' }, + } as Partial); + const res = createMockRes(); + + await expect(middleware(req, res, mockNext)).rejects.toThrow(CsrfError); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should reject DELETE requests without valid token', async () => { + const middleware = csrfMiddleware({ + secret: 'test-secret-key-32-chars-long-good', + strategy: 'signed-double-submit', + }); + + const req = createMockReq('DELETE', { + cookies: { 'csrf-token': 'invalid' }, + } as Partial); + const res = createMockRes(); + + await expect(middleware(req, res, mockNext)).rejects.toThrow(CsrfError); + expect(mockNext).not.toHaveBeenCalled(); + }); + + it('should reject PATCH requests without valid token', async () => { + const middleware = csrfMiddleware({ + secret: 'test-secret-key-32-chars-long-good', + strategy: 'signed-double-submit', + }); + + const req = createMockReq('PATCH', { + cookies: { 'csrf-token': 'invalid' }, + } as Partial); + const res = createMockRes(); + + await expect(middleware(req, res, mockNext)).rejects.toThrow(CsrfError); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); + + describe('Signed-token strategy', () => { + const createMockReq = (method: string, extras: Partial = {}) => + ({ + method, + url: '/api/data', + headers: {}, + cookies: {}, + get: vi.fn(), + header: vi.fn(), + accepts: vi.fn(), + acceptsCharsets: vi.fn(), + acceptsEncodings: vi.fn(), + acceptsLanguages: vi.fn(), + param: vi.fn(), + is: vi.fn(), + app: {}, + route: {}, + ...extras, + }) as unknown as Request; + + const createMockRes = () => + ({ + setHeader: vi.fn(), + cookie: vi.fn(), + }) as unknown as Response; + + it('should generate signed token on GET and validate on POST', async () => { + const secret = 'test-secret-key-32-chars-long-good'; + const middleware = csrfMiddleware({ + secret, + strategy: 'signed-token', + }); + + // GET to obtain token + const getReq = createMockReq('GET'); + const getRes = createMockRes(); + await middleware(getReq, getRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + const signedToken = getReq.csrfToken; + expect(signedToken).toBeDefined(); + // Signed tokens have dots (exp.nonce.signature) + expect(signedToken!.split('.').length).toBe(3); + + mockNext.mockClear(); + + // POST with valid signed token + const postReq = createMockReq('POST', { + headers: { + 'x-csrf-token': signedToken!, + }, + cookies: { + 'csrf-token': signedToken!, + }, + } as Partial); + const postRes = createMockRes(); + + await middleware(postReq, postRes, mockNext); + expect(mockNext).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should reject POST with invalid signed token', async () => { + const middleware = csrfMiddleware({ + secret: 'test-secret-key-32-chars-long-good', + strategy: 'signed-token', + }); + + const req = createMockReq('POST', { + headers: { + 'x-csrf-token': 'invalid.token.here', + }, + } as Partial); + const res = createMockRes(); + + await expect(middleware(req, res, mockNext)).rejects.toThrow(CsrfError); + expect(mockNext).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/nextjs/tests/client.test.ts b/packages/nextjs/tests/client.test.ts new file mode 100644 index 0000000..8d6ec72 --- /dev/null +++ b/packages/nextjs/tests/client.test.ts @@ -0,0 +1,244 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock next/navigation before importing the module +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(() => '/'), +})); + +import { + getCsrfToken, + createCsrfHeaders, + csrfFetch, + refreshCsrfToken, +} from '../src/client/client.js'; + +describe('Client utilities', () => { + beforeEach(() => { + // Clear cookies before each test + document.cookie.split(';').forEach((c) => { + document.cookie = c + .replace(/^ +/, '') + .replace(/=.*/, '=;expires=Thu, 01 Jan 1970 00:00:00 GMT'); + }); + }); + + describe('getCsrfToken', () => { + it('should return token from cookie', () => { + document.cookie = 'csrf-token=test-token-123'; + const token = getCsrfToken(); + expect(token).toBe('test-token-123'); + }); + + it('should use custom cookie name', () => { + document.cookie = 'my-csrf=custom-token'; + const token = getCsrfToken({ cookieName: 'my-csrf' }); + expect(token).toBe('custom-token'); + }); + + it('should decode URI-encoded cookie value', () => { + document.cookie = 'csrf-token=token%20with%20spaces'; + const token = getCsrfToken(); + expect(token).toBe('token with spaces'); + }); + + it('should return raw value if decodeURIComponent fails', () => { + // Set a cookie with a value that would fail decoding + // Use Object.defineProperty to simulate invalid encoding + const originalCookie = Object.getOwnPropertyDescriptor( + Document.prototype, + 'cookie' + ); + Object.defineProperty(document, 'cookie', { + get: () => 'csrf-token=%E0%A4%A', + set: () => {}, + configurable: true, + }); + + const token = getCsrfToken(); + expect(token).toBe('%E0%A4%A'); + + // Restore + if (originalCookie) { + Object.defineProperty(document, 'cookie', originalCookie); + } + }); + + it('should fall back to meta tag', () => { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'csrf-token'); + meta.setAttribute('content', 'meta-token-456'); + document.head.appendChild(meta); + + const token = getCsrfToken(); + expect(token).toBe('meta-token-456'); + + document.head.removeChild(meta); + }); + + it('should return null when no token found', () => { + const token = getCsrfToken(); + expect(token).toBeNull(); + }); + + it('should prefer cookie over meta tag', () => { + document.cookie = 'csrf-token=cookie-token'; + const meta = document.createElement('meta'); + meta.setAttribute('name', 'csrf-token'); + meta.setAttribute('content', 'meta-token'); + document.head.appendChild(meta); + + const token = getCsrfToken(); + expect(token).toBe('cookie-token'); + + document.head.removeChild(meta); + }); + }); + + describe('createCsrfHeaders', () => { + it('should return headers with token', () => { + document.cookie = 'csrf-token=header-test-token'; + const headers = createCsrfHeaders(); + expect(headers).toEqual({ 'x-csrf-token': 'header-test-token' }); + }); + + it('should use custom header name', () => { + document.cookie = 'csrf-token=custom-header-token'; + const headers = createCsrfHeaders({ headerName: 'X-Custom-CSRF' }); + expect(headers).toEqual({ 'X-Custom-CSRF': 'custom-header-token' }); + }); + + it('should return empty object when no token', () => { + const headers = createCsrfHeaders(); + expect(headers).toEqual({}); + }); + }); + + describe('csrfFetch', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('ok', { status: 200 }) + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should add CSRF headers to fetch request', async () => { + document.cookie = 'csrf-token=fetch-token'; + await csrfFetch('/api/data', { method: 'POST' }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/data', + expect.objectContaining({ + method: 'POST', + }) + ); + + const callArgs = (globalThis.fetch as ReturnType).mock + .calls[0]; + const headers = callArgs[1].headers as Headers; + expect(headers.get('x-csrf-token')).toBe('fetch-token'); + }); + + it('should merge with existing headers', async () => { + document.cookie = 'csrf-token=merge-token'; + await csrfFetch('/api/data', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + + const callArgs = (globalThis.fetch as ReturnType).mock + .calls[0]; + const headers = callArgs[1].headers as Headers; + expect(headers.get('x-csrf-token')).toBe('merge-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + }); + + it('should work without a token', async () => { + await csrfFetch('/api/data'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/data', + expect.objectContaining({ + headers: expect.any(Headers), + }) + ); + }); + }); + + describe('refreshCsrfToken', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should make HEAD request and return token from header', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { + headers: { 'x-csrf-token': 'refreshed-token' }, + }) + ); + + const token = await refreshCsrfToken(); + expect(token).toBe('refreshed-token'); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/', + expect.objectContaining({ + method: 'HEAD', + credentials: 'same-origin', + }) + ); + }); + + it('should use custom refresh endpoint', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { + headers: { 'x-csrf-token': 'token' }, + }) + ); + + await refreshCsrfToken({ refreshEndpoint: '/api/csrf/refresh' }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/csrf/refresh', + expect.any(Object) + ); + }); + + it('should fall back to cookie when no header token in response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { headers: {} }) + ); + document.cookie = 'csrf-token=fallback-token'; + + const token = await refreshCsrfToken(); + expect(token).toBe('fallback-token'); + }); + + it('should return current token on fetch failure', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new Error('Network error') + ); + document.cookie = 'csrf-token=current-token'; + + const token = await refreshCsrfToken(); + expect(token).toBe('current-token'); + }); + + it('should use custom header name', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { + headers: { 'X-Custom-CSRF': 'custom-refreshed' }, + }) + ); + + const token = await refreshCsrfToken({ + headerName: 'X-Custom-CSRF', + }); + expect(token).toBe('custom-refreshed'); + }); + }); +}); diff --git a/packages/nextjs/tests/middleware.test.ts b/packages/nextjs/tests/middleware.test.ts index dbc7db6..f2d69c1 100644 --- a/packages/nextjs/tests/middleware.test.ts +++ b/packages/nextjs/tests/middleware.test.ts @@ -333,4 +333,175 @@ describe('CSRF Middleware', () => { expect(postResult.success).toBe(false); expect(postResult.reason).toBe('Cookie integrity check failed'); }); + + it('should generate signed token for signed-token strategy', async () => { + const secret = 'test-secret-32-characters-long-123'; + const csrfProtect = createCsrfMiddleware({ + strategy: 'signed-token', + secret, + }); + + const request = new NextRequest('http://localhost/'); + const response = NextResponse.next(); + + const result = await csrfProtect(request, response); + + expect(result.success).toBe(true); + const headerToken = result.response.headers.get('x-csrf-token'); + expect(headerToken).toBeDefined(); + // Signed tokens have 3 parts: exp.nonce.signature + expect(headerToken!.split('.').length).toBe(3); + }); + + it('should validate signed-token POST request', async () => { + const secret = 'test-secret-32-characters-long-123'; + const csrfProtect = createCsrfMiddleware({ + strategy: 'signed-token', + secret, + }); + + // GET to obtain token + const getRequest = new NextRequest('http://localhost/'); + const getResponse = NextResponse.next(); + const getResult = await csrfProtect(getRequest, getResponse); + + const signedToken = getResult.response.headers.get('x-csrf-token'); + expect(signedToken).toBeDefined(); + + // POST with valid signed token + const postRequest = new NextRequest('http://localhost/api', { + method: 'POST', + headers: { + 'x-csrf-token': signedToken!, + 'Content-Type': 'application/json', + }, + }); + + vi.spyOn(postRequest.cookies, 'getAll').mockReturnValue([ + { name: 'csrf-token', value: signedToken! }, + ]); + + const postResponse = NextResponse.next(); + const postResult = await csrfProtect(postRequest, postResponse); + + expect(postResult.success).toBe(true); + }); + + it('should reject signed-token POST with invalid token', async () => { + const secret = 'test-secret-32-characters-long-123'; + const csrfProtect = createCsrfMiddleware({ + strategy: 'signed-token', + secret, + }); + + const postRequest = new NextRequest('http://localhost/api', { + method: 'POST', + headers: { + 'x-csrf-token': 'invalid.token.signature', + 'Content-Type': 'application/json', + }, + }); + + vi.spyOn(postRequest.cookies, 'getAll').mockReturnValue([ + { name: 'csrf-token', value: 'invalid.token.signature' }, + ]); + + const postResponse = NextResponse.next(); + const postResult = await csrfProtect(postRequest, postResponse); + + expect(postResult.success).toBe(false); + expect(postResult.reason).toContain('CSRF token is invalid'); + }); + + it('should handle hybrid strategy (origin + signed token)', async () => { + const secret = 'test-secret-32-characters-long-123'; + + // Use mocked adapter to properly inject origin header as Map + const adapter = new NextjsAdapter(); + const csrfProtection = createCsrfProtection(adapter, { + strategy: 'hybrid', + secret, + allowedOrigins: ['http://localhost'], + }); + + // GET to obtain token + const getRequest = new NextRequest('http://localhost/'); + const getResponse = NextResponse.next(); + const getResult = await csrfProtection.protect(getRequest, getResponse); + + const signedToken = getResult.response.headers.get('x-csrf-token'); + expect(signedToken).toBeDefined(); + // Hybrid uses signed tokens (3 parts) + expect(signedToken!.split('.').length).toBe(3); + + // POST with valid origin and valid signed token - mock extractRequest to ensure origin is visible + const postRequest = new NextRequest('http://localhost/api', { + method: 'POST', + headers: { + origin: 'http://localhost', + 'x-csrf-token': signedToken!, + 'Content-Type': 'application/json', + }, + }); + + vi.spyOn(adapter, 'extractRequest').mockImplementation((req) => ({ + method: 'POST', + url: 'http://localhost/api', + headers: new Map([ + ['origin', 'http://localhost'], + ['x-csrf-token', signedToken!], + ['content-type', 'application/json'], + ]), + cookies: new Map([['csrf-token', signedToken!]]), + body: req, + })); + + const postResponse = NextResponse.next(); + const postResult = await csrfProtection.protect(postRequest, postResponse); + + expect(postResult.success).toBe(true); + }); + + it('should reject hybrid POST with invalid origin', async () => { + const secret = 'test-secret-32-characters-long-123'; + + const adapter = new NextjsAdapter(); + const csrfProtection = createCsrfProtection(adapter, { + strategy: 'hybrid', + secret, + allowedOrigins: ['http://localhost'], + }); + + // GET to obtain token + const getRequest = new NextRequest('http://localhost/'); + const getResponse = NextResponse.next(); + const getResult = await csrfProtection.protect(getRequest, getResponse); + const signedToken = getResult.response.headers.get('x-csrf-token'); + + // POST with wrong origin + const postRequest = new NextRequest('http://localhost/api', { + method: 'POST', + headers: { + origin: 'http://evil.com', + 'x-csrf-token': signedToken!, + }, + }); + + vi.spyOn(adapter, 'extractRequest').mockImplementation((req) => ({ + method: 'POST', + url: 'http://localhost/api', + headers: new Map([ + ['origin', 'http://evil.com'], + ['x-csrf-token', signedToken!], + ]), + cookies: new Map([['csrf-token', signedToken!]]), + body: req, + })); + + const postResponse = NextResponse.next(); + const postResult = await csrfProtection.protect(postRequest, postResponse); + + expect(postResult.success).toBe(false); + expect(postResult.reason).toContain('not allowed'); + }); }); From 592d2bdc797a29bb857652d13bc8c5ef325fd835 Mon Sep 17 00:00:00 2001 From: Muneeb Date: Sun, 5 Apr 2026 14:18:14 +0200 Subject: [PATCH 2/4] chore: add author and contributors to package.json --- package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/package.json b/package.json index 3650e88..d8a9526 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,11 @@ "description": "Framework-agnostic CSRF protection library", "type": "module", "private": true, + "author": "Muneeb Samuels", + "contributors": [ + { "name": "Raul", "url": "https://github.com/raulcrisan" }, + { "name": "Jordan Labrosse", "url": "https://github.com/Jorgagu" } + ], "workspaces": [ "packages/*" ], From 5a5c86d3227d840fec0d3235e2c5459ad82a059d Mon Sep 17 00:00:00 2001 From: Muneeb Date: Sun, 5 Apr 2026 14:32:31 +0200 Subject: [PATCH 3/4] fix(deps): resolve high/moderate severity vulnerabilities in transitive deps Add pnpm overrides for lodash (>=4.18.0) and defu (>=6.1.5) to address GHSA-r5fr-rjxr-66jc (lodash code injection), GHSA-f23m-r3pf-42rh (lodash prototype pollution), and GHSA-737v-mqg7-c878 (defu prototype pollution). --- .changeset/fix-vulnerable-deps.md | 10 ++++++ package.json | 4 ++- packages/nextjs/package.json | 3 ++ pnpm-lock.yaml | 56 ++++++++++++++++--------------- 4 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 .changeset/fix-vulnerable-deps.md diff --git a/.changeset/fix-vulnerable-deps.md b/.changeset/fix-vulnerable-deps.md new file mode 100644 index 0000000..d1f7d25 --- /dev/null +++ b/.changeset/fix-vulnerable-deps.md @@ -0,0 +1,10 @@ +--- +"@csrf-armor/core": patch +"@csrf-armor/express": patch +"@csrf-armor/nextjs": patch +"@csrf-armor/nuxt": patch +--- + +fix: resolve high/moderate severity vulnerabilities in transitive dependencies + +Added pnpm overrides to force patched versions of `lodash` (>=4.18.0) and `defu` (>=6.1.5), which were pulled in transitively through the nuxt dependency chain. Addresses GHSA-r5fr-rjxr-66jc (lodash code injection), GHSA-f23m-r3pf-42rh (lodash prototype pollution), and GHSA-737v-mqg7-c878 (defu prototype pollution). diff --git a/package.json b/package.json index d8a9526..b142f3f 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,9 @@ "path-to-regexp@>=0.1.0 <0.2.0": "0.1.13", "minimatch@>=5.0.0 <6.0.0": "5.1.9", "minimatch@>=9.0.0 <10.0.0": "9.0.9", - "minimatch@>=10.0.0 <11.0.0": "10.2.4" + "minimatch@>=10.0.0 <11.0.0": "10.2.4", + "lodash": ">=4.18.0", + "defu": ">=6.1.5" } }, "packageManager": "pnpm@10.2.1+sha512.398035c7bd696d0ba0b10a688ed558285329d27ea994804a52bad9167d8e3a72bcb993f9699585d3ca25779ac64949ef422757a6c31102c12ab932e5cbe5cc92" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index aa398b9..47b061d 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -34,6 +34,9 @@ "react" ], "author": "Muneeb Samuels", + "contributors": [ + { "name": "Raul", "url": "https://github.com/raulcrisan" } + ], "license": "MIT", "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0240f56..5e5702a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,8 @@ overrides: minimatch@>=5.0.0 <6.0.0: 5.1.9 minimatch@>=9.0.0 <10.0.0: 9.0.9 minimatch@>=10.0.0 <11.0.0: 10.2.4 + lodash: '>=4.18.0' + defu: '>=6.1.5' importers: @@ -2737,8 +2739,8 @@ packages: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} - defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + defu@6.1.6: + resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} @@ -3407,8 +3409,8 @@ packages: lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -5887,7 +5889,7 @@ snapshots: confbox: 0.2.4 consola: 3.4.2 debug: 4.4.3 - defu: 6.1.4 + defu: 6.1.6 exsolve: 1.0.8 fuse.js: 7.1.0 fzf: 0.5.2 @@ -5982,7 +5984,7 @@ snapshots: dependencies: c12: 3.3.3(magicast@0.5.2) consola: 3.4.2 - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 errx: 0.1.0 exsolve: 1.0.8 @@ -6011,7 +6013,7 @@ snapshots: '@unhead/vue': 2.1.12(vue@3.5.31(typescript@5.9.3)) '@vue/shared': 3.5.30 consola: 3.4.2 - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 devalue: 5.6.4 errx: 0.1.0 @@ -6079,7 +6081,7 @@ snapshots: '@unhead/vue': 2.1.12(vue@3.5.31(typescript@5.9.3)) '@vue/shared': 3.5.30 consola: 3.4.2 - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 devalue: 5.6.4 errx: 0.1.0 @@ -6142,7 +6144,7 @@ snapshots: '@nuxt/schema@4.4.2': dependencies: '@vue/shared': 3.5.30 - defu: 6.1.4 + defu: 6.1.6 pathe: 2.0.3 pkg-types: 2.3.0 std-env: 4.0.0 @@ -6165,7 +6167,7 @@ snapshots: autoprefixer: 10.4.27(postcss@8.5.8) consola: 3.4.2 cssnano: 7.1.4(postcss@8.5.8) - defu: 6.1.4 + defu: 6.1.6 escape-string-regexp: 5.0.0 exsolve: 1.0.8 get-port-please: 3.2.0 @@ -6226,7 +6228,7 @@ snapshots: autoprefixer: 10.4.27(postcss@8.5.8) consola: 3.4.2 cssnano: 7.1.4(postcss@8.5.8) - defu: 6.1.4 + defu: 6.1.6 escape-string-regexp: 5.0.0 exsolve: 1.0.8 get-port-please: 3.2.0 @@ -7227,7 +7229,7 @@ snapshots: graceful-fs: 4.2.11 is-stream: 2.0.1 lazystream: 1.0.1 - lodash: 4.17.23 + lodash: 4.18.1 normalize-path: 3.0.0 readable-stream: 4.7.0 @@ -7372,7 +7374,7 @@ snapshots: dependencies: chokidar: 5.0.0 confbox: 0.2.4 - defu: 6.1.4 + defu: 6.1.6 dotenv: 17.3.1 exsolve: 1.0.8 giget: 2.0.0 @@ -7639,7 +7641,7 @@ snapshots: define-lazy-prop@3.0.0: {} - defu@6.1.4: {} + defu@6.1.6: {} denque@2.1.0: {} @@ -7987,7 +7989,7 @@ snapshots: dependencies: citty: 0.1.6 consola: 3.4.2 - defu: 6.1.4 + defu: 6.1.6 node-fetch-native: 1.6.7 nypm: 0.6.5 pathe: 2.0.3 @@ -8047,7 +8049,7 @@ snapshots: dependencies: cookie-es: 1.2.2 crossws: 0.3.5 - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 iron-webcrypto: 1.2.1 node-mock-http: 1.0.4 @@ -8320,7 +8322,7 @@ snapshots: clipboardy: 4.0.0 consola: 3.4.2 crossws: 0.3.5 - defu: 6.1.4 + defu: 6.1.6 get-port-please: 3.2.0 h3: 1.15.10 http-shutdown: 1.2.2 @@ -8353,7 +8355,7 @@ snapshots: lodash.uniq@4.5.0: {} - lodash@4.17.23: {} + lodash@4.18.1: {} lru-cache@10.4.3: {} @@ -8511,7 +8513,7 @@ snapshots: croner: 9.1.0 crossws: 0.3.5 db0: 0.3.4 - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 dot-prop: 10.1.0 esbuild: 0.27.3 @@ -8643,7 +8645,7 @@ snapshots: compatx: 0.2.0 consola: 3.4.2 cookie-es: 2.0.0 - defu: 6.1.4 + defu: 6.1.6 devalue: 5.6.4 errx: 0.1.0 escape-string-regexp: 5.0.0 @@ -8772,7 +8774,7 @@ snapshots: compatx: 0.2.0 consola: 3.4.2 cookie-es: 2.0.0 - defu: 6.1.4 + defu: 6.1.6 devalue: 5.6.4 errx: 0.1.0 escape-string-regexp: 5.0.0 @@ -9306,12 +9308,12 @@ snapshots: rc9@2.1.2: dependencies: - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 rc9@3.0.0: dependencies: - defu: 6.1.4 + defu: 6.1.6 destr: 2.0.5 react-dom@19.1.0(react@19.1.0): @@ -9539,7 +9541,7 @@ snapshots: serve-placeholder@2.0.2: dependencies: - defu: 6.1.4 + defu: 6.1.6 serve-static@2.2.1: dependencies: @@ -9839,7 +9841,7 @@ snapshots: dependencies: ansis: 4.2.0 cac: 6.7.14 - defu: 6.1.4 + defu: 6.1.6 empathic: 2.0.0 hookable: 6.0.1 import-without-cache: 0.2.5 @@ -9866,7 +9868,7 @@ snapshots: dependencies: ansis: 4.2.0 cac: 6.7.14 - defu: 6.1.4 + defu: 6.1.6 empathic: 2.0.0 hookable: 6.0.1 import-without-cache: 0.2.5 @@ -10027,7 +10029,7 @@ snapshots: untyped@2.0.0: dependencies: citty: 0.1.6 - defu: 6.1.4 + defu: 6.1.6 jiti: 2.6.1 knitwork: 1.3.0 scule: 1.3.0 From fced33c80df479f657d3ce44de0d6eee4b777857 Mon Sep 17 00:00:00 2001 From: Muneeb Samuels Date: Sun, 5 Apr 2026 14:37:22 +0200 Subject: [PATCH 4/4] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Muneeb Samuels --- packages/core/tests/csrf.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tests/csrf.test.ts b/packages/core/tests/csrf.test.ts index 853b81f..d619e40 100644 --- a/packages/core/tests/csrf.test.ts +++ b/packages/core/tests/csrf.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { describe, expect, it, beforeEach } from 'vitest'; import { CsrfProtection, createCsrfProtection } from '../src/csrf.js'; import type { CsrfAdapter,