Skip to content

Commit

Permalink
we do some testing tomfoolery
Browse files Browse the repository at this point in the history
  • Loading branch information
Lordfirespeed committed Aug 23, 2024
1 parent f3fbd60 commit b9322dd
Show file tree
Hide file tree
Showing 5 changed files with 61 additions and 85 deletions.
34 changes: 7 additions & 27 deletions tests/doublecsrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,52 +12,33 @@ 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,
delimiter: "~",
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,
Expand All @@ -73,7 +54,7 @@ describe("csrf-csrf token-rotation", () => {
const doubleCsrfOptions: Omit<DoubleCsrfConfig, "getSecret" | "getSessionIdentifier"> = {}

const {
cookieOptions: { name: cookieName = "__Host-otter.x-csrf-token", signed = false } = {},
cookieOptions: { name: cookieName = "__Host-otter.x-csrf-token" } = {},
} = doubleCsrfOptions

const SECRET1 = "secret1"
Expand All @@ -89,7 +70,6 @@ describe("csrf-csrf token-rotation", () => {
return {
...generateMocksWithToken({
cookieName,
signed,
generateToken,
validateRequest,
}),
Expand Down
35 changes: 14 additions & 21 deletions tests/testsuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -48,7 +47,6 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => {
const generateMocksWithTokenInternal = () =>
generateMocksWithToken({
cookieName,
signed,
generateToken,
validateRequest,
})
Expand All @@ -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,
Expand Down Expand Up @@ -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, {
Expand All @@ -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, {
Expand All @@ -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, {
Expand All @@ -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, {
Expand All @@ -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 = {}
Expand All @@ -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()
}

Expand All @@ -220,7 +214,6 @@ export const createTestSuite: CreateTestSuite = (name, doubleCsrfOptions) => {
// Show an invalid case
const { mockResponse: mockResponseWithToken } = generateMocksWithToken({
cookieName,
signed,
generateToken,
validateRequest,
})
Expand All @@ -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)
})

Expand Down
19 changes: 11 additions & 8 deletions tests/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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(";"))
Expand All @@ -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(";"))
Expand All @@ -65,17 +67,18 @@ export const attachResponseValuesToRequest = ({
cookieName,
headerKey,
}: {
request: Request
response: Response
request: CSRFRequest
response: CSRFResponse
bodyResponseToken: string
cookieName: string
headerKey: string
}) => {
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
}
Expand Down
8 changes: 0 additions & 8 deletions tests/utils/mock-types.ts

This file was deleted.

50 changes: 29 additions & 21 deletions tests/utils/mock.ts
Original file line number Diff line number Diff line change
@@ -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<CSRFRequest>, 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,
Expand All @@ -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
}
Expand All @@ -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) => {
Expand All @@ -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

Expand Down

0 comments on commit b9322dd

Please sign in to comment.