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
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
/**
|
|
* Auth for the plugin API catch-all (`/_emdash/api/plugins/{id}/*`).
|
|
*
|
|
* Regression coverage for #1853: because plugin routes dispatch by name only
|
|
* (the HTTP method never selects a different handler), a route reached via GET
|
|
* or HEAD runs the same code as one reached via POST. The route must not tier
|
|
* permission or CSRF by method — every private invocation needs
|
|
* `plugins:manage` and the CSRF header, so an editor or a cross-origin HEAD
|
|
* can't invoke a mutating admin route by choosing the method.
|
|
*/
|
|
|
|
import type { RoleLevel } from "@emdash-cms/auth";
|
|
import { Role } from "@emdash-cms/auth";
|
|
import type { APIRoute } from "astro";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { GET, POST } from "../../../src/astro/routes/api/plugins/[pluginId]/[...path].js";
|
|
|
|
function createLocals(role: RoleLevel | null, routePublic: boolean) {
|
|
const handlePluginApiRoute = vi.fn(async () => ({ success: true, data: { ok: true } }));
|
|
return {
|
|
locals: {
|
|
user: role == null ? null : { id: "u1", role },
|
|
emdash: {
|
|
handlePluginApiRoute,
|
|
getPluginRouteMeta: () => ({ public: routePublic }),
|
|
},
|
|
},
|
|
handlePluginApiRoute,
|
|
};
|
|
}
|
|
|
|
function invoke(
|
|
handler: APIRoute,
|
|
method: string,
|
|
locals: unknown,
|
|
{ csrf = true }: { csrf?: boolean } = {},
|
|
) {
|
|
const headers = new Headers();
|
|
if (csrf) headers.set("X-EmDash-Request", "1");
|
|
const request = new Request("https://example.com/_emdash/api/plugins/demo/updateHomeConfig", {
|
|
method,
|
|
headers,
|
|
});
|
|
// The catch-all reads params.pluginId / params.path and locals.
|
|
return handler({
|
|
params: { pluginId: "demo", path: "updateHomeConfig" },
|
|
request,
|
|
locals,
|
|
} as never);
|
|
}
|
|
|
|
describe("plugin API catch-all auth (#1853)", () => {
|
|
it("does not run a private route for an editor via GET", async () => {
|
|
const { locals, handlePluginApiRoute } = createLocals(Role.EDITOR, false);
|
|
const res = await invoke(GET, "GET", locals);
|
|
expect(res.status).toBe(403);
|
|
expect(handlePluginApiRoute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not run a private route for an editor via HEAD (dispatched to GET export)", async () => {
|
|
const { locals, handlePluginApiRoute } = createLocals(Role.EDITOR, false);
|
|
const res = await invoke(GET, "HEAD", locals);
|
|
expect(res.status).toBe(403);
|
|
expect(handlePluginApiRoute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still blocks an editor via POST", async () => {
|
|
const { locals, handlePluginApiRoute } = createLocals(Role.EDITOR, false);
|
|
const res = await invoke(POST, "POST", locals);
|
|
expect(res.status).toBe(403);
|
|
expect(handlePluginApiRoute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a private GET without the CSRF header even for an admin", async () => {
|
|
const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, false);
|
|
const res = await invoke(GET, "GET", locals, { csrf: false });
|
|
expect(res.status).toBe(403);
|
|
expect(handlePluginApiRoute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows an admin with the CSRF header (GET)", async () => {
|
|
const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, false);
|
|
const res = await invoke(GET, "GET", locals);
|
|
expect(res.status).toBe(200);
|
|
expect(handlePluginApiRoute).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("allows an admin with the CSRF header (POST)", async () => {
|
|
const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, false);
|
|
const res = await invoke(POST, "POST", locals);
|
|
expect(res.status).toBe(200);
|
|
expect(handlePluginApiRoute).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("leaves public routes reachable without auth or CSRF", async () => {
|
|
const { locals, handlePluginApiRoute } = createLocals(null, true);
|
|
const res = await invoke(GET, "GET", locals, { csrf: false });
|
|
expect(res.status).toBe(200);
|
|
expect(handlePluginApiRoute).toHaveBeenCalledOnce();
|
|
});
|
|
});
|