Ctrl K
ComunidadGuías…

src/proxy.test.ts

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

Archivo original ↗Preguntar a KiBot
import { NextRequest } from "next/server";
import { unstable_doesMiddlewareMatch } from "next/experimental/testing/server";
import { API_REFERENCE_STYLE_HASHES } from "@/lib/api-reference-csp";
import { config } from "./proxy";
import { afterEach, describe, expect, it, vi } from "vitest";

/**
 * A fresh copy of the middleware for a given environment.
 *
 * Both `src/proxy.ts` and `lib/runtime-config.ts` memoize at module scope - deliberately,
 * since the environment cannot change while a real process lives - so a test that wants a
 * different configuration has to get a different module instance.
 */
async function loadProxy(env: Record<string, string> = {}) {
  vi.resetModules();
  for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value);
  return (await import("./proxy")).proxy;
}

function request(url: string, headers: Record<string, string> = {}) {
  return new NextRequest(url, { headers });
}

afterEach(() => {
  vi.unstubAllEnvs();
});

describe("Strict-Transport-Security", () => {
  it("is sent for a request a proxy terminated as HTTPS", async () => {
    const proxy = await loadProxy();

    const response = proxy(
      request("http://dives.example.com/home", {
        "x-forwarded-proto": "https",
      }),
    );

    expect(response.headers.get("Strict-Transport-Security")).toBe(
      "max-age=63072000; includeSubDomains",
    );
  });

  // Why it is set here and not in `next.config.js`: a build-time header is sent to a
  // LAN instance too, and a browser that records the pin can no longer reach it.
  it("is not sent over plain HTTP", async () => {
    const proxy = await loadProxy();

    const response = proxy(request("http://dives.local:3000/home"));

    expect(response.headers.get("Strict-Transport-Security")).toBeNull();
  });

  it("reads the client-facing hop of a forwarded chain, not the last one", async () => {
    const proxy = await loadProxy();

    const plain = proxy(
      request("http://dives.example.com/home", {
        "x-forwarded-proto": "http, https",
      }),
    );
    const secure = proxy(
      request("http://dives.example.com/home", {
        "x-forwarded-proto": "https, http",
      }),
    );

    expect(plain.headers.get("Strict-Transport-Security")).toBeNull();
    expect(secure.headers.get("Strict-Transport-Security")).not.toBeNull();
  });

  it("falls back to the request's own scheme when nothing forwarded one", async () => {
    const proxy = await loadProxy();

    const response = proxy(request("https://dives.example.com/home"));

    expect(response.headers.get("Strict-Transport-Security")).not.toBeNull();
  });

  it("does not offer `preload`", async () => {
    const proxy = await loadProxy();

    const response = proxy(
      request("https://dives.example.com/home", {
        "x-forwarded-proto": "https",
      }),
    );

    expect(response.headers.get("Strict-Transport-Security")).not.toContain(
      "preload",
    );
  });

  it("is dropped entirely by WEB_HSTS=off", async () => {
    const proxy = await loadProxy({ WEB_HSTS: "off" });

    const response = proxy(
      request("https://dives.example.com/home", {
        "x-forwarded-proto": "https",
      }),
    );

    expect(response.headers.get("Strict-Transport-Security")).toBeNull();
  });
});

describe("X-Robots-Tag", () => {
  it("is absent by default, which is what a public instance wants", async () => {
    const proxy = await loadProxy();

    const response = proxy(request("https://dives.example.com/"));

    expect(response.headers.get("X-Robots-Tag")).toBeNull();
  });

  // `robots.txt` asks a crawler not to fetch; only this keeps a URL it heard about
  // elsewhere out of an index.
  it("is sent on every response when WEB_NOINDEX is set", async () => {
    const proxy = await loadProxy({ WEB_NOINDEX: "true" });

    const response = proxy(request("https://dives.example.com/"));

    expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
  });
});

