Three Low/Info hardening items from the 2026-06-10 security review.
OAuth/credential log redaction:
- oauth-flow.server.ts logged the raw exception on code-exchange
failure; a provider error can embed the auth code / token response,
which would land in logs + Sentry. Log only e.message now.
- manager.markNeedsRelink bounded the provider-supplied reason string
to 200 chars before logging.
Magic-link account enumeration:
- createMagicToken now returns null (instead of throwing "No account
found for this email") when no account matches; the login route
always responds { step: "magic-link-sent" }, minting a token and
sending mail only for a real account. The public login form can no
longer be used to probe which emails are registered. Registration's
email/username "already in use/taken" messages are intentionally
unchanged — standard signup UX, and the passkey ceremony can't be
made to fake-succeed.
Federation remote-document size cap:
- assertRemoteDocSize rejects an actor/outbox document over 4 MB once
serialized, applied in the ingest fetchJson seam. Fedify owns the
transfer (with its own SSRF + redirect limits) and our poll uses the
authenticated loader for secure-mode instances, so this is a
downstream guard on iteration/persistence, complementing the existing
per-poll item cap.
Tests: assertRemoteDocSize unit cases; a gated (FEDERATION_INTEGRATION=1)
integration test asserting an unsigned POST to /users/:u/inbox is
rejected with 401 — a regression guard for Fedify's signature
verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { randomUUID } from "node:crypto";
|
|
import { getDb } from "~/lib/db";
|
|
import { users } from "@trails-cool/db/schema/journal";
|
|
|
|
// Regression guard for the inbox's HTTP Signature requirement. Fedify
|
|
// verifies signatures on inbox listeners by default; this fails loudly
|
|
// if a future config change or Fedify upgrade silently stops rejecting
|
|
// unsigned activities — the property the whole federation trust model
|
|
// rests on.
|
|
//
|
|
// Talks to real Postgres + builds the Fedify instance, so it's gated
|
|
// behind FEDERATION_INTEGRATION=1 like the sibling federation
|
|
// integration tests (the recipient actor must exist for Fedify to get
|
|
// as far as verifying the signature, rather than 404ing).
|
|
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
|
|
const ORIGIN = process.env.ORIGIN ?? "http://localhost:3000";
|
|
|
|
const createdUserIds: string[] = [];
|
|
|
|
async function makePublicUser(username: string): Promise<void> {
|
|
const db = getDb();
|
|
const id = randomUUID();
|
|
await db.insert(users).values({
|
|
id,
|
|
email: `${username}@example.test`,
|
|
username,
|
|
domain: new URL(ORIGIN).host,
|
|
profileVisibility: "public",
|
|
});
|
|
createdUserIds.push(id);
|
|
}
|
|
|
|
function unsignedInboxRequest(username: string): Request {
|
|
return new Request(`${ORIGIN}/users/${username}/inbox`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/activity+json" },
|
|
// Deliberately NO Signature header.
|
|
body: JSON.stringify({
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
id: "https://attacker.example/activities/1",
|
|
type: "Follow",
|
|
actor: "https://attacker.example/users/mallory",
|
|
object: `${ORIGIN}/users/${username}`,
|
|
}),
|
|
});
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function routeArgs(request: Request, username: string): any {
|
|
return {
|
|
request,
|
|
params: { username },
|
|
context: {},
|
|
unstable_url: new URL(request.url),
|
|
unstable_pattern: "/users/:username/inbox",
|
|
};
|
|
}
|
|
|
|
describe.runIf(runIntegration)("POST /users/:username/inbox — signature enforcement", () => {
|
|
beforeAll(() => {
|
|
process.env.FEDERATION_ENABLED = "true";
|
|
process.env.ORIGIN ??= ORIGIN;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const db = getDb();
|
|
for (const id of createdUserIds.splice(0)) {
|
|
await db.delete(users).where(eq(users.id, id));
|
|
}
|
|
});
|
|
|
|
it("rejects an unsigned activity with 401 (never 2xx)", async () => {
|
|
const username = `inbox-unsigned-${Date.now()}`;
|
|
await makePublicUser(username);
|
|
|
|
const { action } = await import("./users.$username.inbox.ts");
|
|
const resp = (await action(routeArgs(unsignedInboxRequest(username), username))) as Response;
|
|
|
|
// The contract that matters: an unsigned activity is never accepted.
|
|
expect(resp.status).toBeGreaterThanOrEqual(400);
|
|
expect(resp.status).toBeLessThan(500);
|
|
expect(resp.status).toBe(401);
|
|
});
|
|
});
|