Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | 1x 1x 1x 32x 32x 3x 10x 1x 19x 12x 12x 12x 12x 12x 20x 20x 18x 13x 13x 10x 10x 10x 10x 4x 6x 4x 4x 4x | /**
* Coherent.js Forms - CSRF tokens (server only)
*
* Stateless, session-bound CSRF tokens signed with HMAC-SHA256. Import from
* `@coherent.js/forms/csrf` on the server; the package root stays free of
* `node:crypto` so it keeps working in the browser.
*
* ```js
* import { createCsrfToken, verifyCsrfToken } from '@coherent.js/forms/csrf';
*
* // GET: render the form with a token for this session
* const csrfToken = createCsrfToken(process.env.CSRF_SECRET, req.session.id);
* render(form.buildForm({ csrfToken })); // adds <input type="hidden" name="_csrf">
*
* // POST: reject the request unless the token matches the session
* if (!verifyCsrfToken(req.body._csrf, process.env.CSRF_SECRET, req.session.id, { maxAge: 3_600_000 })) {
* return res.status(403).end();
* }
* ```
*
* A token is `<issuedAt>.<nonce>.<mac>`, where the MAC covers the issue time,
* the nonce and the session id, so a token minted for one session is rejected
* for any other, and `maxAge` bounds how long it stays valid.
*
* @module forms/csrf
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { Buffer } from 'node:buffer';
/** Default name of the hidden field the form builder renders. */
export const CSRF_FIELD_NAME = '_csrf';
const TOKEN_PATTERN = /^([0-9a-z]{1,13})\.([A-Za-z0-9_-]{16,64})\.([A-Za-z0-9_-]{43})$/;
const CLOCK_SKEW_MS = 60_000;
function assertSecret(secret) {
const usable = (typeof secret === 'string' && secret.length > 0) ||
(secret instanceof Uint8Array && secret.length > 0);
if (!usable) {
throw new TypeError('A CSRF secret (non-empty string or Buffer) is required');
}
}
function assertSessionId(sessionId) {
if (typeof sessionId !== 'string' || sessionId.length === 0) {
throw new TypeError('A CSRF token must be bound to a non-empty session id string');
}
}
function sign(secret, issuedAt, nonce, sessionId) {
return createHmac('sha256', secret)
.update(`${issuedAt}.${nonce}.${sessionId}`)
.digest('base64url');
}
/**
* Create a CSRF token bound to `sessionId`.
*
* @param {string|Buffer} secret - Server-side signing secret
* @param {string} sessionId - The session the token belongs to
* @param {{ now?: number }} [options] - `now` (ms) overrides the issue time
* @returns {string} The token
*/
export function createCsrfToken(secret, sessionId, options = {}) {
assertSecret(secret);
assertSessionId(sessionId);
const issuedAt = Math.floor(options.now ?? Date.now()).toString(36);
const nonce = randomBytes(16).toString('base64url');
return `${issuedAt}.${nonce}.${sign(secret, issuedAt, nonce, sessionId)}`;
}
/**
* Check a submitted CSRF token. Returns `false` (never throws) for a missing,
* malformed, forged, expired or other-session token.
*
* @param {unknown} token - The submitted token (e.g. `req.body._csrf`)
* @param {string|Buffer} secret - The secret the token was created with
* @param {string} sessionId - The current request's session id
* @param {{ maxAge?: number, now?: number }} [options] - `maxAge` in ms
* @returns {boolean} Whether the token is valid for this session
*/
export function verifyCsrfToken(token, secret, sessionId, options = {}) {
assertSecret(secret);
if (typeof sessionId !== 'string' || sessionId.length === 0) return false;
if (typeof token !== 'string') return false;
const match = TOKEN_PATTERN.exec(token);
if (!match) return false;
const [, issuedAt, nonce, mac] = match;
const expected = Buffer.from(sign(secret, issuedAt, nonce, sessionId));
const actual = Buffer.from(mac);
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
return false;
}
if (options.maxAge !== undefined) {
const age = (options.now ?? Date.now()) - parseInt(issuedAt, 36);
// A minute of tolerance for clocks that differ between servers.
if (!(age >= -CLOCK_SKEW_MS && age <= options.maxAge)) return false;
}
return true;
}
export default {
CSRF_FIELD_NAME,
createCsrfToken,
verifyCsrfToken
};
|