diff --git a/src/app/(app)/actions.ts b/src/app/(app)/actions.ts index 5c23165..2f95706 100644 --- a/src/app/(app)/actions.ts +++ b/src/app/(app)/actions.ts @@ -136,5 +136,6 @@ export async function restoreEntryAction(id: string): Promise { /** Autocomplete for the description field: the caller's own recent descriptions. */ export async function searchDescriptions(prefix: string): Promise { const { tenant } = await getTenant(); + if (typeof prefix !== "string") return []; return tenant.timeEntries.recentDescriptions(prefix.slice(0, 100)); } diff --git a/src/server/action-state.test.ts b/src/server/action-state.test.ts index 5a2eb3a..02e65ac 100644 --- a/src/server/action-state.test.ts +++ b/src/server/action-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { toActionState } from "./action-state"; +import { DrizzleQueryError } from "drizzle-orm/errors"; +import { describeError, toActionState } from "./action-state"; import { isUniqueViolation } from "./db-errors"; import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from "./errors"; @@ -44,4 +45,30 @@ describe("toActionState", () => { expect(log).toHaveBeenCalled(); log.mockRestore(); }); + + it("treats a malformed id (invalid uuid) as not found, not as a server error", () => { + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + const bad = new DrizzleQueryError("select * from time_entries where id = $1", ["not-a-uuid"], Object.assign(new Error("invalid input syntax for type uuid"), { code: "22P02" })); + expect(toActionState(bad).message).toMatch(/não encontrado/i); + expect(log).not.toHaveBeenCalled(); + log.mockRestore(); + }); + + it("never logs SQL parameters (COMP-006)", () => { + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + const secret = "Reunião com o cliente Fulano sobre contrato"; + const err = new DrizzleQueryError("insert into time_entries (description) values ($1)", [secret], Object.assign(new Error("boom"), { code: "XX000" })); + toActionState(err); + const logged = JSON.stringify(log.mock.calls); + expect(logged).not.toContain(secret); + expect(logged).not.toContain("insert into"); + expect(logged).toContain("XX000"); + log.mockRestore(); + }); + + it("describeError keeps message and stack for our own errors only", () => { + expect(describeError(new Error("ours"))).toMatchObject({ kind: "error", message: "ours" }); + const db = describeError(new DrizzleQueryError("select 1", ["x"], undefined)); + expect(db).toEqual({ kind: "database", name: "Error", code: undefined, constraint: undefined }); + }); }); diff --git a/src/server/action-state.ts b/src/server/action-state.ts index 4e70f21..072a259 100644 --- a/src/server/action-state.ts +++ b/src/server/action-state.ts @@ -1,5 +1,6 @@ import { ZodError } from "zod"; -import { isUniqueViolation } from "./db-errors"; +import { DrizzleQueryError } from "drizzle-orm/errors"; +import { isInvalidInput, isUniqueViolation, pgErrorCode } from "./db-errors"; import { ConflictError, ForbiddenError, NotFoundError, ValidationError } from "./errors"; /** What a Server Action returns to its form (useActionState). Only serializable data. */ @@ -11,6 +12,20 @@ export type ActionState = { export const idle: ActionState = { ok: false }; +/** + * Logs a failure without personal data (COMP-006). A `DrizzleQueryError` carries the SQL *and its parameters* (entry + * descriptions, names, ids) in `message` and `stack`, and Vercel keeps logs outside the app's LGPD controls, so database + * errors are reduced to class, SQLSTATE and constraint name. Our own errors keep message and stack. + */ +export function describeError(error: unknown) { + const db = error instanceof DrizzleQueryError || pgErrorCode(error) !== undefined; + if (db) { + const cause = (error as { cause?: { constraint?: unknown } }).cause; + return { kind: "database", name: error instanceof Error ? error.name : typeof error, code: pgErrorCode(error), constraint: typeof cause?.constraint === "string" ? cause.constraint : undefined }; + } + return error instanceof Error ? { kind: "error", name: error.name, message: error.message, stack: error.stack } : { kind: "unknown", value: typeof error }; +} + /** Turns anything thrown while saving into a user-facing (pt-BR) state. Unknown errors are logged, never shown. */ export function toActionState(error: unknown): ActionState { if (error instanceof ZodError) { @@ -24,10 +39,11 @@ export function toActionState(error: unknown): ActionState { if (error instanceof ValidationError) return { ok: false, fieldErrors: error.fieldErrors }; if (error instanceof ConflictError) return { ok: false, message: error.message }; if (error instanceof ForbiddenError) return { ok: false, message: "Apenas administradores podem alterar os cadastros." }; - if (error instanceof NotFoundError) return { ok: false, message: "Item não encontrado. Atualize a página e tente de novo." }; + // A malformed id (tampered form or action argument) is "not found", not a server error. + if (error instanceof NotFoundError || isInvalidInput(error)) return { ok: false, message: "Item não encontrado. Atualize a página e tente de novo." }; if (isUniqueViolation(error)) { return { ok: false, fieldErrors: { name: "Já existe um item com este nome." } }; } - console.error("catalog action failed", error); + console.error("catalog action failed", describeError(error)); return { ok: false, message: "Não foi possível salvar. Tente novamente." }; } diff --git a/src/server/auth/auth.ts b/src/server/auth/auth.ts index 6f05b2b..f1ca2c8 100644 --- a/src/server/auth/auth.ts +++ b/src/server/auth/auth.ts @@ -9,6 +9,7 @@ import { getDb, type Db } from "@/db"; 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 { sendMail, type Mailer } from "./mail"; import { stripProviderTokens } from "./strip-tokens"; @@ -48,7 +49,11 @@ export function createAuth({ storage: "database", window: 60, max: 100, - customRules: { "/email-otp/send-verification-otp": { window: 60, max: 3 } }, + customRules: { + "/email-otp/send-verification-otp": { window: 60, max: 3 }, + "/organization/invite-member": { window: 60, max: 10 }, + "/organization/create": { window: 3600, max: 10 }, + }, }, hooks: { // Runs before the code exists, so a refused request neither rotates nor invalidates a valid code. Same answer for every @@ -108,11 +113,14 @@ export function createAuth({ }), organization({ requireEmailVerificationOnInvitation: true, + // Abuse caps (COMP-004): signing up is open, so a person cannot spawn unlimited workspaces and invitations. + organizationLimit: 5, + invitationLimit: 20, async sendInvitationEmail({ id, email, organization: org, inviter }) { await send({ to: email, - subject: `${inviter.user.name || inviter.user.email} convidou você para ${org.name} no Compasso`, - text: `Aceite o convite: ${baseURL}/accept-invitation/${id}\n(expira em 48 horas)`, + subject: INVITATION_SUBJECT, + text: invitationText({ inviter: inviter.user.name || inviter.user.email, workspace: org.name, link: `${baseURL}/accept-invitation/${id}` }), }); }, organizationHooks: { diff --git a/src/server/auth/invite-abuse.test.ts b/src/server/auth/invite-abuse.test.ts new file mode 100644 index 0000000..3a17d1b --- /dev/null +++ b/src/server/auth/invite-abuse.test.ts @@ -0,0 +1,72 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import type { Db } from "@/db"; +import { createTestDb, type TestDb } from "@/test/db"; +import { createAuth } from "./auth"; +import { getAuthEnv } from "./env"; +import { INVITATION_SUBJECT, invitationText, plainName } from "./invite-mail"; + +describe("plainName / invitationText (COMP-004)", () => { + it("flattens, drops links and phone-like numbers, and truncates", () => { + expect(plainName("Seu acesso expira\nhoje http://evil.example/x ligue 0800 123 4567")).toBe("Seu acesso expira hoje ligue"); + expect(plainName("www.golpe.com Lab")).toBe("Lab"); + expect(plainName("Laboratório 2024")).toBe("Laboratório 2024"); + expect(plainName("a".repeat(200))).toHaveLength(60); + expect(plainName(null)).toBe(""); + }); + + it("never puts the names in the subject", () => { + expect(INVITATION_SUBJECT).not.toMatch(/\$\{|convidou/); + const body = invitationText({ inviter: "Ana", workspace: "Lab Recife", link: "https://x.test/accept-invitation/1" }); + expect(body).toContain('Ana convidou você para o workspace "Lab Recife"'); + expect(body).toContain("https://x.test/accept-invitation/1"); + }); +}); + +describe("invitation and workspace caps", () => { + 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) => void mails.push(m), + }); + }); + + async function signIn(email: string) { + mails.length = 0; + await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } }); + await new Promise((r) => setTimeout(r, 20)); + const otp = /(\d{6})/.exec(mails[0].text)![1]; + const res = await auth.api.signInEmailOTP({ body: { email, otp }, returnHeaders: true }); + return new Headers({ cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }); + } + + it("sends a fixed subject and a sanitized body, whatever the workspace is called", async () => { + const headers = await signIn("spammer@example.com"); + const org = await auth.api.createOrganization({ headers, body: { name: "PREMIO http://golpe.example ligue 0800 123 4567", slug: "premio" } }); + mails.length = 0; + await auth.api.createInvitation({ headers, body: { email: "target@example.com", role: "member", organizationId: org.id } }); + const mail = mails.at(-1)!; + expect(mail.subject).toBe(INVITATION_SUBJECT); + expect(mail.text).not.toContain("golpe.example"); + expect(mail.text).not.toContain("0800"); + }); + + it("limits how many workspaces one person can be in", async () => { + const headers = await signIn("many@example.com"); + for (let i = 0; i < 5; i++) await auth.api.createOrganization({ headers, body: { name: `Org ${i}`, slug: `many-${i}` } }); + await expect(auth.api.createOrganization({ headers, body: { name: "One too many", slug: "many-6" } })).rejects.toThrow(); + }); + + it("limits pending invitations per workspace", async () => { + const headers = await signIn("inviter@example.com"); + const org = await auth.api.createOrganization({ headers, body: { name: "Big", slug: "big" } }); + for (let i = 0; i < 20; i++) await auth.api.createInvitation({ headers, body: { email: `guest${i}@example.com`, role: "member", organizationId: org.id } }); + await expect(auth.api.createInvitation({ headers, body: { email: "guest20@example.com", role: "member", organizationId: org.id } })).rejects.toThrow(); + }); +}); diff --git a/src/server/auth/invite-mail.ts b/src/server/auth/invite-mail.ts new file mode 100644 index 0000000..bdd0174 --- /dev/null +++ b/src/server/auth/invite-mail.ts @@ -0,0 +1,24 @@ +/** + * Invitation e-mail text (audit COMP-004). Anyone can sign up, name a workspace and set their own display name, and the + * mail leaves from the app's verified domain, so those names are attacker-controlled text. The subject is fixed; the names + * only appear in the body, flattened to one line, stripped of links and phone-like numbers and cut short. + */ +export const INVITATION_SUBJECT = "Você recebeu um convite no Compasso"; + +const MAX_NAME = 60; + +export function plainName(value: string | null | undefined): string { + const cleaned = (value ?? "") + .replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ") + .replace(/\b(?:https?:\/\/|www\.)\S+/gi, "") + .replace(/\+?\d[\d\s().-]{6,}\d/g, "") + .replace(/\s+/g, " ") + .trim(); + return cleaned.length > MAX_NAME ? `${cleaned.slice(0, MAX_NAME - 1).trimEnd()}…` : cleaned; +} + +export function invitationText({ inviter, workspace, link }: { inviter: string | null | undefined; workspace: string | null | undefined; link: string }) { + const who = plainName(inviter) || "Alguém"; + const where = plainName(workspace) || "um workspace"; + return `${who} convidou você para o workspace "${where}" no Compasso.\n\nAceite o convite: ${link}\n(expira em 48 horas)\n\nSe você não esperava este convite, ignore esta mensagem.`; +} diff --git a/src/server/db-errors.ts b/src/server/db-errors.ts index 8f08dc7..94a1c4c 100644 --- a/src/server/db-errors.ts +++ b/src/server/db-errors.ts @@ -7,3 +7,17 @@ export function isUniqueViolation(error: unknown): boolean { } return false; } + +/** Postgres SQLSTATE of an error, also when Drizzle or the driver wrapped it (`cause` chain). */ +export function pgErrorCode(error: unknown): string | undefined { + let current: unknown = error; + for (let depth = 0; depth < 5 && typeof current === "object" && current !== null; depth++) { + const code = (current as { code?: unknown }).code; + if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return code; + current = (current as { cause?: unknown }).cause; + } + return undefined; +} + +/** `invalid_text_representation`: a value that does not parse for its column, e.g. a malformed uuid sent by a tampered client. */ +export const isInvalidInput = (error: unknown) => pgErrorCode(error) === "22P02";