diff --git a/tests/doublecsrf.test.ts b/tests/doublecsrf.test.ts index 1460b8b..8e5566f 100644 --- a/tests/doublecsrf.test.ts +++ b/tests/doublecsrf.test.ts @@ -12,27 +12,16 @@ import { } from "./utils/helpers.js" import { generateMocks, generateMocksWithToken } from "./utils/mock.js" -createTestSuite("csrf-csrf unsigned, single secret", { +createTestSuite("csrf-csrf single secret", { getSecret: getSingleSecret, getSessionIdentifier: legacySessionIdentifier, }) -createTestSuite("csrf-csrf signed, single secret", { - cookieOptions: { signed: true, getSigningSecret: () => COOKIE_SECRET }, - getSecret: getSingleSecret, - getSessionIdentifier: legacySessionIdentifier, - errorConfig: { - statusCode: 400, - message: "NOT GOOD", - code: "BADTOKEN", - }, -}) -createTestSuite("csrf-csrf signed with custom options, single secret", { + +createTestSuite("csrf-csrf custom options, single secret", { getSecret: getSingleSecret, getSessionIdentifier: legacySessionIdentifier, cookieOptions: { name: "__Host.test-the-thing.token", - signed: true, - getSigningSecret: () => COOKIE_SECRET, sameSite: "strict", }, size: 128, @@ -40,24 +29,16 @@ createTestSuite("csrf-csrf signed with custom options, single secret", { hmacAlgorithm: "sha512", }) -createTestSuite("csrf-csrf unsigned, multiple secrets", { +createTestSuite("csrf-csrf multiple secrets", { getSecret: getMultipleSecrets, getSessionIdentifier: legacySessionIdentifier, }) -createTestSuite("csrf-csrf signed, multiple secrets", { - cookieOptions: { signed: true, getSigningSecret: () => COOKIE_SECRET }, - getSecret: getMultipleSecrets, - getSessionIdentifier: legacySessionIdentifier, - delimiter: "~", - hmacAlgorithm: "sha512", -}) -createTestSuite("csrf-csrf signed with custom options, multiple secrets", { + +createTestSuite("csrf-csrf custom options, multiple secrets", { getSecret: getMultipleSecrets, getSessionIdentifier: legacySessionIdentifier, cookieOptions: { name: "__Host.test-the-thing.token", - signed: true, - getSigningSecret: () => COOKIE_SECRET, sameSite: "strict", }, size: 128, @@ -73,7 +54,7 @@ describe("csrf-csrf token-rotation", () => { const doubleCsrfOptions: Omit = {} const { - cookieOptions: { name: cookieName = "__Host-otter.x-csrf-token", signed = false } = {}, + cookieOptions: { name: cookieName = "__Host-otter.x-csrf-token" } = {}, } = doubleCsrfOptions const SECRET1 = "secret1" @@ -89,7 +70,6 @@ describe("csrf-csrf token-rotation", () => { return { ...generateMocksWithToken({ cookieName, - signed, generateToken, validateRequest, }), diff --git a/tests/testsuite.ts b/tests/testsuite.ts index 655e401..b51e918 100644 --- a/tests/testsuite.ts +++ b/tests/testsuite.ts @@ -6,10 +6,10 @@ import { assert, describe, expect, it } from "vitest" import { COOKIE_SECRET, HEADER_KEY, TEST_TOKEN } from "./utils/constants" import { getCookieFromRequest, getCookieFromResponse, switchSecret } from "./utils/helpers" import { generateMocks, generateMocksWithToken, next } from "./utils/mock" -import type { Request, Response } from "./utils/mock-types" import { doubleCsrf } from "@/index" -import type { DoubleCsrfConfig } from "@/types" +import type { DoubleCsrfConfig, CSRFRequest, CSRFResponse } from "@/types" +import { Cookie } from "@otterhttp/request"; type CreateTestSuite = ( name: string, @@ -33,7 +33,6 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { const { cookieOptions: { name: cookieName = "__Host-otter.x-csrf-token", - signed = false, path = "/", secure = true, sameSite = "lax", @@ -48,7 +47,6 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { const generateMocksWithTokenInternal = () => generateMocksWithToken({ cookieName, - signed, generateToken, validateRequest, }) @@ -63,7 +61,7 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { describe("generateToken", () => { it("should attach both a token and its hash to the response and return a token", () => { const { mockRequest, decodedCookieValue, setCookie } = generateMocksWithTokenInternal() - const cookieValue = signed ? `s:${sign(decodedCookieValue as string, COOKIE_SECRET)}` : decodedCookieValue + const cookieValue = `s:${sign(decodedCookieValue as string, COOKIE_SECRET)}` const expectedSetCookieValue = serializeCookie(cookieName, cookieValue as string, { path, @@ -106,10 +104,8 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { it("should throw if csrf cookie is present and invalid, overwrite is false, and validateOnReuse is enabled", () => { const { mockRequest, mockResponse, decodedCookieValue } = generateMocksWithTokenInternal() // modify the cookie to make the token/hash pair invalid - const cookieJar = signed ? mockRequest.signedCookies : mockRequest.cookies - cookieJar[cookieName] = signed - ? `s:${sign(`${(decodedCookieValue as string).split("|")[0]}|invalid-hash`, COOKIE_SECRET)}` - : `${(decodedCookieValue as string).split("|")[0]}|invalid-hash` + const cookieJar = mockRequest.cookies + cookieJar[cookieName] = new Cookie(`${(decodedCookieValue as string).split("|")[0]}|invalid-hash`) expect(() => generateToken(mockRequest, mockResponse, { @@ -119,7 +115,7 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { ).to.throw(invalidCsrfTokenError.message) // just an invalid value in the cookie - cookieJar[cookieName] = signed ? `s:${sign("invalid-value", COOKIE_SECRET)}` : "invalid-value" + cookieJar[cookieName] = new Cookie("invalid-value") expect(() => generateToken(mockRequest, mockResponse, { @@ -143,10 +139,8 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { mockResponse.setHeader("set-cookie", []) // modify the cookie to make the token/hash pair invalid - const cookieJar = signed ? mockRequest.signedCookies : mockRequest.cookies - cookieJar[cookieName] = signed - ? `s:${sign(`${(decodedCookieValue as string).split("|")[0]}|invalid-hash`, COOKIE_SECRET)}` - : `${(decodedCookieValue as string).split("|")[0]}|invalid-hash` + const cookieJar = mockRequest.cookies + cookieJar[cookieName] = new Cookie(`${(decodedCookieValue as string).split("|")[0]}|invalid-hash`) assert.doesNotThrow(() => { generatedToken = generateToken(mockRequest, mockResponse, { @@ -159,7 +153,7 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { assert.notEqual(generatedToken, csrfToken) // just an invalid value in the cookie - cookieJar[cookieName] = signed ? `s:${sign("invalid-value", COOKIE_SECRET)}` : "invalid-value" + cookieJar[cookieName] = new Cookie("invalid-value") assert.doesNotThrow(() => { generatedToken = generateToken(mockRequest, mockResponse, { @@ -182,7 +176,7 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { it("should return false when a token is generated but not received in request", () => { const { mockRequest, decodedCookieValue } = generateMocksWithTokenInternal() - assert.equal(getCookieFromRequest(cookieName, signed, mockRequest), decodedCookieValue) + assert.equal(getCookieFromRequest(cookieName, mockRequest), decodedCookieValue) // Wipe token mockRequest.headers = {} @@ -198,17 +192,17 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { it("should return false when cookie is not present", () => { const { mockRequest } = generateMocksWithTokenInternal() // Wipe hash - signed ? delete mockRequest.signedCookies[cookieName] : delete mockRequest.cookies[cookieName] + delete mockRequest.cookies[cookieName] assert.isFalse(validateRequest(mockRequest)) }) }) describe("doubleCsrfProtection", () => { - const assertProtectionToThrow = (request: Request, response: Response) => { + const assertProtectionToThrow = (request: CSRFRequest, response: CSRFResponse) => { expect(() => doubleCsrfProtection(request, response, next)).to.throw(invalidCsrfTokenError.message) } - const assertProtectionToNotThrow = (request: Request, response: Response) => { + const assertProtectionToNotThrow = (request: CSRFRequest, response: CSRFResponse) => { expect(() => doubleCsrfProtection(request, response, next)).to.not.throw() } @@ -220,7 +214,6 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { // Show an invalid case const { mockResponse: mockResponseWithToken } = generateMocksWithToken({ cookieName, - signed, generateToken, validateRequest, }) @@ -245,7 +238,7 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => { it("should not allow a protected request with no cookie", () => { const { mockResponse, mockRequest } = generateMocksWithTokenInternal() - signed ? delete mockRequest.signedCookies[cookieName] : delete mockRequest.cookies[cookieName] + delete mockRequest.cookies[cookieName] assertProtectionToThrow(mockRequest, mockResponse) }) diff --git a/tests/utils/helpers.ts b/tests/utils/helpers.ts index e46649c..b74bd40 100644 --- a/tests/utils/helpers.ts +++ b/tests/utils/helpers.ts @@ -1,4 +1,6 @@ -import type { Request, Response } from "./mock-types.js" +import { Cookie } from "@otterhttp/request"; + +import type { CSRFRequest, CSRFResponse } from "@/types" const SECRET_1 = "secrets must be unique and must not" const SECRET_2 = "be used elsewhere, nor be sentences" @@ -24,7 +26,7 @@ export const { getSingleSecret, getMultipleSecrets, switchSecret } = (() => { * @param res The response object * @returns The set-cookie header string and the cookie value containing both the csrf token and its hash */ -export const getCookieValueFromResponse = (res: Response) => { +export const getCookieValueFromResponse = (res: CSRFResponse) => { const setCookie = res.getHeader("set-cookie") as string | string[] const setCookieString: string = Array.isArray(setCookie) ? setCookie[0] : setCookie const cookieValue = setCookieString.substring(setCookieString.indexOf("=") + 1, setCookieString.indexOf(";")) @@ -36,12 +38,12 @@ export const getCookieValueFromResponse = (res: Response) => { } // Returns the cookie value from the request, accommodate signed and unsigned. -export const getCookieFromRequest = (cookieName: string, signed: boolean, req: Request) => +export const getCookieFromRequest = (cookieName: string, req: CSRFRequest) => // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access - signed ? req.signedCookies[cookieName] : req.cookies[cookieName] + req.cookies[cookieName].value // as of now, we only have one cookie, so we can just return the first one -export const getCookieFromResponse = (res: Response) => { +export const getCookieFromResponse = (res: CSRFResponse) => { const setCookie = res.getHeader("set-cookie") as string | string[] const setCookieString: string = Array.isArray(setCookie) ? setCookie[0] : setCookie const cookieValue = setCookieString.substring(setCookieString.indexOf("=") + 1, setCookieString.indexOf(";")) @@ -65,8 +67,8 @@ export const attachResponseValuesToRequest = ({ cookieName, headerKey, }: { - request: Request - response: Response + request: CSRFRequest + response: CSRFResponse bodyResponseToken: string cookieName: string headerKey: string @@ -74,8 +76,9 @@ export const attachResponseValuesToRequest = ({ const { cookieValue } = getCookieValueFromResponse(response) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - request.cookies[cookieName] = decodeURIComponent(cookieValue) request.headers.cookie = `${cookieName}=${cookieValue};` + // @ts-expect-error + request._cookies = null request.headers[headerKey] = bodyResponseToken } diff --git a/tests/utils/mock-types.ts b/tests/utils/mock-types.ts deleted file mode 100644 index b540897..0000000 --- a/tests/utils/mock-types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { IncomingMessage, ServerResponse } from "node:http" - -export type Request = IncomingMessage & { - cookies: Record - signedCookies: Record -} - -export type Response = ServerResponse diff --git a/tests/utils/mock.ts b/tests/utils/mock.ts index b3ebd0b..ae7779e 100644 --- a/tests/utils/mock.ts +++ b/tests/utils/mock.ts @@ -1,23 +1,39 @@ -import { IncomingMessage, ServerResponse } from "node:http" +import { ServerResponse } from "node:http" import type { Socket } from "node:net" -import { parse } from "@otterhttp/cookie" -import { cookieParser, signedCookie } from "@tinyhttp/cookie-parser" +import { parse, serialize, type SerializeOptions } from "@otterhttp/cookie" +import {sign, unsign} from "@otterhttp/cookie-signature" +import { Request } from "@otterhttp/request" import { assert } from "vitest" import { COOKIE_SECRET, HEADER_KEY } from "./constants.js" import { getCookieFromRequest, getCookieValueFromResponse } from "./helpers.js" -import type { Request, Response } from "./mock-types" -import type { CsrfRequestValidator, CsrfTokenCreator } from "@/types.js" +import type { CSRFRequest, CSRFResponse, CsrfRequestValidator, CsrfTokenCreator } from "@/types.js" // Create some request and response mocks export const generateMocks = () => { - const mockRequest: Request = Object.assign(new IncomingMessage(undefined as unknown as Socket), { - cookies: {}, - signedCookies: {}, + const mockRequest: CSRFRequest = Object.assign(new Request(undefined as unknown as Socket), { + appSettings: { + cookieParsing: { + encodedCookieMatcher: (value: string) => decodeURIComponent(value).startsWith("s:"), + cookieDecoder: (value: string) => { + const result = unsign(decodeURIComponent(value).slice(2), COOKIE_SECRET) + if (result === false) throw new Error("Failed to parse cookie") + return result + } + } + } }) - const mockResponse: Response = new ServerResponse(mockRequest) + const mockResponse: CSRFResponse = Object.assign(new ServerResponse(mockRequest), { + cookie: function (this: ServerResponse, name: string, value: string, options?: SerializeOptions): unknown { + const resolvedOptions = Object.assign({}, { + encode: (value: string) => encodeURIComponent(`s:${sign(value, COOKIE_SECRET)}`) + }, options) + this.appendHeader('set-cookie', serialize(name, value, resolvedOptions)) + return this + }, + }) return { mockRequest, @@ -27,11 +43,8 @@ export const generateMocks = () => { export const next = () => undefined -export const cookieParserMiddleware = cookieParser(COOKIE_SECRET) - export type GenerateMocksWithTokenOptions = { cookieName: string - signed: boolean generateToken: CsrfTokenCreator validateRequest: CsrfRequestValidator } @@ -40,7 +53,6 @@ export type GenerateMocksWithTokenOptions = { // Set them up as if they have been pre-processed in a valid state. export const generateMocksWithToken = ({ cookieName, - signed, generateToken, validateRequest, }: GenerateMocksWithTokenOptions) => { @@ -49,15 +61,11 @@ export const generateMocksWithToken = ({ const csrfToken = generateToken(mockRequest, mockResponse) const { setCookie, cookieValue } = getCookieValueFromResponse(mockResponse) mockRequest.headers.cookie = `${cookieName}=${cookieValue};` - const decodedCookieValue = signed - ? signedCookie(parse(mockRequest.headers.cookie)[cookieName], COOKIE_SECRET) - : // signedCookie already decodes the value, but we need it if it's not signed. - decodeURIComponent(cookieValue) - // Have to delete the cookies object otherwise cookieParser will skip its parsing. + // @ts-expect-error - mockRequest.cookies = undefined - cookieParserMiddleware(mockRequest, mockResponse, next) - assert.equal(getCookieFromRequest(cookieName, signed, mockRequest), decodedCookieValue) + mockRequest._cookies = null + const decodedCookieValue = mockRequest.cookies[cookieName].value + assert.equal(getCookieFromRequest(cookieName, mockRequest), decodedCookieValue) mockRequest.headers[HEADER_KEY] = csrfToken