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
1 change: 1 addition & 0 deletions src/app/(app)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,6 @@ export async function restoreEntryAction(id: string): Promise<ActionState> {
/** Autocomplete for the description field: the caller's own recent descriptions. */
export async function searchDescriptions(prefix: string): Promise<string[]> {
const { tenant } = await getTenant();
if (typeof prefix !== "string") return [];
return tenant.timeEntries.recentDescriptions(prefix.slice(0, 100));
}
29 changes: 28 additions & 1 deletion src/server/action-state.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 });
});
});
22 changes: 19 additions & 3 deletions src/server/action-state.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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) {
Expand All @@ -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." };
}
14 changes: 11 additions & 3 deletions src/server/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
72 changes: 72 additions & 0 deletions src/server/auth/invite-abuse.test.ts
Original file line number Diff line number Diff line change
@@ -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<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) => 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();
});
});
24 changes: 24 additions & 0 deletions src/server/auth/invite-mail.ts
Original file line number Diff line number Diff line change
@@ -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.`;
}
14 changes: 14 additions & 0 deletions src/server/db-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading