Skip to content

Commit f48f75f

Browse files
authored
Merge pull request #83 from melgarafael/fix/issues-64-66
Teto de tentativas na superfície de auth + invariante de versões do AGENTS.md. O e2e pegou dois defeitos do meu desenho no caminho (contagem em sucesso, e latência do Redis morto).
2 parents 9ef240f + dfdc9af commit f48f75f

11 files changed

Lines changed: 419 additions & 1 deletion

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Dependencies
22
node_modules/
3+
# Sem a barra tambem: `node_modules/` casa so com diretorio, e um SYMLINK
4+
# chamado node_modules (worktree apontando para a instalacao principal) passa
5+
# batido e vai parar no commit. Aconteceu no PR #83.
6+
node_modules
37
.pnp
48
.pnp.js
59

app/actions/auth/requestPasswordReset.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { headers } from "next/headers";
55
import { createClient } from "@/lib/supabase/server";
66
import { forgotPasswordSchema, type ForgotPasswordInput } from "@/lib/auth/schemas";
77
import { audit, hashEmail } from "@/lib/audit";
8+
import { authRateLimited, AUTH_LIMITS } from "@/lib/auth/rate-limit";
89
import { env } from "@/lib/env";
910

1011
export type RequestPasswordResetResult =
@@ -38,6 +39,12 @@ export async function requestPasswordReset(
3839
const ip = hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
3940
const userAgent = hdrs.get("user-agent") ?? null;
4041

42+
// Sem teto, este endpoint é uma metralhadora de e-mail contra terceiros e um
43+
// oráculo de enumeração de conta. Issue #64.
44+
if (await authRateLimited("reset", parsed.data.email, AUTH_LIMITS.reset)) {
45+
return { ok: false, error: "rate_limited" };
46+
}
47+
4148
const supabase = await createClient();
4249
const { error } = await supabase.auth.resetPasswordForEmail(parsed.data.email, {
4350
redirectTo: `${origin}/auth/confirm`,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Issue #64 — o teto está LIGADO no login, não só disponível numa lib.
3+
*
4+
* O helper tem teste próprio (lib/auth/rate-limit.test.ts); este aqui prova a
5+
* fiação: a action recusa a 6ª tentativa contra a MESMA conta dentro da janela,
6+
* antes de falar com o GoTrue. Sem a chamada em signInWithPassword.ts, as seis
7+
* tentativas chegariam ao provedor e o teste fica vermelho.
8+
*/
9+
import { beforeEach, describe, expect, it, vi } from "vitest";
10+
11+
import { headers } from "next/headers";
12+
import { createClient } from "@/lib/supabase/server";
13+
14+
vi.mock("next/headers", () => ({ headers: vi.fn() }));
15+
vi.mock("@/lib/supabase/server", () => ({ createClient: vi.fn() }));
16+
vi.mock("@/lib/audit", async (orig) => ({
17+
...(await orig<Record<string, unknown>>()),
18+
audit: vi.fn(async () => undefined),
19+
}));
20+
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
21+
22+
const signIn = vi.fn(async () => ({
23+
data: { user: null, session: null },
24+
error: { message: "Invalid login credentials", status: 400 },
25+
}));
26+
27+
describe("signInWithPassword — teto de tentativas", () => {
28+
beforeEach(() => {
29+
vi.resetModules();
30+
signIn.mockClear();
31+
vi.mocked(headers).mockResolvedValue({
32+
get: (k: string) => (k === "x-forwarded-for" ? "203.0.113.77" : null),
33+
} as never);
34+
signIn.mockResolvedValue({
35+
data: { user: null, session: null },
36+
error: { message: "Invalid login credentials", status: 400 },
37+
} as never);
38+
vi.mocked(createClient).mockResolvedValue({
39+
auth: {
40+
signInWithPassword: signIn,
41+
mfa: { listFactors: vi.fn(async () => ({ data: { totp: [{ id: "f1" }] } })) },
42+
},
43+
} as never);
44+
});
45+
46+
it("recusa a 6ª tentativa contra a mesma conta sem chamar o provedor", async () => {
47+
const { signInWithPassword } = await import("./signInWithPassword");
48+
const input = { email: "alvo@example.com", password: "senha-errada-123" };
49+
50+
const resultados = [];
51+
for (let i = 0; i < 6; i++) {
52+
resultados.push(await signInWithPassword(input));
53+
}
54+
55+
// AUTH_LIMITS.login.id = 5 → as 5 primeiras passam do teto e falham no
56+
// provedor; a 6ª nem chega lá.
57+
expect(resultados.slice(0, 5).map((r) => r.error)).toEqual(
58+
Array(5).fill("invalid_credentials"),
59+
);
60+
expect(resultados[5]?.error).toBe("rate_limited");
61+
expect(signIn).toHaveBeenCalledTimes(5);
62+
});
63+
64+
it("acertar a senha não gasta o orçamento de bloqueio da conta", async () => {
65+
const { signInWithPassword } = await import("./signInWithPassword");
66+
const input = { email: "certo@example.com", password: "senha-certa-123" };
67+
68+
// Provedor aceita, e a conta tem MFA — o retorno é mfa_required, o que
69+
// basta: o ponto é que o caminho de SUCESSO não incrementa o contador.
70+
signIn.mockResolvedValue({
71+
data: { user: { id: "u1" }, session: {} },
72+
error: null,
73+
} as never);
74+
75+
const resultados = [];
76+
for (let i = 0; i < 10; i++) {
77+
resultados.push(await signInWithPassword(input));
78+
}
79+
80+
// Nenhuma das dez foi barrada: se o sucesso contasse, a 6ª seria.
81+
expect(resultados.filter((r) => r?.error === "rate_limited")).toHaveLength(0);
82+
expect(signIn).toHaveBeenCalledTimes(10);
83+
});
84+
});

app/actions/auth/signInWithPassword.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { redirect } from "next/navigation";
66
import { createClient } from "@/lib/supabase/server";
77
import { loginSchema, type LoginInput } from "@/lib/auth/schemas";
88
import { audit, hashEmail } from "@/lib/audit";
9+
import {
10+
authRateLimited,
11+
contaBloqueadaPorFalhas,
12+
registrarFalhaDeLogin,
13+
AUTH_LIMITS,
14+
} from "@/lib/auth/rate-limit";
915

1016
export type SignInResult = {
1117
ok: false;
@@ -43,12 +49,31 @@ export async function signInWithPassword(
4349
const ip = hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
4450
const userAgent = hdrs.get("user-agent") ?? null;
4551

52+
// Antes de falar com o GoTrue: sem isto, tentar senha era de graça e
53+
// ilimitado (issue #64). Conta por IP e por conta — o ataque distribuído
54+
// contra um e-mail só não aparece na contagem por IP.
55+
if (
56+
(await authRateLimited("login", null, AUTH_LIMITS.login)) ||
57+
(await contaBloqueadaPorFalhas(parsed.data.email, AUTH_LIMITS.login))
58+
) {
59+
await audit({
60+
action: "auth.login_rate_limited",
61+
metadata: { email_hash: hashEmail(parsed.data.email) },
62+
requestId,
63+
ip,
64+
userAgent,
65+
});
66+
return { ok: false, error: "rate_limited" };
67+
}
68+
4669
const { data, error } = await supabase.auth.signInWithPassword({
4770
email: parsed.data.email,
4871
password: parsed.data.password,
4972
});
5073

5174
if (error || !data.user) {
75+
// Só senha errada gasta o orçamento da conta.
76+
await registrarFalhaDeLogin(parsed.data.email, AUTH_LIMITS.login);
5277
await audit({
5378
action: "auth.login_failed",
5479
metadata: {

app/actions/auth/signUp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { headers } from "next/headers";
55
import { createClient } from "@/lib/supabase/server";
66
import { signupSchema, type SignupInput } from "@/lib/auth/schemas";
77
import { audit, hashEmail } from "@/lib/audit";
8+
import { authRateLimited, AUTH_LIMITS } from "@/lib/auth/rate-limit";
89
import { env } from "@/lib/env";
910

1011
export type SignUpResult =
@@ -40,6 +41,12 @@ export async function signUp(input: SignupInput): Promise<SignUpResult> {
4041
const ip = hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null;
4142
const userAgent = hdrs.get("user-agent") ?? null;
4243

44+
// Criar conta é fluxo raro por pessoa: teto baixo por IP evita fábrica de
45+
// organizações (cada signup provisiona tenant). Issue #64.
46+
if (await authRateLimited("signup", null, AUTH_LIMITS.signup)) {
47+
return { ok: false, error: "rate_limited" };
48+
}
49+
4350
const supabase = await createClient();
4451
const { data, error } = await supabase.auth.signUp({
4552
email: parsed.data.email,

app/team/accept-invite/[token]/page.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import Link from "next/link";
1212

1313
import { verifyInviteToken } from "@/lib/auth/invite-token";
14+
import { authRateLimited, AUTH_LIMITS } from "@/lib/auth/rate-limit";
1415
import { createClient } from "@/lib/supabase/server";
1516
import { acceptInviteAction } from "@/app/actions/team/acceptInvite";
1617

@@ -22,6 +23,22 @@ interface PageProps {
2223

2324
export default async function AcceptInvitePage({ params }: PageProps) {
2425
const { token } = await params;
26+
27+
// O gargalo de enumeração é AQUI, não no aceite: a rota é pública e cada
28+
// GET testa um token. Sem teto, varrer o espaço de tokens sai de graça
29+
// (issue #64). Barrar antes de verificar mantém a resposta indistinguível
30+
// entre token válido e inválido para quem está varrendo.
31+
if (await authRateLimited("invite_accept", null, AUTH_LIMITS.invite_accept)) {
32+
return (
33+
<Shell>
34+
<h1 className="text-xl font-semibold">Muitas tentativas</h1>
35+
<p className="mt-2 text-sm text-muted-foreground">
36+
Aguarde alguns minutos e abra o link do convite de novo.
37+
</p>
38+
</Shell>
39+
);
40+
}
41+
2542
const payload = verifyInviteToken(token);
2643

2744
if (!payload) {

lib/ai/dispatcher/rate-limit.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ function getRedis(): Redis | null {
2727
}
2828
return null;
2929
}
30-
_redis = new Redis({ url, token });
30+
// `retry: false`: com Redis inalcançável (URL errada, rede caída), o default
31+
// do SDK re-tenta com backoff e cada chamada passa a custar SEGUNDOS. Como
32+
// já existe fallback em memória logo abaixo, retentar aqui só transfere a
33+
// indisponibilidade do Redis para a latência do login. Falhe rápido e caia
34+
// para a memória. Medido: com URL morta, duas chamadas somavam ~8s numa
35+
// requisição de recuperação de senha, estourando o timeout da tela.
36+
_redis = new Redis({ url, token, retry: false });
3137
return _redis;
3238
}
3339

@@ -94,3 +100,29 @@ export async function checkRateLimit(
94100
window_sec: windowSec,
95101
};
96102
}
103+
104+
/**
105+
* Lê o contador SEM incrementar (issue #64).
106+
*
107+
* Existe porque bloqueio por tentativa-que-falhou precisa de duas operações
108+
* distintas: *consultar* antes de chamar o provedor (senão o ataque nunca é
109+
* barrado antes de acontecer) e *incrementar* só quando a tentativa falha
110+
* (senão login bem-sucedido consome o orçamento e tranca quem acertou a senha).
111+
*/
112+
export async function peekRateLimit(bucket: string, windowSec: number): Promise<number> {
113+
const windowStart = Math.floor(Date.now() / (windowSec * 1000));
114+
const key = `${bucket}:${windowStart}`;
115+
116+
const redis = getRedis();
117+
if (!redis) {
118+
const existing = _memBuckets.get(key);
119+
return !existing || existing.expiresAt <= Date.now() ? 0 : existing.count;
120+
}
121+
try {
122+
const value = await redis.get<number | string>(key);
123+
return value == null ? 0 : Number(value);
124+
} catch {
125+
const existing = _memBuckets.get(key);
126+
return !existing || existing.expiresAt <= Date.now() ? 0 : existing.count;
127+
}
128+
}

lib/audit/actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
export type AuditAction =
66
| "auth.login_success"
77
| "auth.login_failed"
8+
/** Teto de tentativas barrou antes de chegar ao provedor (issue #64). */
9+
| "auth.login_rate_limited"
810
| "auth.logout"
911
| "auth.mfa_enrolled"
1012
| "auth.mfa_success"

lib/auth/rate-limit.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Issue #64 — o teto existe e conta as duas coisas.
3+
*
4+
* Sem Upstash configurado o `checkRateLimit` cai para o contador em memória do
5+
* processo, que é exatamente o que este teste quer exercitar: o caminho real,
6+
* sem mock do limitador. O que se mocka é só o `headers()` do Next, para
7+
* escolher o IP de cada tentativa.
8+
*/
9+
import { beforeEach, describe, expect, it, vi } from "vitest";
10+
11+
import { headers } from "next/headers";
12+
13+
vi.mock("next/headers", () => ({ headers: vi.fn() }));
14+
15+
function comIp(ip: string) {
16+
vi.mocked(headers).mockResolvedValue({
17+
get: (k: string) => (k === "x-forwarded-for" ? ip : null),
18+
} as never);
19+
}
20+
21+
describe("authRateLimited", () => {
22+
beforeEach(() => {
23+
vi.resetModules();
24+
});
25+
26+
it("barra ao estourar o teto por IP", async () => {
27+
const { authRateLimited } = await import("./rate-limit");
28+
comIp("203.0.113.10");
29+
const limites = { ip: 3, windowSec: 300 };
30+
31+
const veredito: boolean[] = [];
32+
for (let i = 0; i < 4; i++) {
33+
veredito.push(await authRateLimited("teste_ip", null, limites));
34+
}
35+
36+
expect(veredito).toEqual([false, false, false, true]);
37+
});
38+
39+
it("barra por identificador mesmo com o IP mudando a cada tentativa", async () => {
40+
const { authRateLimited } = await import("./rate-limit");
41+
const limites = { ip: 100, id: 2, windowSec: 300 };
42+
const alvo = "vitima@example.com";
43+
44+
const veredito: boolean[] = [];
45+
for (let i = 0; i < 3; i++) {
46+
comIp(`198.51.100.${i}`); // IP diferente a cada tentativa: o ataque distribuído
47+
veredito.push(await authRateLimited("teste_id", alvo, limites));
48+
}
49+
50+
// Se só houvesse contagem por IP, os três passariam.
51+
expect(veredito).toEqual([false, false, true]);
52+
});
53+
54+
it("conta identificadores diferentes em baldes separados", async () => {
55+
const { authRateLimited } = await import("./rate-limit");
56+
comIp("203.0.113.20");
57+
const limites = { ip: 100, id: 1, windowSec: 300 };
58+
59+
expect(await authRateLimited("teste_sep", "a@example.com", limites)).toBe(false);
60+
expect(await authRateLimited("teste_sep", "b@example.com", limites)).toBe(false);
61+
expect(await authRateLimited("teste_sep", "a@example.com", limites)).toBe(true);
62+
});
63+
64+
it("não põe o e-mail em claro na chave do contador", async () => {
65+
const rl = await import("@/lib/ai/dispatcher/rate-limit");
66+
const spy = vi.spyOn(rl, "checkRateLimit");
67+
vi.resetModules();
68+
const { authRateLimited } = await import("./rate-limit");
69+
comIp("203.0.113.30");
70+
71+
await authRateLimited("teste_pii", "cliente@example.com", { ip: 10, id: 10, windowSec: 300 });
72+
73+
const chaves = spy.mock.calls.map((c) => c[0]).join(" ");
74+
expect(chaves).not.toContain("cliente@example.com");
75+
expect(chaves).not.toContain("203.0.113.30");
76+
});
77+
});

0 commit comments

Comments
 (0)