b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
172 lines
5.0 KiB
TypeScript
172 lines
5.0 KiB
TypeScript
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("virtual:emdash/auth", () => ({ authenticate: vi.fn() }));
|
|
vi.mock("virtual:emdash/config", () => ({ default: {} }));
|
|
vi.mock("astro:middleware", () => ({
|
|
defineMiddleware: (handler: unknown) => handler,
|
|
}));
|
|
vi.mock("@emdash-cms/auth", () => ({
|
|
TOKEN_PREFIXES: {},
|
|
generatePrefixedToken: vi.fn(),
|
|
hashPrefixedToken: vi.fn(),
|
|
VALID_SCOPES: [],
|
|
validateScopes: vi.fn(),
|
|
hasScope: vi.fn(() => false),
|
|
computeS256Challenge: vi.fn(),
|
|
Role: { ADMIN: 50 },
|
|
}));
|
|
vi.mock("@emdash-cms/auth/adapters/kysely", () => ({
|
|
createKyselyAdapter: vi.fn(() => ({
|
|
getUserById: vi.fn(),
|
|
getUserByEmail: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
type AuthMiddlewareModule = typeof import("../../../src/astro/middleware/auth.js");
|
|
|
|
let onRequest: AuthMiddlewareModule["onRequest"];
|
|
|
|
beforeAll(async () => {
|
|
({ onRequest } = await import("../../../src/astro/middleware/auth.js"));
|
|
});
|
|
|
|
async function runAuthMiddleware(opts: {
|
|
pathname: string;
|
|
method?: string;
|
|
origin?: string;
|
|
extraHeaders?: Record<string, string>;
|
|
}): Promise<{ response: Response; next: ReturnType<typeof vi.fn> }> {
|
|
const url = new URL(opts.pathname, "https://site.example.com");
|
|
const method = opts.method ?? "POST";
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...opts.extraHeaders,
|
|
};
|
|
if (opts.origin) headers.Origin = opts.origin;
|
|
|
|
const session = {
|
|
get: vi.fn().mockResolvedValue(null),
|
|
set: vi.fn(),
|
|
destroy: vi.fn(),
|
|
};
|
|
const next = vi.fn(async () => new Response("ok"));
|
|
const response = await onRequest(
|
|
{
|
|
url,
|
|
request: new Request(url, {
|
|
method,
|
|
headers,
|
|
body: method === "GET" || method === "HEAD" ? undefined : "{}",
|
|
}),
|
|
locals: {
|
|
emdash: { db: {}, config: {} },
|
|
},
|
|
session,
|
|
redirect: (location: string) =>
|
|
new Response(null, { status: 302, headers: { Location: location } }),
|
|
} as Parameters<AuthMiddlewareModule["onRequest"]>[0],
|
|
next,
|
|
);
|
|
|
|
return { response, next };
|
|
}
|
|
|
|
describe("Public search API routes", () => {
|
|
it.each(["/_emdash/api/search", "/_emdash/api/search/suggest"])(
|
|
"allows anonymous GET to %s",
|
|
async (pathname) => {
|
|
const { response, next } = await runAuthMiddleware({
|
|
pathname,
|
|
method: "GET",
|
|
});
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(response.status).toBe(200);
|
|
},
|
|
);
|
|
|
|
it("keeps search management endpoints private", async () => {
|
|
const { response, next } = await runAuthMiddleware({
|
|
pathname: "/_emdash/api/search/rebuild",
|
|
method: "GET",
|
|
});
|
|
|
|
expect(next).not.toHaveBeenCalled();
|
|
expect(response.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* OAuth protocol endpoints (RFC 6749, 7591, 8628) are designed to be called
|
|
* cross-origin. They must bypass the Origin-based CSRF check that applies to
|
|
* other public API routes.
|
|
*
|
|
* Regression test for PR #671: dynamic client registration and the token
|
|
* endpoint were unreachable from real MCP clients because an Origin header
|
|
* from a different origin triggered CSRF_REJECTED in middleware.
|
|
*/
|
|
describe("CSRF exemption for OAuth protocol endpoints", () => {
|
|
const EXEMPT_PATHS = [
|
|
"/_emdash/api/oauth/token",
|
|
"/_emdash/api/oauth/register",
|
|
"/_emdash/api/oauth/device/code",
|
|
"/_emdash/api/oauth/device/token",
|
|
] as const;
|
|
|
|
it.each(EXEMPT_PATHS)(
|
|
"allows cross-origin POST to %s (passes request through to handler)",
|
|
async (pathname) => {
|
|
const { response, next } = await runAuthMiddleware({
|
|
pathname,
|
|
origin: "https://claude.ai",
|
|
});
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(response.status).toBe(200);
|
|
},
|
|
);
|
|
|
|
it.each(EXEMPT_PATHS)("allows same-origin POST to %s", async (pathname) => {
|
|
const { response, next } = await runAuthMiddleware({
|
|
pathname,
|
|
origin: "https://site.example.com",
|
|
});
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it.each(EXEMPT_PATHS)("allows POST without any Origin header to %s", async (pathname) => {
|
|
const { response, next } = await runAuthMiddleware({ pathname });
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("still rejects cross-origin POST to non-exempt public routes (comments)", async () => {
|
|
const { response, next } = await runAuthMiddleware({
|
|
pathname: "/_emdash/api/comments/some-id",
|
|
origin: "https://evil.example.com",
|
|
});
|
|
|
|
expect(next).not.toHaveBeenCalled();
|
|
expect(response.status).toBe(403);
|
|
await expect(response.json()).resolves.toEqual({
|
|
error: { code: "CSRF_REJECTED", message: "Cross-origin request blocked" },
|
|
});
|
|
});
|
|
|
|
it("still rejects cross-origin POST to device/authorize (session-authenticated consent step)", async () => {
|
|
// /oauth/device/authorize is NOT in the exempt list — it's where the user
|
|
// approves the CLI's device code from their browser session. It must be
|
|
// protected by the normal CSRF check.
|
|
const { response, next } = await runAuthMiddleware({
|
|
pathname: "/_emdash/api/oauth/device/authorize",
|
|
origin: "https://evil.example.com",
|
|
});
|
|
|
|
expect(next).not.toHaveBeenCalled();
|
|
expect(response.status).toBe(403);
|
|
});
|
|
});
|