Ctrl K
ComunidadGuías…

test/shared/csrf.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { expect } from "@std/expect";
import { beforeEach, describe, it as test } from "@std/testing/bdd";
import { FakeTime } from "@std/testing/time";
import { hmacHash } from "#crypto/hashing.ts";
import { base64ToBase64Url } from "#crypto/utils.ts";
import {
  getCurrentCsrfToken,
  isSignedCsrfToken,
  signCsrfToken,
  verifySignedCsrfToken,
} from "#shared/csrf.ts";
import { setupTestEncryptionKey } from "#test-utils/env.ts";
import { withRequestContext } from "#test-utils/request-context.ts";

describe("signCsrfToken", () => {
  beforeEach(() => {
    setupTestEncryptionKey();
  });

  test("produces a token with s1. prefix", async () => {
    const token = await signCsrfToken();
    expect(token.startsWith("s1.")).toBe(true);
  });

  test("produces a token with four dot-separated parts", async () => {
    const token = await signCsrfToken();
    const parts = token.split(".");
    expect(parts.length).toBe(4);
  });

  test("includes a numeric timestamp", async () => {
    const token = await signCsrfToken();
    const parts = token.split(".");
    const timestamp = Number.parseInt(parts[1]!, 10);
    expect(Number.isNaN(timestamp)).toBe(false);
    const nowS = Math.floor(Date.now() / 1000);
    expect(Math.abs(timestamp - nowS)).toBeLessThan(5);
  });

  test("generates unique tokens on each call", async () => {
    const a = await signCsrfToken();
    const b = await signCsrfToken();
    expect(a).not.toBe(b);
  });
});

describe("isSignedCsrfToken", () => {
  test("returns true for signed tokens", async () => {
    setupTestEncryptionKey();
    const token = await signCsrfToken();
    expect(isSignedCsrfToken(token)).toBe(true);
  });

  test("returns false for plain tokens", () => {
    expect(isSignedCsrfToken("abcdef123456")).toBe(false);
  });

  test("returns false for empty string", () => {
    expect(isSignedCsrfToken("")).toBe(false);
  });
});

describe("verifySignedCsrfToken", () => {
  beforeEach(() => {
    setupTestEncryptionKey();
  });

  test("accepts a freshly signed token", async () => {
    const token = await signCsrfToken();
    expect(await verifySignedCsrfToken(token)).toBe(true);
  });

  test("rejects a token with tampered HMAC", async () => {
    const token = await signCsrfToken();
    const tampered = `${token.slice(0, -4)}XXXX`;
    expect(await verifySignedCsrfToken(tampered)).toBe(false);
  });

  test("rejects a token with tampered nonce", async () => {
    const token = await signCsrfToken();
    const parts = token.split(".");
    parts[2] = "tampered-nonce-value";
    expect(await verifySignedCsrfToken(parts.join("."))).toBe(false);
  });

  test("rejects a token with tampered timestamp", async () => {
    const token = await signCsrfToken();
    const parts = token.split(".");
    parts[1] = "9999999999";
    expect(await verifySignedCsrfToken(parts.join("."))).toBe(false);
  });

  test("rejects an expired token", async () => {
    const token = await signCsrfToken();
    // Verify with maxAge=-1 so the token is already expired
    expect(await verifySignedCsrfToken(token, -1)).toBe(false);
  });

  test("rejects a token once real elapsed time exceeds maxAge", async () => {
    using time = new FakeTime(1_000_000_000_000);
    const token = await signCsrfToken();
    // Accepted while still inside the window...
    expect(await verifySignedCsrfToken(token, 60)).toBe(true);
    // ...then advance real time past maxAge so age (120s) > maxAge (60s).
    time.tick(120_000);
    expect(await verifySignedCsrfToken(token, 60)).toBe(false);
  });

  test("rejects a token dated more than 60s in the future", async () => {
    // Sign under a far-future clock so the token's timestamp is well ahead
    // of "now"; FakeTime cannot move backwards, so verify under a separate
    // present-time clock. The gap (decades) far exceeds the 60s skew window.
    const signedInFuture = await (async () => {
      using _future = new FakeTime(4_000_000_000_000);
      return await signCsrfToken();
    })();

    using _present = new FakeTime(1_700_000_000_000);
    expect(await verifySignedCsrfToken(signedInFuture)).toBe(false);
  });

  test("rejects a plain (unsigned) token", async () => {
    expect(await verifySignedCsrfToken("plain-random-token")).toBe(false);
  });

  test("rejects an empty string", async () => {
    expect(await verifySignedCsrfToken("")).toBe(false);
  });

  test("rejects a token with wrong number of parts", async () => {
    expect(await verifySignedCsrfToken("s1.only-two-parts")).toBe(false);
    expect(await verifySignedCsrfToken("s1.a.b.c.d.extra")).toBe(false);
  });

  test("rejects a token with empty parts", async () => {
    expect(await verifySignedCsrfToken("s1..nonce.hmac")).toBe(false);
  });

  test("rejects a correctly-signed token whose nonce is empty", async () => {
    // An attacker crafts a token with a valid HMAC but an empty nonce slot.
    // The signature itself verifies, so only the explicit empty-part guard
    // rejects it — a weaker guard would accept the malformed token.
    const timestamp = Math.floor(Date.now() / 1000);
    const message = `s1.${timestamp}.`; // buildMessage(timestamp, "") — empty nonce
    const hmac = base64ToBase64Url(await hmacHash(message));
    const token = `s1.${timestamp}..${hmac}`;
    expect(await verifySignedCsrfToken(token)).toBe(false);
  });

  test("rejects a token with non-numeric timestamp", async () => {
    expect(await verifySignedCsrfToken("s1.notanumber.nonce.hmac")).toBe(false);
  });
});

describe("getCurrentCsrfToken (request-scoped)", () => {
  beforeEach(() => {
    setupTestEncryptionKey();
  });

  test("returns the token signed in the current scope", async () => {
    const { signed, current } = await withRequestContext(async () => {
      const signed = await signCsrfToken();
      return { current: getCurrentCsrfToken(), signed };
    });
    expect(current).toBe(signed);
    expect(isSignedCsrfToken(current)).toBe(true);
  });

  test("re-signing within a scope replaces the rendered token", async () => {
    const { first, second, rendered } = await withRequestContext(async () => {
      const first = await signCsrfToken();
      const second = await signCsrfToken();
      return { first, rendered: getCurrentCsrfToken(), second };
    });
    expect(second).not.toBe(first);
    expect(rendered).toBe(second); // replaced outright, not appended to `first`
  });

  test("concurrent request scopes each render their own token", async () => {
    const request = () =>
      withRequestContext(async () => {
        const signed = await signCsrfToken();
        await new Promise((r) => setTimeout(r, 20)); // render happens later
        return { rendered: getCurrentCsrfToken(), signed };
      });
    const [a, b] = await Promise.all([request(), request()]);
    // Each scope's rendered token matches the one it signed — no cross-request
    // bleed from a shared global.
    expect(a.rendered).toBe(a.signed);
    expect(b.rendered).toBe(b.signed);
    expect(a.rendered).not.toBe(b.rendered);
  });
});