describe("Content-Security-Policy", () => {
  // Unchanged by the headers above, and the one this middleware exists for.
  it("still carries a per-request nonce", async () => {
    const proxy = await loadProxy();

    const first = proxy(request("https://dives.example.com/"));
    const second = proxy(request("https://dives.example.com/"));

    const nonceOf = (value: string | null) =>
      value?.match(/'nonce-([^']+)'/)?.[1];

    expect(nonceOf(first.headers.get("Content-Security-Policy"))).toBeTruthy();
    expect(nonceOf(first.headers.get("Content-Security-Policy"))).not.toBe(
      nonceOf(second.headers.get("Content-Security-Policy")),
    );
  });

  // No Google code runs here - the button is a top-level navigation, which no
  // fetch directive governs - so no directive names Google in *either*
  // configuration. Both halves are asserted deliberately: a re-added source would
  // be conditional on a client ID, and a single unconfigured check would pass
  // while the policy named Google for every instance that actually uses it.
  it.each([
    ["configured", { GOOGLE_CLIENT_ID: "abc.apps.googleusercontent.com" }],
    ["unconfigured", {}],
  ])("names no Google origin when Google sign-in is %s", async (_l, env) => {
    const proxy = await loadProxy(env);

    const csp = proxy(request("https://dives.example.com/signin")).headers.get(
      "Content-Security-Policy",
    );

    expect(csp).toBeTruthy();
    expect(csp).not.toContain("google");
    expect(csp).not.toContain("gstatic");
  });

  // Reading one directive out of the header, so an assertion about `connect-src`
  // cannot be satisfied by a host that reached `img-src` instead.
  const directive = async (
    name: string,
    env: Record<string, string> = {},
  ): Promise<string> => {
    const proxy = await loadProxy(env);
    const csp =
      proxy(request("https://dives.example.com/sites")).headers.get(
        "Content-Security-Policy",
      ) ?? "";
    return (
      csp
        .split("; ")
        .find((entry) => entry.startsWith(`${name} `) || entry === name) ?? ""
    );
  };

  // **Load-bearing rather than declarative.** This policy emits no `child-src`,
  // so without this line a worker falls back to `script-src`, whose
  // `'strict-dynamic'` short-circuits the source list for anything not
  // parser-inserted - and `new Worker(url)` never is. The policy would then
  // permit a `blob:` worker with no violation at all, which is the exact hazard
  // MapLibre was kept out of this app over. `worker-src`'s own check has no such
  // carve-out.
  it("governs workers itself rather than letting them fall back", async () => {
    expect(await directive("worker-src")).toBe("worker-src 'self'");
  });

  it.each([
    ["worker-src", {}],
    ["worker-src", { MAP_TILE_URL: "https://tiles.example/{z}/{x}/{y}.png" }],
  ])("never admits a blob worker (%s, %j)", async (name, env) => {
    expect(await directive(name, env)).not.toContain("blob:");
  });

  // The basemap is a `connect-src` source in *both* modes: MapLibre's image
  // decoder takes an `ArrayBuffer` and goes `Blob` -> `createImageBitmap`, so
  // even raster tile bytes arrive by `fetch`.
  it("names the bundled basemap's provider by default", async () => {
    expect(await directive("connect-src")).toBe(
      "connect-src 'self' https://tiles.openfreemap.org",
    );
  });

  it("names a configured style's own origin", async () => {
    expect(
      await directive("connect-src", {
        MAP_STYLE_URL: "https://styles.example/day.json",
        MAP_ATTRIBUTION: "© Someone",
      }),
    ).toBe("connect-src 'self' https://styles.example");
  });

  it("names a raster escape hatch's host", async () => {
    expect(
      await directive("connect-src", {
        MAP_TILE_URL: "https://tiles.example/{z}/{x}/{y}.png",
      }),
    ).toBe("connect-src 'self' https://tiles.example");
  });

  // **`img-src` names no third party at all**: MapLibre fetches every tile under
  // `connect-src` in both of its modes.
  //
  // What every `<img>` in this app points at is *this instance* - the page's own
  // origin, or `apiOrigin` where the API is split onto another one. That is not
  // the same claim as "its own origin", and species photos and a check-in link's
  // pictures are why: they are plain `<img src>` elements aimed straight at the
  // API, since the routes serving them take no bearer token, so a split-origin
  // build resolves them to `apiOrigin`.
  // The assertions below still hold, because `apiOrigin` is not a third party and
  // is not a basemap host either.
  it.each([
    ["by default", {}],
    [
      "with a raster escape hatch configured",
      {
        MAP_TILE_URL: "https://tiles.example/{z}/{x}/{y}.png",
      },
    ],
    [
      "with a style configured",
      {
        MAP_STYLE_URL: "https://styles.example/day.json",
        MAP_ATTRIBUTION: "© Someone",
      },
    ],
  ])("names no basemap host in img-src (%s)", async (_case, env) => {
    expect(await directive("img-src", env)).toBe("img-src 'self' data: blob:");
  });

  // On every path, not only `/developers`: a client navigation there keeps the
  // policy of whichever document it started from.
  it("allows Scalar's two stylesheets alongside the nonce in production", async () => {
    const styleSrc = await directive("style-src", { NODE_ENV: "production" });

    expect(styleSrc).toMatch(/^style-src 'self' 'nonce-[^']+'/);
    for (const hash of API_REFERENCE_STYLE_HASHES) {
      expect(styleSrc).toContain(hash);
    }
    expect(styleSrc).not.toContain("'unsafe-inline'");
  });

  // The empty-source filtering that `cspList` does is easy to lose when a
  // directive stops taking a conditional source, and a doubled space reads as a
  // typo forever after.
  it("leaves no doubled spaces behind the sources it dropped", async () => {
    const proxy = await loadProxy();

    const csp = proxy(request("https://dives.example.com/")).headers.get(
      "Content-Security-Policy",
    );

    expect(csp).not.toMatch(/ {2}/);
    expect(csp).not.toMatch(/;\s*;/);
  });
});

