fix(server): token actor, login CSRF, 415, cookie decoding, admin invites, production default
- API writes pass token:<name> for Bearer requests and "user" for sessions. - X-Requested-With: sttracker on every non-Bearer write, login and register included. - JSON-body routes reject non-JSON content types with 415. - A malformed stt_session cookie counts as no cookie (401), not 500. - DELETE me/tokens/:id is 404 for unknown or foreign ids; admin/invites is admin-only. - Bun.serve development mode only with NODE_ENV=development or --hot. - OpenAPI documents multipart fields. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
aa8e4db070
commit
5f7d54b805
@@ -23,15 +23,15 @@ interface CallOpts {
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
form?: FormData;
|
||||
/** Omit the X-Requested-With header that browsers (the web app) send on writes. */
|
||||
noCsrf?: boolean;
|
||||
}
|
||||
|
||||
async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
|
||||
const headers: Record<string, string> = { ...o.headers };
|
||||
if (o.cookie) {
|
||||
headers.cookie = o.cookie;
|
||||
headers["x-requested-with"] ??= "sttracker";
|
||||
}
|
||||
if (o.cookie) headers.cookie = o.cookie;
|
||||
if (o.token) headers.authorization = `Bearer ${o.token}`;
|
||||
else if (!o.noCsrf) headers["x-requested-with"] ??= "sttracker";
|
||||
let body: BodyInit | undefined;
|
||||
if (o.form) body = o.form;
|
||||
else if (o.body !== undefined) {
|
||||
@@ -184,8 +184,16 @@ forEachDialect("api", (getDb) => {
|
||||
|
||||
test("cookie-authenticated writes need the CSRF header", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, headers: { "x-requested-with": "" } });
|
||||
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, noCsrf: true });
|
||||
expect(res.status).toBe(403);
|
||||
// Login and register too (login CSRF).
|
||||
const login = await call(app, "POST", "auth/login", { body: { username: "ana", password: "correct horse" }, noCsrf: true });
|
||||
expect(login.status).toBe(403);
|
||||
expect(login.headers.get("set-cookie")).toBeNull();
|
||||
expect((await call(app, "POST", "auth/register", { body: { username: "zed", password: "correct horse" }, noCsrf: true })).status).toBe(403);
|
||||
// Bearer requests are exempt.
|
||||
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "w", scopes: ["write"] } })).json()) as { token: string };
|
||||
expect((await call(app, "POST", "entries/quick", { token, body: { title: "Dune" } })).status).toBe(201);
|
||||
});
|
||||
|
||||
test("login is rate limited to 10 per minute", async () => {
|
||||
@@ -264,16 +272,97 @@ forEachDialect("api", (getDb) => {
|
||||
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"');
|
||||
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
const bobs = (await (await call(app, "POST", "entries/quick", { cookie: bob, body: { title: "Bob's" } })).json()) as { id: string };
|
||||
const form2 = new FormData();
|
||||
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
|
||||
form2.set("entity", "entry");
|
||||
form2.set("entityId", "0190f000-0000-7000-8000-000000000000");
|
||||
form2.set("entityId", bobs.id);
|
||||
expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404);
|
||||
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: bob })).status).toBe(404);
|
||||
|
||||
expect((await call(app, "DELETE", `attachments/${att.id}/links/${att.links[0]!.id}`, { cookie: ana })).status).toBe(204);
|
||||
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404);
|
||||
});
|
||||
|
||||
test("JSON bodies need Content-Type application/json (415)", async () => {
|
||||
const res = await app.fetch(
|
||||
new Request(`${BASE}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain", "x-requested-with": "sttracker" },
|
||||
body: JSON.stringify({ username: "ana", password: "correct horse" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(415);
|
||||
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unsupported_media_type");
|
||||
});
|
||||
|
||||
test("a malformed session cookie is 401, not 500", async () => {
|
||||
expect((await call(app, "GET", "me", { headers: { cookie: "stt_session=%" } })).status).toBe(401);
|
||||
});
|
||||
|
||||
test("cookie plus a bogus Bearer on a write is 401", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const res = await call(app, "POST", "entries/quick", { cookie, token: "stt_bogus", body: { title: "Dune" } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("a write-scope token can write; history records the token, field_sources the user", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
|
||||
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["write", "read"] } })).json()) as { token: string };
|
||||
const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 70 } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { fieldSources: Record<string, string> }).fieldSources.rating).toBe("user");
|
||||
const hist = (await (await call(app, "GET", `entries/${add.id}/history`, { token })).json()) as { actor: string }[];
|
||||
expect(hist[0]!.actor).toBe("token:script");
|
||||
});
|
||||
|
||||
test("DELETE me/tokens/:id is 404 for unknown or foreign tokens", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
const { id } = (await (await call(app, "POST", "me/tokens", { cookie: ana, body: { name: "t", scopes: ["read"] } })).json()) as { id: string };
|
||||
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: bob })).status).toBe(404);
|
||||
expect((await call(app, "DELETE", "me/tokens/nope", { cookie: ana })).status).toBe(404);
|
||||
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: ana })).status).toBe(204);
|
||||
});
|
||||
|
||||
test("admin-only routes are 403 for members", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
expect((await call(app, "POST", "admin/plugins/reload", { cookie: bob })).status).toBe(403);
|
||||
expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403);
|
||||
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
expect((await call(app, "POST", "admin/plugins/reload", { cookie: ana })).status).toBe(200);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a paused source without credentials stays listed and can be resumed", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
|
||||
expect((await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "paused" } })).status).toBe(200);
|
||||
const listed = (await (await call(app, "GET", "sources", { cookie })).json()) as { id: string; status: string }[];
|
||||
expect(listed).toMatchObject([{ id, status: "paused" }]);
|
||||
const resumed = await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "active" } });
|
||||
expect(((await resumed.json()) as { status: string }).status).toBe("active");
|
||||
});
|
||||
|
||||
test("a multipart upload over 50 MB is 413", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
|
||||
const form = new FormData();
|
||||
form.set("file", new File([new Uint8Array(50 * 1024 * 1024 + 1)], "big.csv", { type: "text/csv" }));
|
||||
const res = await call(app, "POST", `sources/${id}/import`, { cookie, form });
|
||||
expect(res.status).toBe(413);
|
||||
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation");
|
||||
});
|
||||
|
||||
test("openapi.json lists /api/v1/entries", async () => {
|
||||
const res = await call(app, "GET", "openapi.json");
|
||||
expect(res.status).toBe(200);
|
||||
@@ -281,6 +370,12 @@ forEachDialect("api", (getDb) => {
|
||||
expect(doc.openapi).toStartWith("3.");
|
||||
expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy();
|
||||
expect(doc.paths["/api/v1/entries/{id}"]?.patch).toBeTruthy();
|
||||
const up = doc.paths["/api/v1/attachments"]?.post as unknown as {
|
||||
requestBody: { content: Record<string, { schema: { properties: Record<string, unknown>; required: string[] } }> };
|
||||
};
|
||||
const mp = up.requestBody.content["multipart/form-data"]!.schema;
|
||||
expect(Object.keys(mp.properties).sort()).toEqual(["caption", "entity", "entityId", "file"]);
|
||||
expect(mp.required).toEqual(["file", "entity", "entityId"]);
|
||||
});
|
||||
|
||||
test("about lists attributions; ingest and unknown routes are 404 JSON", async () => {
|
||||
|
||||
Reference in New Issue
Block a user