From a2ac1f480237d411d064d320ab1e95711a59500e Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Fri, 9 Oct 2026 23:11:15 +0000 Subject: [PATCH 1/2] fix(auth): fecha rotas de redefinicao por OTP e endurece o limite de codigos (auditoria COMP-013, 014, 009) - disabledPaths para request-password-reset, forget-password, reset-password, request-email-change e change-email; sendVerificationOTP so envia type "sign-in" (COMP-013) - log de cota de OTP estourada com hash do e-mail e IP; comentario corrigido (ate 24 h) (COMP-014) - regra de lint contra @/db tambem para imports relativos, com teste via ESLint API (COMP-009) - testes via auth.handler (HTTP) verificados por mutacao Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VDxbrneS3TEAUmdgVmkKpQ --- eslint.config.mjs | 2 +- src/server/auth/auth-hardening.test.ts | 64 ++++++++++++++++++++++++++ src/server/auth/auth.ts | 22 +++++++-- src/server/auth/otp-limit.ts | 5 +- src/test/eslint-boundary.test.ts | 22 +++++++++ 5 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 src/test/eslint-boundary.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 5cbe259..af40a95 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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).", }, diff --git a/src/server/auth/auth-hardening.test.ts b/src/server/auth/auth-hardening.test.ts index 51d4a21..6cc4e28 100644 --- a/src/server/auth/auth-hardening.test.ts +++ b/src/server/auth/auth-hardening.test.ts @@ -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; + 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); + }); +}); diff --git a/src/server/auth/auth.ts b/src/server/auth/auth.ts index bcaefac..44ccce4 100644 --- a/src/server/auth/auth.ts +++ b/src/server/auth/auth.ts @@ -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"; @@ -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", @@ -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." }); } }), @@ -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( diff --git a/src/server/auth/otp-limit.ts b/src/server/auth/otp-limit.ts index fd56658..7f4646d 100644 --- a/src/server/auth/otp-limit.ts +++ b/src/server/auth/otp-limit.ts @@ -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 { diff --git a/src/test/eslint-boundary.test.ts b/src/test/eslint-boundary.test.ts new file mode 100644 index 0000000..c3a3982 --- /dev/null +++ b/src/test/eslint-boundary.test.ts @@ -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); +}); From ece3baa50e3bd590dd046d57ee6eb759ecc5784f Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Fri, 9 Oct 2026 23:15:24 +0000 Subject: [PATCH 2/2] chore(ci): Dependabot nao abre PRs de versao maior para npm (upgrades maiores sao planejados) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VDxbrneS3TEAUmdgVmkKpQ --- .github/dependabot.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a6a86e8..0c50855 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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