describe("matcher", () => {
  // Next's own CSP guide pairs this matcher with a `missing:` clause skipping prefetch
  // requests; this app deliberately carries none, so that nothing a client puts in a
  // request can opt the response out of the policy. `src/proxy.ts` has the reasoning -
  // in short, a real prefetch payload has no nonce to go stale, and the clause was a
  // one-header way to be served a full HTML document with no CSP, no HSTS and no
  // `X-Robots-Tag`.
  //
  // The guarantee is four lines away from being handed back, and the tests above call
  // `proxy()` directly, so not one of them would notice. Asserted both ways: on the
  // exported `config`, and through Next's own matcher with the headers that clause read.
  it("gives no request header a way to opt out of the policy", () => {
    expect(config.matcher.length).toBeGreaterThan(0);

    for (const entry of config.matcher) {
      // The only two matcher keys that condition on the request itself. `source`
      // conditions on the path, which is what the exclusions in it are for.
      expect(entry).not.toHaveProperty("missing");
      expect(entry).not.toHaveProperty("has");
    }

    for (const headers of [
      { purpose: "prefetch" },
      { "next-router-prefetch": "1" },
      { "sec-purpose": "prefetch" },
    ]) {
      expect(
        unstable_doesMiddlewareMatch({
          config,
          url: "http://dives.example.com/privacy",
          headers,
        }),
      ).toBe(true);
    }
  });

  // The API proxy is skipped so its bodies stream unbuffered; anything else under
  // `/api` is the not-found page, scripts and all, and needs the policy.
  it.each([
    ["/api/v1/dive", false],
    ["/api/v1/auth/email/verify/check", false],
    ["/api/v1", true],
    ["/api", true],
    ["/api/foo", true],
    ["/api-docs", true],
    ["/home", true],
    ["/_next/static/chunks/main.js", false],
    ["/healthz", false],
  ])("%s runs the middleware: %s", (pathname, expected) => {
    expect(
      unstable_doesMiddlewareMatch({
        config,
        url: `http://dives.example.com${pathname}`,
      }),
    ).toBe(expected);
  });
});