honojs / hono

Web framework built on Web Standards
https://hono.dev
MIT License
19.25k stars 546 forks source link

Built-in CSRF helper #1688

Closed htunnicliff closed 8 months ago

htunnicliff commented 11 months ago

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.

yusukebe commented 11 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.

htunnicliff commented 11 months ago

@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?

yusukebe commented 11 months ago

I believe we should not implement Signed Double Submit Cookie for several reasons:

  1. This method does not require storing values server-side, which is advantageous for Hono.
  2. However, if an attacker can alter the cookie value, it introduces a vulnerability.

Moreover, users can implement Custom Request Headers by themselves.

These features are not necessary.

htunnicliff commented 11 months ago

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.

yusukebe commented 11 months ago

BTW, The "Signed Cookie" feature is already implemented in the cookie helper:

https://hono.dev/helpers/cookie

htunnicliff commented 11 months ago

@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.

htunnicliff commented 11 months ago

Would you be open to me making a small PR with an implementation? That might help make it more clear.

htunnicliff commented 11 months ago

@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;
yusukebe commented 11 months ago

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?

htunnicliff commented 11 months ago

@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.

yusukebe commented 11 months ago

Okay! Also I'd like to hear @Code-Hex 's thoughts.

watany-dev commented 11 months ago

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.

usualoma commented 11 months ago

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.

htunnicliff commented 10 months ago

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.

hjaber commented 10 months ago

Sveltekit checks the origin header for post requests, works great. https://github.com/sveltejs/kit/issues/72

usualoma commented 10 months ago

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.

htunnicliff commented 10 months ago

Hi all! I did some more research and review regarding CSRF. Here is what I found:

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:

CSRF Protection

This implementation is what OWASP refers to as a Signed Double Submit Cookie.

Open Questions

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.

Additional Notes

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!

usualoma commented 10 months ago

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()
  }
}
usualoma commented 10 months ago

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()
  }
}
htunnicliff commented 10 months ago

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)));
}
htunnicliff commented 10 months ago

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!

yusukebe commented 8 months ago

This is implemented in #1823 ! Thanks!

tcurdt commented 3 months ago

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?

yusukebe commented 3 months ago

@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