Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ updates:
groups:
minor-and-patch:
update-types: [minor, patch]
# Major bumps (typescript, eslint, @types/node, ...) are upgrades with their own plan, not weekly noise: @types/node must also
# follow the Node line in .nvmrc, not the newest release. Security advisories still open PRs regardless of this rule.
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
commit-message:
prefix: "chore(deps)"
- package-ecosystem: github-actions
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const eslintConfig = defineConfig([
{
patterns: [
{
group: ["@/db", "@/db/*"],
group: ["@/db", "@/db/*", "**/db", "**/db/*"], // relative paths too: "../../db" must not escape the rule
allowTypeImports: true,
message: "Do not import the database outside src/server: go through getTenant() / createTenant() (ADR-026).",
},
Expand Down
64 changes: 64 additions & 0 deletions src/server/auth/auth-hardening.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,67 @@ describe("auth hardening", () => {
expect(created.emailVerified).toBe(true);
});
});

/** Second audit (COMP-013): the OTP plugin's password-reset and e-mail-change routes are closed over HTTP. */
describe("unused OTP routes (COMP-013)", () => {
let db: TestDb;
let auth: ReturnType<typeof createAuth>;
const mails: { to: string; subject: string; text: string }[] = [];

beforeAll(async () => {
db = await createTestDb();
auth = createAuth({
db: db as unknown as Db,
nextJsCookies: false,
env: getAuthEnv({ NODE_ENV: "test", BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-123", BETTER_AUTH_URL: "http://localhost:3000" }),
send: async (m) => {
mails.push(m);
},
});
// a registered address: the routes must stay shut for it, not only for strangers
await auth.api.sendVerificationOTP({ body: { email: "registered@example.com", type: "sign-in" } });
await new Promise((r) => setTimeout(r, 20));
const otp = /(\d{6})/.exec(mails[0].text)![1];
await auth.api.signInEmailOTP({ body: { email: "registered@example.com", otp } });
});

const post = (path: string, body: unknown) =>
auth.handler(
new Request(`http://localhost:3000/api/auth${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin: "http://localhost:3000" },
body: JSON.stringify(body),
}),
);

it.each([
"/email-otp/request-password-reset",
"/forget-password/email-otp",
"/email-otp/reset-password",
"/email-otp/request-email-change",
"/email-otp/change-email",
])("answers 404 on %s and sends nothing", async (path) => {
mails.length = 0;
const res = await post(path, { email: "registered@example.com", otp: "123456", password: "Sup3rSecret!pw", newEmail: "x@example.com" });
expect(res.status).toBe(404);
await new Promise((r) => setTimeout(r, 20));
expect(mails).toHaveLength(0);
});

it("still serves the sign-in code route over HTTP", async () => {
mails.length = 0;
const res = await post("/email-otp/send-verification-otp", { email: "registered@example.com", type: "sign-in" });
expect(res.status).toBe(200);
await new Promise((r) => setTimeout(r, 20));
expect(mails).toHaveLength(1);
});

it("never mails a code for a flow other than sign-in", async () => {
mails.length = 0;
for (const type of ["forget-password", "email-verification", "change-email"] as const) {
await auth.api.sendVerificationOTP({ body: { email: "registered@example.com", type } }).catch(() => undefined);
}
await new Promise((r) => setTimeout(r, 20));
expect(mails).toHaveLength(0);
});
});
22 changes: 18 additions & 4 deletions src/server/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as authSchema from "@/db/auth-schema";
import { users, workspaceMembers, workspaces } from "@/db/schema";
import { getAuthEnv } from "./env";
import { invitationText, INVITATION_SUBJECT } from "./invite-mail";
import { consumeOtpQuota } from "./otp-limit";
import { consumeOtpQuota, hashEmail } from "./otp-limit";
import { sendMail, type Mailer } from "./mail";
import { stripProviderTokens } from "./strip-tokens";

Expand Down Expand Up @@ -56,6 +56,16 @@ export function createAuth({
// neon-http (ADR-003): for provider "pg" the adapter only opens transactions when `transaction: true`, which we leave off.
database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),
socialProviders: { ...google, ...github },
// The OTP plugin also registers password-reset and e-mail-change routes. The product signs in by code or social only, and
// those routes sit outside the per-e-mail quota below: they could mail any registered address in bulk and let a distributed
// brute force write a password onto someone's account (COMP-013). Closed over HTTP; the server API is unaffected.
disabledPaths: [
"/email-otp/request-password-reset",
"/forget-password/email-otp",
"/email-otp/reset-password",
"/email-otp/request-email-change",
"/email-otp/change-email",
],
// Counters live in the database: serverless instances do not share memory. Sending codes is the sensitive path.
rateLimit: {
storage: "database",
Expand All @@ -69,13 +79,16 @@ export function createAuth({
},
hooks: {
// Runs before the code exists, so a refused request neither rotates nor invalidates a valid code. Same answer for every
// address (no enumeration). Trade-off: someone can exhaust the quota of a target address and delay its code login for
// up to an hour; social sign-in still works and the alternative was a ~48% chance of guessing a code in a day (COMP-002).
// address (no enumeration). Trade-off (COMP-014): someone who knows an address can exhaust its quota with ~21 requests and
// delay its code login for up to 24 h (the daily window); social sign-in still works. Accepted: without the cap a botnet
// gets ~60 guesses per address per day. Every refusal is logged (hashed address) so a campaign is visible. An anti-bot
// challenge on this endpoint is the real fix and is planned before launch.
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== "/email-otp/send-verification-otp") return;
const email = (ctx.body as { email?: unknown } | undefined)?.email;
if (typeof email !== "string") return;
if (!(await consumeOtpQuota(db, email))) {
console.warn("[auth] otp quota exceeded", { emailHash: hashEmail(email), ip: ctx.request?.headers.get("x-forwarded-for")?.split(",")[0]?.trim() });
throw new APIError("TOO_MANY_REQUESTS", { message: "Muitos códigos pedidos para este e-mail. Tente de novo mais tarde." });
}
}),
Expand Down Expand Up @@ -118,7 +131,8 @@ export function createAuth({
expiresIn: 300,
allowedAttempts: 3,
storeOTP: "hashed", // a leaked table must not hold usable codes (COMP-005)
async sendVerificationOTP({ email, otp }) {
async sendVerificationOTP({ email, otp, type }) {
if (type !== "sign-in") return; // no other OTP flow is used (COMP-013); never mail a code the app cannot honour
// Not awaited on purpose (timing attacks), but kept alive with after(): on Vercel the function can be frozen
// as soon as the response is sent, which silently drops a fire-and-forget fetch. Errors never include the body.
const task = send({ to: email, subject: "Seu código de acesso ao Compasso", text: `Seu código: ${otp}\nVálido por 5 minutos.` }).catch(
Expand Down
5 changes: 4 additions & 1 deletion src/server/auth/otp-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export const OTP_EMAIL_LIMITS = [
{ name: "day", windowMs: 24 * 60 * 60 * 1000, max: 20 },
] as const;

const keyFor = (name: string, email: string) => `otp-email:${name}:${createHash("sha256").update(email.trim().toLowerCase()).digest("hex")}`;
/** Stable, non-reversible identifier of an address, for counter keys and logs (never the address itself). */
export const hashEmail = (email: string) => createHash("sha256").update(email.trim().toLowerCase()).digest("hex");

const keyFor = (name: string, email: string) => `otp-email:${name}:${hashEmail(email)}`;

/** Registers one code request for `email`. Returns false when any window is over its cap (the request must be refused). */
export async function consumeOtpQuota(db: Db, email: string, now = Date.now()): Promise<boolean> {
Expand Down
22 changes: 22 additions & 0 deletions src/test/eslint-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { ESLint } from "eslint";

/** COMP-009: pages, components and libs must not reach the database directly, by alias or by relative path (ADR-026). */
describe("database import boundary (eslint)", () => {
const eslint = new ESLint({ cwd: process.cwd() });
const lint = async (filePath: string, code: string) => (await eslint.lintText(code, { filePath })).flatMap((r) => r.messages.filter((m) => m.ruleId === "@typescript-eslint/no-restricted-imports"));

it.each([
["src/app/probe.ts", 'import { getDb } from "@/db";\nexport const x = getDb;\n'],
["src/app/x/probe.ts", 'import { getDb } from "../../db";\nexport const x = getDb;\n'],
["src/components/probe.ts", 'import { users } from "@/db/schema";\nexport const x = users;\n'],
["src/lib/probe.ts", 'import { getDb } from "../db/index";\nexport const x = getDb;\n'],
])("rejects a runtime import in %s", async (file, code) => {
expect(await lint(file, code)).not.toHaveLength(0);
}, 30_000);

it("allows type-only imports and the server layer", async () => {
expect(await lint("src/app/probe.ts", 'import type { Db } from "@/db";\nexport type X = Db;\n')).toHaveLength(0);
expect(await lint("src/server/probe.ts", 'import { getDb } from "@/db";\nexport const x = getDb;\n')).toHaveLength(0);
}, 30_000);
});
Loading