Closed htunnicliff closed 8 months ago
@htunnicliff
If you were to create a "CSRF tokens feature", how would you implement it? Hono doesn't have a built-in session mechanism.
@yusukebe based on the philosophies behind Hono, I think two CSRF protection techniques would be a good fit:
I think that the signed double submit cookie could be implemented simply, but I would omit the reliance on user or session information since—as you mentioned—Hono does not have built-in sessions.
What do you think?
I believe we should not implement Signed Double Submit Cookie for several reasons:
Moreover, users can implement Custom Request Headers by themselves.
These features are not necessary.
However, if an attacker can alter the cookie value, it introduces a vulnerability.
With signed cookies, I don't see how that would be possible unless an attacker gained access to server-side secrets.
Moreover, users can implement Custom Request Headers by themselves. These features are not necessary.
While this is true (users can write all this code themselves), this middleware can be implemented without third-party dependencies.
As an example, Hono provides helpers for decoding, signing, and verifying JWTs. It seems like signing and verifying CSRF tokens would be a natural candidate for a helper, especially in light of the JWT helper.
BTW, The "Signed Cookie" feature is already implemented in the cookie helper:
@yusukebe yes, signed cookies are implemented, but that can't be used for CSRF protection. Per the OWASP cheatsheet linked previously:
CSRF tokens should not be transmitted using cookies.
That is why I'd like to create a distinct helper and middleware.
Would you be open to me making a small PR with an implementation? That might help make it more clear.
@yusukebe here is a one-file demo of a Hono app that uses CSRF tokens. The logic to implement CSRF is quite similar to signed cookies, but the key is that the tokens can be placed in the body of form elements or used by JS (not shown in this example) to issue requests with a custom header.
This example contains the middleware and a token generation function. A Hono instance is configured at the bottom with a form using the CSRF tokens.
I added a button that deletes the CSRF field from the form — clicking it and then submitting the form will cause the CSRF middleware to prevent the request from succeeding, as intended.
import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import { html } from "hono/html";
import { logger } from "hono/logger";
const encoder = new TextEncoder();
const algorithm = { name: "HMAC", hash: "SHA-256" };
function csrf(secret: string) {
let key: CryptoKey;
return createMiddleware(async (c, next) => {
if (!key) {
key = await getCryptoKey(secret);
}
// Pull the token from header or form data
let token = c.req.header("X-CSRF-Token");
if (!token) {
const formData = await c.req.formData();
if (formData.has("_csrf")) {
token = formData.get("_csrf") as string;
}
}
if (token) {
// Parse token
const [rawToken, encodedSignature] = token.split(".");
if (rawToken && encodedSignature) {
// Verify token
const signature = atob(encodedSignature);
const valid = await crypto.subtle.verify(
algorithm,
key,
Uint8Array.from(signature, (c) => c.charCodeAt(0)),
encoder.encode(rawToken),
);
if (valid) {
return next();
}
}
}
return c.text("Invalid CSRF token", 403);
});
}
/**
* Generate a CSRF token with signature
*/
async function csrfToken(secret: string) {
const key = await getCryptoKey(secret);
const rawToken = "csrf__" + crypto.randomUUID();
const signatureBuffer = await crypto.subtle.sign(
algorithm,
key,
encoder.encode(rawToken),
);
const signature = btoa(
String.fromCharCode(...new Uint8Array(signatureBuffer)),
);
return `${rawToken}.${signature}`;
}
/**
* Create a CryptoKey using a given secret
*/
async function getCryptoKey(secret: string | BufferSource): Promise<CryptoKey> {
const secretBuffer =
typeof secret === "string" ? encoder.encode(secret) : secret;
return await crypto.subtle.importKey("raw", secretBuffer, algorithm, false, [
"sign",
"verify",
]);
}
const EXAMPLE_SECRET = "super-secret";
const app = new Hono();
app.use("*", logger());
app.get("/", async (c) => {
return c.html(html`
<!doctype html>
<html lang="en">
<head></head>
<body>
<h1>CSRF Demo</h1>
<p>
<button
type="button"
onclick="document.getElementById('csrf-token').disabled = true"
>
Remove CSRF token from form
</button>
</p>
<form method="POST" action="/protected">
<input
id="csrf-token"
type="hidden"
name="_csrf"
value="${await csrfToken(EXAMPLE_SECRET)}"
/>
<input
type="text"
name="name"
placeholder="Enter some text"
required
/>
<button type="submit">Submit</button>
</form>
</body>
</html>
`);
});
app.post("/protected", csrf(EXAMPLE_SECRET), async (c) => {
const formData = await c.req.formData();
return c.json({
name: formData.get("name"),
});
});
export default app;
Hi @htunnicliff,
Sorry for the delayed response.
That looks great to me! It needs to be worked out, but it is good that you are making it middleware.
cc: @watany-dev, what are your thoughts on this feature?
@yusukebe I've iterated on this a bit more in my own project, so if you would like to see a PR I will be able to share a more refined version.
Okay! Also I'd like to hear @Code-Hex 's thoughts.
I think this middleware is a good addition. However, if the protection offered by this middleware is limited, it would be helpful for users if the package name indicates the specific method of protection being used, such as csrf/token or csrf-token.
Hi @htunnicliff,
Thanks for the suggestion on CSRF protection; CSRF protection is a critical topic, and it would be nice to provide it as middleware for Hono.
However, the "embed CSRF token in the form without associating it with a cookie," as suggested in the following comment, does not seem to work as a CSRF token to me. https://github.com/honojs/hono/issues/1688#issuecomment-1809374715
Once an attacker gets the CSRF token through a normal process, he can submit arbitrary forms by embedding the token in the attacker's site. Sorry if I'm wrong; I'm just trying to understand how this works.
Hi all! I would like to do a bit more research on finished CSRF implementations from other frameworks. My implementation listed above was a proof of concept but is likely not as thorough or as polished as we would want it to be.
Sveltekit checks the origin header for post requests, works great. https://github.com/sveltejs/kit/issues/72
If the app only needs to receive requests from JavaScript (i.e., if it does not expect direct submissions from "form" elements), countermeasures against CSRF attacks using the Origin header are low-cost to implement and work well. It would be worth considering adding to our middleware.
Hi all! I did some more research and review regarding CSRF. Here is what I found:
SameSite
cookie option and the same-origin policy can help prevent some CSRF attacks.From my research into Django, Laravel, and Rails, as well as review of the OWASP CSRF Prevention Cheat Sheet, here are some recommendations about CSRF mitigation strategies that Hono can provide:
app.use("*", csrf())
).ctx.vars
.ctx.vars.renderCsrfTokenInput
should be called to inject an input field containing a CSRF token into the form.Content-Type
header of incoming requests to evaluate whether or not CSRF validation is needed. Forms and JSON requests seem like the only ones that need to be evaluated.X-CSRF-Token
header and extract the token from there if available.This implementation is what OWASP refers to as a Signed Double Submit Cookie.
If the middleware attaches a renderCsrfTokenInput
function to the Hono context, there needs to be a way for user's to access this in a type-safe manner. I'm not sure if there is a precedent for doing this, other than providing types that the user is instructed to supply to their Hono instances via TypeScript generics. I would welcome any suggestions here!
An alternative that I've explored does not rely on adding a function to ctx.vars
. The drawback here is that the user must ensure the ctx
is passed all the way down to wherever they are rendering a form, since the ctx
is needed in order to determine the current CSRF session identifier.
Some defense-in-depth strategies that OWASP advocates for could—in all likelihood—eliminate the need for certain aspects of the signed double submit cookie strategy. For example, using a custom header for Fetch requests automatically opts-in to the same-origin policy, which would obviate the need to pass any token at all.
However, because some of these browser capabilities are relatively new (introduced in the last couple of years), I think it makes sense to rely on the full signed double submit cookie strategy since it will provide security to clients that may be on older browsers (if a Hono user needs to support older browsers).
Please let me know what you think!
Thank you, @htunnicliff. I have read the document. It is very informative.
When I made this comment, I thought the Origin
header was only sent when submitting from JavaScript, but I was mistaken.
https://github.com/honojs/hono/issues/1688#issuecomment-1826942821
Thank you @hjaber I agree.
Sveltekit checks the origin header for post requests, works great.
The following document explains that modern web browsers send an Origin
request header to all requests except GET
and HEAD
.
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin
https://fetch.spec.whatwg.org/#http-requests
In addition, CSRF is prevented by CORS preflighting for xhr
and fetch
requests.
The OWASP document states that there are some environments where Origin will not be sent. Still, in 2023, there is no need to support Internet Explorer 11 (at least in hono's core); other cases are exceptional. Also, such exceptions are false positives (although inconvenient) and do not allow attacks with false negatives, which I think is a good drop-off for a standard feature. https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#identifying-the-target-origin
In conclusion, I prefer the same approach as sveltekit in hono's core, provided as middleware as follows.
import { HTTPException } from '../../http-exception'
import type { MiddlewareHandler } from '../../types'
interface CSRFOptions {
origin?: string
}
const isSafeMethodRe = /^(GET|HEAD)$/
const isRequestedByFormElementRe =
/^\b(application\/x-www-form-urlencoded|multipart\/form-data|text\/plain)\b/
export const csrf = (options?: CSRFOptions): MiddlewareHandler => {
return async (c, next) => {
if (
!isSafeMethodRe.test(c.req.method) &&
isRequestedByFormElementRe.test(c.req.raw.headers.get('content-type') || '') &&
c.req.raw.headers.get('origin') !== (options?.origin || new URL(c.req.url).origin)
) {
const res = new Response('Forbidden', {
status: 403,
})
throw new HTTPException(401, { res })
}
await next()
}
}
Incidentally, I also wrote the following pattern to embed a csrf token, but it is a bit too complicated and I did not think it necessary to include it in core.
import type { Context } from '../../context'
import { getSignedCookie, setSignedCookie, deleteCookie } from '../../helper/cookie'
import { HTTPException } from '../../http-exception'
import type { MiddlewareHandler } from '../../types'
interface CSRFOptionsWithoutSecret {
origin?: string
}
interface CSRFOptionsWithSecret extends CSRFOptionsWithoutSecret {
secret: string
getRequestToken?: (c: Context) => string | Promise<string>
getSessionId?: (c: Context) => string | Promise<string>
verifySessionId?: (c: Context, id: string) => boolean | Promise<boolean>
}
type CSRFOptions = CSRFOptionsWithoutSecret | CSRFOptionsWithSecret
interface TokenData {
name: string
value: string
}
interface CSRF {
getTokenData(): Promise<TokenData>
resetToken(): Promise<void>
}
const isSafeMethodRe = /^(GET|HEAD|OPTIONS|TRACE)$/
const isRequestedByFormElementRe =
/^\b(application\/x-www-form-urlencoded|multipart\/form-data|text\/plain)\b/
class CSRFImpl implements CSRF {
#context: Context
#secret: string
#getRequestToken: (c: Context) => string | Promise<string>
#getSessionId?: (c: Context) => string | Promise<string>
#verifySessionId?: (c: Context, id: string) => boolean | Promise<boolean>
#newTokenRequired: boolean = false
#name = 'csrf-token'
constructor(
options: {
context: Context
} & CSRFOptionsWithSecret
) {
this.#secret = options.secret
this.#context = options.context
this.#getRequestToken =
options.getRequestToken ||
(async (c) => {
const token = (await c.req.parseBody())[this.#name]
return typeof token === 'string' ? token : ''
})
this.#getSessionId = options.getSessionId
this.#verifySessionId = options.verifySessionId
}
private async getToken() {
return (
(!this.#newTokenRequired &&
(await getSignedCookie(this.#context, this.#secret, this.#name))) ||
crypto.randomUUID() + ((await this.#getSessionId?.(this.#context)) || '')
)
}
private async getRequestToken() {
return this.#getRequestToken?.(this.#context) || ''
}
async verifyToken() {
const requestToken = await this.getRequestToken()
return (
requestToken !== (await this.getToken()) ||
!(await this.#verifySessionId?.(this.#context, requestToken.substring(36)))
)
}
async getTokenData() {
const token = await this.getToken()
await setSignedCookie(this.#context, this.#name, token, this.#secret)
return { name: this.#name, value: token }
}
async resetToken() {
this.#newTokenRequired = true
deleteCookie(this.#context, this.#name)
}
}
const cannotUseCSRFToken = () => {
throw new Error('You should specify "secret" option, if you want to use CSRF token.')
}
const csrfWithoutSecret: CSRF = {
getTokenData: cannotUseCSRFToken,
resetToken: cannotUseCSRFToken,
}
export type CSRFEnvVariables = {
csrf: CSRF
}
export const csrf = (options?: CSRFOptions): MiddlewareHandler => {
if (options && 'secret' in options && options.secret === '') {
throw new Error('An empty string cannot be specified for "secret".')
}
return async (c, next) => {
const method = c.req.method
let forbidden
if (options && 'secret' in options) {
const csrf = new CSRFImpl({
context: c,
...options,
})
c.set('csrf', csrf)
forbidden = !isSafeMethodRe.test(method) && !(await csrf.verifyToken())
} else {
c.set('csrf', csrfWithoutSecret)
forbidden =
!isSafeMethodRe.test(method) &&
isRequestedByFormElementRe.test(c.req.raw.headers.get('content-type') || '') &&
(c.req.raw.headers.get('origin') || '') !== (options?.origin || new URL(c.req.url).origin)
}
if (forbidden) {
const res = new Response('Forbidden', {
status: 403,
statusText: 'Invalid CSRF token',
})
throw new HTTPException(401, { res })
}
await next()
}
}
Here is my latest implementation, for reference:
// app.ts
import { Hono } from "hono";
import { html } from "hono/html";
import { csrf } from "./csrf";
const app = new Hono();
app.use("*", csrf("my-secret"));
app.get("/", async (c) =>
c.html(html`
<!doctype html>
<html>
<head></head>
<body>
<form action="/action" method="post">
${await c.var.csrfInput()}
<input type="text" name="name" />
<input type="submit" value="submit" />
</form>
</body>
</html>
`),
);
app.post("/action", async (c) => {
const { name } = await c.req.parseBody();
return c.text(`Hello, ${name}!`);
});
export default app;
// csrf.ts
import { Context } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { html } from "hono/html";
import { sign, verify } from "hono/jwt";
import { HtmlEscapedString } from "hono/utils/html";
const sessionSymbol = Symbol("csrfSession");
declare module "hono" {
interface ContextVariableMap {
[sessionSymbol]?: string;
csrfInput: () => Promise<HtmlEscapedString>;
csrfToken: () => Promise<string>;
}
}
type Config = {
/**
* Strategy for checking CSRF tokens
* - `both`: Check both header and form field
* - `header`: Check header only (for Fetch requests)
* - `form`: Check form field only (for HTML forms)
*/
strategy: "both" | "header" | "form";
/**
* Name of the form field that contains the CSRF token
*/
tokenFormField: string;
/**
* Header name that contains CSRF token for API requests
*/
tokenHeader: string;
/**
* Name of a specific cookie that contains CSRF token for use in JS requests
*/
tokenCookie: string;
/**
* Name of a specific session cookie that is used to compare CSRF tokens
*/
sessionCookie: string;
/**
* Algorithm used for signing and verifying tokens
*/
algorithm: "HS256" | "HS384" | "HS512";
/**
* Request methods that should be checked for CSRF tokens
*/
protectRequestMethods: Set<string>;
/**
* Paths that should be ignored for CSRF token checks
*/
ignoredPaths?: ReadonlyArray<string | RegExp>;
};
export function csrf(secret: string, options: Partial<Config> = {}) {
const config: Config = {
strategy: "both",
tokenFormField: "_csrf",
tokenHeader: "X-CSRF-Token",
tokenCookie: "__Host-csrf_token",
sessionCookie: "__Host-csrf_session",
algorithm: "HS256" as const,
protectRequestMethods: new Set(["POST", "PUT", "PATCH", "DELETE"]),
...options,
};
const checkHeaders =
config.strategy === "both" || config.strategy === "header";
const checkForms = config.strategy === "both" || config.strategy === "form";
const getSessionCookie = (c: Context) => getCookie(c, config.sessionCookie);
const createSessionCookie = async (c: Context) => {
const session = await sign(
{ id: crypto.randomUUID(), session: true },
secret,
config.algorithm,
);
setCookie(c, config.sessionCookie, session, {
httpOnly: true,
sameSite: "Lax",
path: "/",
secure: true,
});
c.set(sessionSymbol, session);
};
const getSessionId = async (c: Context) => {
const sessionToken = getSessionCookie(c) ?? c.get(sessionSymbol);
if (!sessionToken) {
throw new Error("Missing CSRF session identifier");
}
try {
const { id } = await verify(sessionToken, secret, config.algorithm);
return id;
} catch (error) {
throw new Error("Invalid CSRF session identifier");
}
};
const createToken = async (c: Context) =>
await sign(
{ id: await getSessionId(c), tok: `tok_${getRandomBytes(16)}` },
secret,
config.algorithm,
);
const generateCsrfInput = async (c: Context) => {
return html`<input
type="hidden"
name="${config.tokenFormField}"
value="${await createToken(c)}"
/>`;
};
const hasCsrfCookie = (c: Context) => !!getCookie(c, config.sessionCookie);
const createCsrfCookie = async (c: Context) => {
setCookie(c, config.tokenCookie, await createToken(c), {
httpOnly: false,
sameSite: "Lax",
path: "/",
secure: true,
});
};
const csrfTokenMatchesSession = async (c: Context): Promise<boolean> => {
let token: string | undefined;
// Get token from request body
if (checkForms) {
try {
const body = await c.req.formData();
if (body) {
const value = body.get(config.tokenFormField);
if (typeof value === "string") {
token = value;
}
}
} catch (cause) {
// no-op
}
}
// Get token from request header
if (checkHeaders) {
try {
if (!token) {
token = c.req.header(config.tokenHeader);
}
} catch (cause) {
// no-op
}
}
if (!token) {
return false;
}
try {
// Verify token
const { id, tok } = await verify(token, secret, config.algorithm);
// Ensure tok_ prefix is present and session ID matches
return /^tok_/.test(tok) && id === (await getSessionId(c));
} catch (err) {
return false;
}
};
return createMiddleware(async (c, next) => {
// Skip entirely for ignored paths
if (
config.ignoredPaths?.some((path) =>
typeof path === "string" ? path === c.req.path : path.test(c.req.path),
)
) {
return next();
}
// Add CSRF session cookie
if (!getSessionCookie(c)) {
await createSessionCookie(c);
}
// Set CSRF functions on context
c.set("csrfInput", async () => await generateCsrfInput(c));
c.set("csrfToken", async () => await createToken(c));
// Set CSRF token cookie
if (checkHeaders && !hasCsrfCookie(c)) {
await createCsrfCookie(c);
}
// If request method is not protected, skip
if (!config.protectRequestMethods.has(c.req.method)) {
return next();
}
// Otherwise, get the token supplied by the client
if (await csrfTokenMatchesSession(c)) {
return next();
}
if (c.req.header("Accept")?.includes("application/json")) {
return c.json({ error: "CSRF token mismatch" }, 403);
}
return c.text("CSRF token mismatch", 403);
});
}
function encodeBuffer(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
function getRandomBytes(length: number) {
return encodeBuffer(crypto.getRandomValues(new ArrayBuffer(length)));
}
I've created a PR with this implementation. Feel free to tinker, iterate, or skip over it if you want to go with a different technique!
This is implemented in #1823 ! Thanks!
I am confused - the merged version is much simpler
https://github.com/honojs/hono/blob/main/src/middleware/csrf/index.ts
than
https://github.com/honojs/hono/issues/1688#issuecomment-1836501874
No token support?
@tcurdt
No token support?
It does not use tokens. The CSRF middleware looks up origin
instead of using tokens. You can see why we decided so: https://github.com/honojs/hono/pull/1760
What is the feature you are proposing?
It would be useful to have some sort of CSRF protection mechanism built in that users could opt in to if desired.
OSWAP’s cheat sheet for CSRF Prevention has a few strategies that could be implemented in a helper.
If maintainers are open to this, I would be glad to put forth a PR, as this is definitely a feature I would take advantage of.