Repository: j0lv3r4/next-csrf Branch: main Commit: f3a9288bba25 Files: 36 Total size: 43.1 KB Directory structure: gitextract_4a39ddmd/ ├── .eslintrc.js ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── example/ │ ├── .gitignore │ ├── components/ │ │ └── layout.js │ ├── lib/ │ │ └── csrf.js │ ├── package.json │ ├── pages/ │ │ ├── _app.js │ │ ├── api/ │ │ │ ├── csrf/ │ │ │ │ └── setup.js │ │ │ ├── hello.js │ │ │ └── protected.js │ │ ├── index.js │ │ └── login.js │ └── styles/ │ ├── Home.module.css │ └── globals.css ├── jest.config.js ├── package.json ├── playwright.config.ts ├── rollup.config.js ├── src/ │ ├── index.e2e.ts │ ├── index.ts │ ├── middleware/ │ │ ├── csrf.test.ts │ │ ├── csrf.ts │ │ ├── index.ts │ │ └── setup.ts │ ├── package.json │ ├── types.ts │ └── utils/ │ ├── create-token.ts │ ├── get-cookie.ts │ ├── get-secret.ts │ ├── httpError.ts │ └── index.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.js ================================================ module.exports = { "env": { "browser": true, "es2020": true }, "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended" ], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 11, "sourceType": "module" }, "plugins": [ "@typescript-eslint" ], "rules": { "indent": [ "error", 2 ], "linebreak-style": [ "error", "unix" ], "quotes": [ "error", "double" ], "semi": [ "error", "always" ] } }; ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [jOlv3r4] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .gitignore ================================================ #### joe made this: http://goel.io/joe #### linux #### *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* #### macos #### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk #### node #### # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ @types # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env # parcel-bundler cache (https://parceljs.org/) .cache # next.js build output .next # nuxt.js build output .nuxt # vuepress build output .vuepress/dist # Serverless directories .serverless #### vim #### # Swap [._]*.s[a-v][a-z] [._]*.sw[a-p] [._]s[a-rt-v][a-z] [._]ss[a-gi-z] [._]sw[a-p] # Session Session.vim # Temporary .netrwhist *~ # Auto-generated tag files tags # Persistent undo [._]*.un~ #### visualstudiocode #### .vscode/* dist .idea .now example/.vercel #### yalc #### example/.yalc **/.yalc/**/*.md ================================================ FILE: .npmignore ================================================ rollup.config.js src package-lock.json yarn.lock ================================================ FILE: LICENSE.md ================================================ MIT License Copyright (c) 2022 Juan Olvera Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # next-csrf ![Discord](https://discord.com/api/guilds/967474884378763314/widget.png) CSRF mitigation for Next.js. ## Features Mitigation patterns that `next-csrf` implements: * [Synchronizer Token Pattern](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern) using [`csrf`](https://github.com/pillarjs/csrf) (Also [read Understanding CSRF](https://github.com/pillarjs/understanding-csrf#csrf-tokens)) ## Installation With yarn: ```bash yarn add next-csrf ``` With npm: ```bash npm i next-csrf --save ``` ## Usage Create an initialization file to add options: ```js // file: lib/csrf.js import { nextCsrf } from "next-csrf"; const { csrf, setup } = nextCsrf({ // eslint-disable-next-line no-undef secret: process.env.CSRF_SECRET, }); export { csrf, setup }; ``` Protect an API endpoint: ```js // file: pages/api/protected.js import { csrf } from '../lib/csrf'; const handler = (req, res) => { return res.status(200).json({ message: "This API route is protected."}) } export default csrf(handler); ``` Test the protected API route by sending a POST request from your terminal. Since this request doesn't have the proper token setup, it wil fail. ```shell curl -X POST http://localhost:3000/api/protected >> {"message": "Invalid CSRF token"} ``` Use an [SSG page](https://nextjs.org/docs/basic-features/pages#server-side-rendering) to set up the token. Usually, you use CSRF mitigation to harden your requests from authenticated users, if this is the case then you should use the login page. ```js // file: pages/login.js import { setup } from '../lib/csrf'; function Login() { const loginRequest = async (event) => { event.preventDefault(); // The secret and token are sent with the request by default, so no extra // configuration is needed in the request. const response = await fetch('/api/protected', { method: 'post' }); if (response.ok) { console.log('protected response ok'); } } return (
) } // Here's the important part. `setup` saves the necesary secret and token. export const getServerSideProps = setup(async ({req, res}) => { return { props: {}} }); export default Login; ``` ## API ### `nextCsrf(options);` Returns two functions: * `setup` Setups two cookies, one for the secret and other one for the token. Only works on SSG pages. * `csrf` Protects API routes from requests without the token. Validates and verify signatures on the cookies. #### `options` * `tokenKey` (`string`) The name of the cookie to store the CSRF token. Default is `"XSRF-TOKEN"`. * `csrfErrorMessage` (`string`) Error message to return for unauthorized requests. Default is `"Invalid CSRF token"`. * `ignoredMethods`: (`string[]`) Methods to ignore, i.e. let pass all requests with these methods. Default is `["GET", "HEAD", "OPTIONS"]`. * `cookieOptions`: Same options as https://www.npmjs.com/package/cookie ================================================ FILE: example/.gitignore ================================================ .vercel .yalc ================================================ FILE: example/components/layout.js ================================================ import Head from "next/head"; import Link from "next/link"; import styles from "../styles/Home.module.css"; function Layout({ children }) { return (
Next CSRF

Next CSRF

{children}
); } export default Layout; ================================================ FILE: example/lib/csrf.js ================================================ import { nextCsrf } from "next-csrf"; const { csrf, setup } = nextCsrf({ // eslint-disable-next-line no-undef secret: process.env.CSRF_SECRET, }); export { csrf, setup }; ================================================ FILE: example/package.json ================================================ { "name": "next-csrf-example", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start" }, "dependencies": { "next": "9.5.3", "next-csrf": "file:.yalc/next-csrf", "react": "16.13.1", "react-dom": "16.13.1" }, "devDependencies": { "prettier": "^2.1.1" } } ================================================ FILE: example/pages/_app.js ================================================ import Layout from "../components/layout"; import "../styles/globals.css"; function MyApp({ Component, pageProps }) { return ( ); } export default MyApp; ================================================ FILE: example/pages/api/csrf/setup.js ================================================ import { setup } from "../../../lib/csrf"; const handler = (req, res) => { res.statusCode = 200; res.json({ message: "CSRF token added to cookies" }); }; export default setup(handler); ================================================ FILE: example/pages/api/hello.js ================================================ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction export default (req, res) => { res.statusCode = 200; res.json({ name: "John Doe" }); }; ================================================ FILE: example/pages/api/protected.js ================================================ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction import { csrf } from "../../lib/csrf"; const handler = (req, res) => { res.statusCode = 200; res.json({ message: "Request successful" }); }; export default csrf(handler); ================================================ FILE: example/pages/index.js ================================================ import styles from "../styles/Home.module.css"; import Link from "next/link"; export default function Home() { return ( <>

Get started by editing{" "} pages/index.js

Send a request without the CSRF token

Because any request we send from the browser will have a cookie with the token attached, try to send a request from a terminal and see what happens with a missing or an invalid CSRF token.

            
              $ curl -X POST http://localhost:3000/api/protected
            
          
            
              {`>> {"message": "Invalid CSRF token"}`}
            
          

Send a request with the proper CSRF token setup

  1. Go to the Login page
  2. Open the Console
  3. Fill the form with "demo" for username and "demo" for password
  4. Submit the form, inspect the Console and the Network
  5. Try to modify the cookie values, try again, and see the request being blocked.
); } ================================================ FILE: example/pages/login.js ================================================ import styles from "../styles/Home.module.css"; import { setup } from "../lib/csrf"; function Login() { const requestWithToken = async (event) => { event.preventDefault(); const response = await fetch("/api/protected", { method: "post", }); if (response.ok) { console.log("protected response ok"); console.log(response); } }; return ( <>

Use "demo" for both username and password

); } export const getServerSideProps = setup(async ({ req, res }) => { return { props: {} }; }); export default Login; ================================================ FILE: example/styles/Home.module.css ================================================ .container { min-height: 100vh; padding: 0 0.5rem; display: flex; flex-direction: column; justify-content: center; align-items: center; } .form .info { color: #333; } .form { border: 1px solid #ccc; padding: 1rem; } .form label { padding: .5rem 0; display: flex; flex-direction: column; } .form input { margin-top: .5rem; } .form button { margin-top: .5rem; } .nav { display: flex; justify-content: center; padding-top: 1rem; padding-bottom: 1rem; margin-top: 1rem; margin-bottom: 1rem; } .nav a { text-decoration: underline; color: blue; margin-right: .5rem; } .main { max-width: 45rem; padding: 5rem 0; flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; } .footer { width: 100%; height: 100px; border-top: 1px solid #eaeaea; display: flex; justify-content: center; align-items: center; } .footer img { margin-left: 0.5rem; } .footer a { display: flex; justify-content: center; align-items: center; } .title a { color: #0070f3; text-decoration: none; } .title a:hover, .title a:focus, .title a:active { text-decoration: underline; } .title { margin: 0; line-height: 1.15; font-size: 4rem; } .title, .description { text-align: center; } .description { line-height: 1.5; font-size: 1.5rem; } .code { background: #fafafa; border-radius: 5px; padding: 0.75rem; font-size: 1.1rem; font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace; } .grid { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; max-width: 800px; margin-top: 3rem; } .button { padding: .5rem 1rem; border: 1px solid black; background: black; color: white; font-size: 1.2rem; } .card { margin: 1rem; flex-basis: 45%; padding: 1.5rem; text-align: left; color: inherit; text-decoration: none; border: 1px solid #eaeaea; border-radius: 10px; transition: color 0.15s ease, border-color 0.15s ease; } .card:hover, .card:focus, .card:active { color: #0070f3; border-color: #0070f3; } .card h3 { margin: 0 0 1rem 0; font-size: 1.5rem; } .card p { margin-top: 0; font-size: 1.25rem; line-height: 1.5; } .logo { height: 1em; } @media (max-width: 600px) { .grid { width: 100%; flex-direction: column; } } ================================================ FILE: example/styles/globals.css ================================================ html, body { padding: 0; margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; } a { color: inherit; text-decoration: none; } * { box-sizing: border-box; } ================================================ FILE: jest.config.js ================================================ // For a detailed explanation regarding each configuration property, visit: // https://jestjs.io/docs/en/configuration.html module.exports = { // All imported modules in your tests should be mocked automatically // automock: false, // Stop running tests after `n` failures // bail: 0, // Respect "browser" field in package.json when resolving modules // browser: false, // The directory where Jest should store its cached dependency information // cacheDirectory: "/tmp/jest_rs", // Automatically clear mock calls and instances between every test clearMocks: true, // Indicates whether the coverage information should be collected while executing the test // collectCoverage: false, // An array of glob patterns indicating a set of files for which coverage information should be collected // collectCoverageFrom: undefined, // The directory where Jest should output its coverage files // coverageDirectory: undefined, // An array of regexp pattern strings used to skip coverage collection // coveragePathIgnorePatterns: [ // "/node_modules/" // ], // A list of reporter names that Jest uses when writing coverage reports // coverageReporters: [ // "json", // "text", // "lcov", // "clover" // ], // An object that configures minimum threshold enforcement for coverage results // coverageThreshold: undefined, // A path to a custom dependency extractor // dependencyExtractor: undefined, // Make calling deprecated APIs throw helpful error messages // errorOnDeprecated: false, // Force coverage collection from ignored files using an array of glob patterns // forceCoverageMatch: [], // A path to a module which exports an async function that is triggered once before all test suites // globalSetup: undefined, // A path to a module which exports an async function that is triggered once after all test suites // globalTeardown: undefined, // A set of global variables that need to be available in all test environments // globals: {}, // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. // maxWorkers: "50%", // An array of directory names to be searched recursively up from the requiring module's location // moduleDirectories: [ // "node_modules" // ], // An array of file extensions your modules use moduleFileExtensions: [ "js", // "json", // "jsx", "ts", "tsx", // "node" ], // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module // moduleNameMapper: {}, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader // modulePathIgnorePatterns: [], // Activates notifications for test results // notify: false, // An enum that specifies notification mode. Requires { notify: true } // notifyMode: "failure-change", // A preset that is used as a base for Jest's configuration // preset: undefined, // Run tests from one or more projects // projects: undefined, // Use this configuration option to add custom reporters to Jest // reporters: undefined, // Automatically reset mock state between every test // resetMocks: false, // Reset the module registry before running each individual test // resetModules: false, // A path to a custom resolver // resolver: undefined, // Automatically restore mock state between every test // restoreMocks: false, // The root directory that Jest should scan for tests and modules within // rootDir: undefined, // A list of paths to directories that Jest should use to search for files in // roots: [ // "" // ], // Allows you to use a custom runner instead of Jest's default test runner // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], // A list of paths to snapshot serializer modules Jest should use for snapshot testing // snapshotSerializers: [], // The test environment that will be used for testing testEnvironment: "node", // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, // Adds a location field to test results // testLocationInResults: false, // The glob patterns Jest uses to detect test files // testMatch: [ // "**/__tests__/**/*.[jt]s?(x)", // "**/?(*.)+(spec|test).[tj]s?(x)" // ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // testPathIgnorePatterns: [ // "/node_modules/" // ], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], // This option allows the use of a custom results processor // testResultsProcessor: undefined, // This option allows use of a custom test runner // testRunner: "jasmine2", // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href // testURL: "http://localhost", // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" // timers: "real", // A map from regular expressions to paths to transformers transform: { ".(ts|tsx)": "ts-jest", }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation // transformIgnorePatterns: [ // "/node_modules/" // ], // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them // unmockedModulePathPatterns: undefined, // Indicates whether each individual test should be reported during the run // verbose: undefined, // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode // watchPathIgnorePatterns: [], // Whether to use watchman for file crawling // watchman: true, }; ================================================ FILE: package.json ================================================ { "name": "next-csrf", "version": "0.2.1", "description": "CSRF mitigation library for Next.js", "main": "dist/next-csrf.js", "module": "dist/next-csrf.esm.js", "types": "@types/", "source": "src/index.ts", "repository": "https://github.com/j0lv3r4/next-csrf", "author": "Juan Olvera", "license": "MIT", "scripts": { "build": "npm run types && rollup -c", "dev": "rollup -w", "test": "jest", "test:e2e": "playwright test", "types": "tsc --emitDeclarationOnly --declaration --outDir @types", "release": "cross-var npm run build && cross-var git commit -am $npm_package_version && cross-var git tag $npm_package_version && git push && git push --tags && npm publish" }, "devDependencies": { "@playwright/test": "^1.20.0", "@rollup/plugin-commonjs": "^13.0.2", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^8.4.0", "@rollup/plugin-typescript": "^5.0.2", "@types/cookie": "^0.4.0", "@types/cookie-signature": "^1.0.3", "@types/csrf": "^1.3.2", "@types/jest": "^26.0.15", "@types/supertest": "^2.0.10", "@typescript-eslint/eslint-plugin": "^3.10.1", "@typescript-eslint/parser": "^3.10.1", "axios": "^0.26.1", "eslint": "^7.12.1", "eslint-config-airbnb-base": "^14.2.0", "eslint-plugin-import": "^2.22.1", "jest": "^26.6.1", "next": "^9.5.5", "np": "^6.5.0", "prettier": "^2.1.2", "rollup": "^2.32.1", "rollup-plugin-analyzer": "^3.3.0", "supertest": "^4.0.2", "ts-jest": "^26.4.3", "typescript": "^3.9.7" }, "dependencies": { "@types/http-errors": "^1.8.0", "cookie": "^0.4.1", "cookie-signature": "^1.1.0", "csrf": "^3.1.0", "querystring": "^0.2.0" } } ================================================ FILE: playwright.config.ts ================================================ import { PlaywrightTestConfig } from "@playwright/test"; const config: PlaywrightTestConfig = { testMatch: "**/*.e2e.ts", webServer: { command: "npm --prefix example run dev", port: 3000, timeout: 120 * 1000, reuseExistingServer: !process.env.CI, }, }; export default config; ================================================ FILE: rollup.config.js ================================================ import resolve from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import typescript from "@rollup/plugin-typescript"; import analyze from "rollup-plugin-analyzer"; import pkg from "./package.json"; export default [ { input: "src/index.ts", output: [ { file: pkg.main, format: "cjs" }, { file: pkg.module, format: "es" }, ], plugins: [typescript(), commonjs(), resolve(), analyze()], external: ["crypto", "util"], }, ]; ================================================ FILE: src/index.e2e.ts ================================================ import { test, expect, request } from "@playwright/test"; import axios from "axios"; // Go to login page // Check for secret and token // Submit the form // Expect 200 // Go to login page // Check for secret and token // Modify token // Expect 403 // Go to login page // Check for secret and token // Modify token // Expect 403 // Send a GET request to /login // Grab the cookies // Send a POST request to /api/protected with the same cookies // Expect 200 // Send a GET request to /login // Grab the cookies // Modify the cookies // Send a POST request to /api/protected with the same cookies // Expect 403 test("config is setup correctly in the example", async ({ page, baseURL }) => { if (baseURL != null) { await page.goto(baseURL); const [response] = await Promise.all([ page.waitForResponse((res) => res.status() === 200), ]); expect(response.status()).toBe(200); } }); ================================================ FILE: src/index.ts ================================================ import { NextApiHandler } from "next"; import { csrf, setup } from "./middleware"; import { NextCsrfOptions } from "./types"; import { CookieSerializeOptions } from "cookie"; const cookieDefaultOptions: CookieSerializeOptions = { httpOnly: true, path: "/", sameSite: "lax", secure: process.env.NODE_ENV === "production", }; const defaultOptions = { tokenKey: "XSRF-TOKEN", csrfErrorMessage: "Invalid CSRF token", ignoredMethods: ["GET", "HEAD", "OPTIONS"], cookieOptions: cookieDefaultOptions, }; type Middleware = (handler: NextApiHandler) => void; type NextCSRF = { setup: Middleware; csrf: Middleware; }; function nextCsrf(userOptions: NextCsrfOptions): NextCSRF { const options = { ...defaultOptions, ...userOptions, }; // generate middleware return { setup: (handler: NextApiHandler) => setup(handler, { tokenKey: options.tokenKey, cookieOptions: options.cookieOptions, secret: options.secret, }), csrf: (handler: NextApiHandler) => csrf(handler, options), }; } export { nextCsrf }; ================================================ FILE: src/middleware/csrf.test.ts ================================================ import request from "supertest"; import http, { IncomingMessage, ServerResponse } from "http"; import { apiResolver } from "next/dist/next-server/server/api-utils"; import { NextApiRequest, NextApiResponse } from "next"; import { nextCsrf } from "../index"; describe("setup middleware", () => { const secret = "yoMqR8xtQUhbmLwM*kRK"; const tokenKey = "XSRF-TOKEN"; const userOptions = { secret, tokenKey, cookieOptions: { httpOnly: true, path: "/" }, }; const apiPreviewPropsMock = { previewModeId: "id", previewModeEncryptionKey: "key", previewModeSigningKey: "key", }; const { setup } = nextCsrf(userOptions); const requestListener = (req: IncomingMessage, res: ServerResponse) => { apiResolver( req, res, undefined, setup((req: NextApiRequest, res: NextApiResponse) => { return res.status(200).json({ message: "Hello, world." }); }), apiPreviewPropsMock, true ); }; it.only("Should setup cookies with csrfSecret and token", async () => { const server = http.createServer(requestListener); const agent = await request.agent(server).post("/"); expect(agent.header["set-cookie"][0]).toEqual( expect.stringMatching(/csrfSecret=(.+); Path=\/; HttpOnly/g) ); expect(agent.header["set-cookie"][1]).toEqual( expect.stringMatching(/XSRF-TOKEN=(.+); Path=\/; HttpOnly/g) ); expect(agent.text).toBe(JSON.stringify({ message: "Hello, world." })); }); }); describe("csrf middleware", () => { const secret = "yoMqR8xtQUhbmLwM*kRK"; const tokenKey = "XSRF-TOKEN"; const userOptions = { secret, tokenKey, cookieOptions: { httpOnly: true, path: "/" }, }; // mock for `apiResolver`'s 5th parameter to please TS const apiPreviewPropsMock = { previewModeId: "id", previewModeEncryptionKey: "key", previewModeSigningKey: "key", }; const { csrf } = nextCsrf(userOptions); const requestListener = (req: IncomingMessage, res: ServerResponse) => { apiResolver( req, res, undefined, csrf((req: NextApiRequest, res: NextApiResponse) => { return res.status(200).json({ message: "Hello, world." }); }), apiPreviewPropsMock, true ); }; it("should setup a CSRF token on the first request, i.e. when there's no token already in a cookie", async () => { // If we receive a request without secret in a cookie we assume it's the first request to an API route const server = http.createServer(requestListener); const agent = await request.agent(server).post("/"); console.log(agent.status); expect(agent.header["set-cookie"][0]).toEqual( expect.stringMatching(/XSRF-TOKEN=(.+); Path=\/; HttpOnly/g) ); expect(agent.text).toBe(JSON.stringify({ message: "Hello, world." })); }); it("should validate a token with a secret and send 200 if everything is okay after the first request", async () => { const server = http.createServer(requestListener); const firstRequest = await request.agent(server).get("/"); // Grab the token and secret from the response const [reqCsrfToken] = firstRequest.header["set-cookie"]; // Send back the token in a header and a cookie const secondRequest = await request .agent(server) .get("/") .set("Cookie", reqCsrfToken) .set(tokenKey, reqCsrfToken); expect(secondRequest.status).toBe(200); }); it("should validate a token with a secret and send 200 even if the cookie exists but is different from the XSRF token", async () => { const server = http.createServer(requestListener); const firstRequest = await request.agent(server).get("/"); // Grab the token and secret from the response const [reqCsrfToken] = firstRequest.header["set-cookie"]; // Send back the token in a header and a cookie const secondRequest = await request .agent(server) .get("/") .set("Cookie", "anotherCookieThanXSRF") .set(tokenKey, reqCsrfToken); expect(secondRequest.status).toBe(200); }); it("should return 403 if we don't send a valid token in a custom header and a cookie", async () => { const server = http.createServer(requestListener); const firstRequest = await request.agent(server).post("/"); // Grab the token and secret from the response const [reqCsrfToken] = firstRequest.header["set-cookie"]; const [token, signature] = reqCsrfToken.split("."); const tamperedToken = `${token}.invalidSignature`; // Request without token in a custom header const secondRequest = await request .agent(server) .post("/") .set("Cookie", reqCsrfToken); // Request with an invalid token in a custom header const thirdRequest = await request .agent(server) .post("/") .set("Cookie", reqCsrfToken) .set(tokenKey, tamperedToken); // Request with an invalid token in the cookie const fourthRequest = await request .agent(server) .post("/") .set("Cookie", tamperedToken) .set(tokenKey, reqCsrfToken); expect(secondRequest.status).toBe(403); expect(thirdRequest.status).toBe(403); expect(fourthRequest.status).toBe(403); }); }); ================================================ FILE: src/middleware/csrf.ts ================================================ import { HttpError } from "../utils"; import { serialize, parse } from "cookie"; import { sign, unsign } from "cookie-signature"; import { createToken } from "../utils/create-token"; import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { MiddlewareArgs } from "../types"; const csrf = ( handler: NextApiHandler, { ignoredMethods, csrfErrorMessage, tokenKey, cookieOptions, secret, }: MiddlewareArgs ) => async (req: NextApiRequest, res: NextApiResponse): Promise => { try { if (typeof req.method !== "string") { throw new HttpError(403, csrfErrorMessage); } // Do nothing on if method is in `ignoreMethods` if (ignoredMethods.includes(req.method)) { return handler(req, res); } // Fail if no cookie is present if (req.headers?.cookie === undefined) { throw new HttpError(403, csrfErrorMessage); } const cookie = parse(req.headers?.cookie); // Extract secret and token from their cookies let token = cookie[tokenKey]; const csrfSecret = cookie["csrfSecret"]; // Check token is in the cookie if (!token) { throw new HttpError(403, csrfErrorMessage); } // If user provided a secret, then the cookie is signed. // Unsign and verify aka Synchronizer token pattern. // https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern if (secret != null) { // unsign cookie const unsignedToken = unsign(token, secret); // validate signature if (!unsignedToken) { throw new HttpError(403, csrfErrorMessage); } token = unsignedToken; } // 5. verify CSRF token if (!createToken.verify(csrfSecret, token)) { throw new HttpError(403, csrfErrorMessage); } // If token is verified, generate a new one and save it in the cookie let newToken; if (secret != null) { // Sign if `secret` is present newToken = sign(createToken.create(csrfSecret), secret); } else { newToken = createToken.create(csrfSecret); } res.setHeader("Set-Cookie", serialize(tokenKey, newToken, cookieOptions)); return handler(req, res); } catch (error) { return res.status(error.status ?? 500).json({ message: error.message }); } }; export { csrf }; ================================================ FILE: src/middleware/index.ts ================================================ export * from "./csrf"; export * from "./setup"; ================================================ FILE: src/middleware/setup.ts ================================================ import { GetServerSidePropsContext, NextApiHandler, NextApiRequest, NextApiResponse, } from "next"; import { SetupMiddlewareArgs } from "../types"; import { createToken } from "../utils/create-token"; import { sign } from "cookie-signature"; import { serialize } from "cookie"; import { getSecret } from "../utils/get-secret"; type SetupArgs = | NextApiRequest[] | NextApiResponse[] | GetServerSidePropsContext[]; const setup = ( handler: NextApiHandler, { secret, tokenKey, cookieOptions }: SetupMiddlewareArgs ) => async (...args: SetupArgs): Promise => { const isApi = args.length > 1; const req = isApi ? (args[0] as NextApiRequest) // (*req*, res) : (args[0] as GetServerSidePropsContext).req; // (context).req const res = isApi ? (args[1] as NextApiResponse) // (req, *res*) : (args[0] as GetServerSidePropsContext).res; // (context).res const csrfSecret = getSecret(req, "csrfSecret") || createToken.secretSync(); const unsignedToken = createToken.create(csrfSecret); // TODO: // Make a note that if the user changes the secret in the backend all the sessions // will invalidate let token; if (secret != null) { token = sign(unsignedToken, secret); } else { token = unsignedToken; } res.setHeader("Set-Cookie", [ serialize("csrfSecret", csrfSecret, cookieOptions), serialize(tokenKey, token, cookieOptions), ]); return handler(req as NextApiRequest, res as NextApiResponse); }; export { setup }; ================================================ FILE: src/package.json ================================================ { "name": "src", "version": "1.0.0", "dependencies": { } } ================================================ FILE: src/types.ts ================================================ import { CookieSerializeOptions } from "cookie"; interface NextCsrfOptions { ignoredMethods?: string[]; csrfErrorMessage?: string; tokenKey?: string; cookieOptions?: CookieSerializeOptions; secret?: string; } // Make the optional parameters in `nextCsrf` required in the `csrf` middleware interface MiddlewareArgs extends NextCsrfOptions { csrfErrorMessage: string; ignoredMethods: string[]; tokenKey: string; cookieOptions: CookieSerializeOptions; } interface SetupMiddlewareArgs { tokenKey: string; cookieOptions: CookieSerializeOptions; secret?: string; } export { NextCsrfOptions, MiddlewareArgs, SetupMiddlewareArgs }; ================================================ FILE: src/utils/create-token.ts ================================================ import Tokens from "csrf"; const createToken = new Tokens(); export { createToken }; ================================================ FILE: src/utils/get-cookie.ts ================================================ import { parse } from "cookie"; import { NextApiRequest } from "next"; import { IncomingMessage } from "http"; function getCookie(req: IncomingMessage, name: string): string { if (req.headers.cookie != null) { const parsedCookie = parse(req.headers.cookie); return parsedCookie[name]; } return ""; } export { getCookie }; ================================================ FILE: src/utils/get-secret.ts ================================================ import { getCookie } from "./get-cookie"; import { IncomingMessage } from "http"; const getSecret = (req: IncomingMessage, tokenKey: string): string => { return getCookie(req, tokenKey.toLowerCase()); }; export { getSecret }; ================================================ FILE: src/utils/httpError.ts ================================================ class HttpError extends Error { private status: number; constructor(status = 403, message: string, ...params: undefined[]) { super(...params); if (Error.captureStackTrace) { Error.captureStackTrace(this, HttpError); } this.name = "HttpError"; this.status = status; this.message = message; } } export { HttpError }; ================================================ FILE: src/utils/index.ts ================================================ export * from "./httpError"; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { /* Visit https://aka.ms/tsconfig.json to read more about this file */ /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ // "lib": [], /* Specify library files to be included in the compilation. */ // "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "declaration": true, /* Generates corresponding '.d.ts' file. */ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ // "outDir": "./", /* Redirect output structure to the directory. */ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ // "removeComments": true, /* Do not emit comments to output. */ // "noEmit": true, /* Do not emit outputs. */ // "importHelpers": true, /* Import emit helpers from 'tslib'. */ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ /* Strict Type-Checking Options */ "strict": true, /* Enable all strict type-checking options. */ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ // "strictNullChecks": true, /* Enable strict null checks. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ /* Additional Checks */ // "noUnusedLocals": true, /* Report errors on unused locals. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ /* Module Resolution Options */ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ /* Advanced Options */ "skipLibCheck": true, /* Skip type checking of declaration files. */ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ }, "include": [ "src" ] }