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
16 changes: 16 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,21 @@ WEB_ORIGIN=http://localhost:3000
# vale o padrão de produção.
# REFRESH_TOKEN_REUSE_GRACE_SECONDS=10

# Segredo que a web manda em `X-Internal-Secret` para a API aceitar o IP do navegador repassado em
# `X-Client-Ip`. O valor é o mesmo do serviço `web`, no compose.dev.yaml, e só vale no ambiente local.
INTERNAL_API_SECRET=segredo-interno-so-do-ambiente-local

# Limite de tentativas. Sem as variáveis, valem os padrões de produção: no login, 5 por e-mail e
# 20 por IP a cada 15 minutos; no cadastro, no reenvio de verificação e no "esqueci a senha",
# 3 por e-mail e 10 por IP a cada hora.
# LOGIN_RATE_LIMIT_PER_EMAIL=5
# LOGIN_RATE_LIMIT_WINDOW_SECONDS=900
# EMAIL_REQUEST_RATE_LIMIT_PER_EMAIL=3
# EMAIL_REQUEST_RATE_LIMIT_WINDOW_SECONDS=3600
# No ambiente local, o navegador da pessoa e os testes no navegador saem todos do mesmo IP, e o
# teto por IP de produção travaria a suíte no meio. Por isso ele fica alto aqui.
LOGIN_RATE_LIMIT_PER_IP=100000
EMAIL_REQUEST_RATE_LIMIT_PER_IP=100000

# JWT_PRIVATE_KEY e JWT_PUBLIC_KEY não ficam aqui: o par RS256 é gerado na primeira subida
# e gravado direto no `.env`.
5 changes: 4 additions & 1 deletion apps/api/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Estado atual

A fundação está pronta: módulo de config (`src/config/`), banco com Prisma (`prisma/` e `src/infra/database/`), envio de e-mail (`src/infra/mail/`), filtro de erros e validação de entrada (`src/common/`) e a base de testes (`test/support/`). Dos módulos de domínio (`src/modules/`), existem o `users`, com a rota do Usuário autenticado, e o `auth`, com o cadastro, a verificação de e-mail, o reenvio, o login, a renovação da Sessão, o logout e a recuperação de senha. O token de acesso e o guard que o exige ficam em `src/common/auth/`. O limite de tentativas **ainda não existe**. A estrutura-alvo está na seção 1 do `docs/lld.md`, e o código novo deve nascer nela.
A fundação está pronta: módulo de config (`src/config/`), banco com Prisma (`prisma/` e `src/infra/database/`), envio de e-mail (`src/infra/mail/`), filtro de erros e validação de entrada (`src/common/`) e a base de testes (`test/support/`). Dos módulos de domínio (`src/modules/`), existem o `users`, com a rota do Usuário autenticado, e o `auth`, com o cadastro, a verificação de e-mail, o reenvio, o login, a renovação da Sessão, o logout e a recuperação de senha. O token de acesso e o guard que o exige ficam em `src/common/auth/`. O limite de tentativas fica em `src/common/rate-limit/`, e o IP de quem chama, em `src/common/client-ip/`. A estrutura-alvo está na seção 1 do `docs/lld.md`, e o código novo deve nascer nela.

## Comandos

Expand Down Expand Up @@ -85,6 +85,7 @@ As suítes de integração e de ponta a ponta usam a base de `test/support/`. N
- **Rotas da autenticação:** `authRoutes(app)`, de `test/support/auth-routes.ts`, traz as chamadas de cadastro, verificação, login, renovação, logout, recuperação de senha e `GET /me`, e os atalhos que deixam um Usuário cadastrado ou verificado. O token de um link de e-mail sai de `verificationToken()` e `resetToken()`, que falham se o e-mail não tiver exatamente um link para o caminho esperado.
- **E-mail:** os testes leem o que foi enviado pela API HTTP do Mailpit, com `waitForMailTo()`. Cada teste usa um destinatário próprio, de `uniqueEmail()`, e acha os e-mails por ele. A caixa do Mailpit nunca é apagada, porque ela também serve ao desenvolvimento. Quando o teste provoca mais de um e-mail para o mesmo destinatário (reenvio, redefinição de senha), ele espera o seguinte com `waitForMailTo(endereço, { count: 2 })`. Sem o `count`, a espera termina no primeiro e-mail que já existir. Para provar que um envio **não** aconteceu, o teste provoca em seguida um envio que conhece, espera por ele e confere o total com `countMailTo()`.
- **Prazos:** são testados reduzindo a variável de ambiente no teste, com `vi.stubEnv` antes de `createTestApp()`, sem relógio falso dentro dos services. O valor trocado passa pelo schema: se for inválido, a aplicação do teste não sobe. O Vitest desfaz a troca sozinho no fim de cada teste (`unstubEnvs`), sem `vi.unstubAllEnvs()` à mão.
- **Limite de tentativas:** a base de testes sobe os tetos por e-mail e por IP, porque os testes saem todos do mesmo IP e repetem chamadas com o mesmo e-mail. O teste que exercita um teto o reduz com `vi.stubEnv` antes de `createTestApp()` e esvazia o banco antes de cada caso, com `cleanDatabase()`, para o contador do IP não atravessar os testes.
- **Configuração no teste:** um teste que precisa de um valor de configuração o pega da configuração injetada (`app.get(mailConfig.KEY)`), e não de `process.env`.
- **Rota só de teste:** para provar um comportamento da fundação que nenhuma rota de negócio exercita, o teste declara um controller próprio e o passa em `createTestApp({ controllers })`. Ele não entra na aplicação real.

Expand All @@ -94,6 +95,8 @@ As suítes de integração e de ponta a ponta usam a base de `test/support/`. N
- **Configuração global:** o que vale para todas as rotas e não depende de injeção entra em `configureApp()`. O filtro de erros e a validação de entrada são registrados no `AppModule` (`APP_FILTER` e `APP_PIPE`), e não no `main.ts`, para valerem também nos testes.

- **Rota protegida:** o controller leva `@UseGuards(AuthGuard)` e lê o id do Usuário com `@CurrentUserId()`, os dois de `src/common/auth/`. O módulo dele importa o `AccessTokensModule`. O guard só confere a assinatura do token, sem consultar o banco.
- **IP de quem chama:** o controller leva `@UseGuards(ClientIpGuard)` e lê o IP com `@ClientIp()`, os dois de `src/common/client-ip/`. Ninguém lê o IP da requisição por conta própria: é o guard que decide entre o IP repassado pela web e o da conexão.
- **Limite de tentativas:** o service do módulo dono da regra monta as chaves e chama o `RateLimitService`, de `src/common/rate-limit/`, antes de qualquer outra coisa. O módulo dele importa o `RateLimitModule`.

## Onde está cada assunto

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "rate_limits" (
"key" TEXT NOT NULL,
"window_start" TIMESTAMPTZ NOT NULL,
"count" INTEGER NOT NULL,

CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("key")
);
10 changes: 10 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,13 @@ model RefreshToken {
@@index([userId])
@@map("refresh_tokens")
}

/// Contador do limite de tentativas: uma linha por chave, com a janela atual.
model RateLimit {
/// Por exemplo `login:email:<hash do e-mail>` ou `login:ip:<ip>`.
key String @id
windowStart DateTime @map("window_start") @db.Timestamptz
count Int

@@map("rate_limits")
}
29 changes: 29 additions & 0 deletions apps/api/src/common/client-ip/canonical-ip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { canonicalIp } from './canonical-ip.js';

describe('canonicalIp', () => {
it.each([
['203.0.113.7', '203.0.113.7'],
['::ffff:203.0.113.7', '203.0.113.7'],
['::FFFF:203.0.113.7', '203.0.113.7'],
['::ffff:cb00:7107', '203.0.113.7'],
['0:0:0:0:0:ffff:cb00:7107', '203.0.113.7'],
['2001:db8::8', '2001:db8:0:0:0:0:0:8'],
['2001:0DB8:0000:0000:0000:0000:0000:0008', '2001:db8:0:0:0:0:0:8'],
['2001:db8:0:0:0:0:0:8', '2001:db8:0:0:0:0:0:8'],
['2001:db8::', '2001:db8:0:0:0:0:0:0'],
['::1', '0:0:0:0:0:0:0:1'],
['::', '0:0:0:0:0:0:0:0'],
['fe80::1%eth0', 'fe80:0:0:0:0:0:0:1'],
// Só o prefixo `::ffff:` embrulha um IPv4.
['64:ff9b::203.0.113.7', '64:ff9b:0:0:0:0:cb00:7107'],
])('escreve %s como %s', (ip, canonical) => {
expect(canonicalIp(ip)).toBe(canonical);
});

it.each(['unknown', '', 'não é um IP', '203.0.113.7, 203.0.113.8'])(
'devolve como veio o que não é um IP (%j)',
(value) => {
expect(canonicalIp(value)).toBe(value);
},
);
});
46 changes: 46 additions & 0 deletions apps/api/src/common/client-ip/canonical-ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { isIP } from 'node:net';

/**
* Uma grafia só por endereço, para ele ter um contador só no limite de
* tentativas. O IPv6 sai por extenso, com os oito grupos em minúsculas e sem
* zeros à esquerda, e o IPv4 que chega embrulhado em IPv6
* (`::ffff:203.0.113.7`) sai como IPv4. O que não é um IP volta como veio.
*/
export function canonicalIp(ip: string): string {
if (isIP(ip) !== 6) {
return ip;
}
const groups = ipv6Groups(ip);
const [high, low] = groups.slice(6);
const isMappedIpv4 =
groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
return isMappedIpv4
? [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.')
: groups.map((group) => group.toString(16)).join(':');
}

/** Os oito grupos de 16 bits de um IPv6 válido, em qualquer grafia. */
function ipv6Groups(ip: string): number[] {
// A zona (`%eth0`) diz por qual interface o endereço chegou, e não quem ele é.
const [address] = ip.split('%');
const [head, tail] = address.split('::');
const before = parseGroups(head);
// Sem `::`, o endereço já traz os oito grupos.
const after = tail === undefined ? [] : parseGroups(tail);
const zeros = Array<number>(8 - before.length - after.length).fill(0);
return [...before, ...zeros, ...after];
}

function parseGroups(part: string): number[] {
if (part === '') {
return [];
}
return part.split(':').flatMap((group) => {
if (!group.includes('.')) {
return [parseInt(group, 16)];
}
// Os últimos 32 bits escritos como IPv4 (`::ffff:203.0.113.7`).
const [a, b, c, d] = group.split('.').map(Number);
return [(a << 8) | b, (c << 8) | d];
});
}
16 changes: 16 additions & 0 deletions apps/api/src/common/client-ip/client-ip.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { RequestWithClientIp } from './client-ip.guard.js';

/** O IP de quem fez a chamada, numa rota que passa pelo `ClientIpGuard`. */
export const ClientIp = createParamDecorator(
(_data: unknown, context: ExecutionContext): string => {
const { clientIp } = context
.switchToHttp()
.getRequest<Partial<RequestWithClientIp>>();
// Sem o guard, todas as chamadas dividiriam o contador de um IP que não existe.
if (!clientIp) {
throw new Error('@ClientIp() exige o ClientIpGuard na rota.');
}
return clientIp;
},
);
81 changes: 81 additions & 0 deletions apps/api/src/common/client-ip/client-ip.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { ExecutionContext, Logger } from '@nestjs/common';
import { ClientIpGuard, RequestWithClientIp } from './client-ip.guard.js';

const SECRET = 'um-segredo-com-mais-de-32-caracteres';

function requestWith(headers: Record<string, string>): RequestWithClientIp {
return {
headers,
socket: { remoteAddress: '::ffff:10.0.0.9' },
} as unknown as RequestWithClientIp;
}

function run(guard: ClientIpGuard, headers: Record<string, string>): string {
const request = requestWith(headers);
guard.canActivate({
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext);
return request.clientIp;
}

describe('ClientIpGuard', () => {
let guard: ClientIpGuard;
let warn: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
guard = new ClientIpGuard({
port: 3000,
webOrigin: 'http://localhost:3000',
internalApiSecret: SECRET,
});
warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

it('usa o IP repassado, numa grafia só, quando o segredo confere', () => {
const ip = run(guard, {
'x-client-ip': '2001:DB8::8',
'x-internal-secret': SECRET,
});

expect(ip).toBe('2001:db8:0:0:0:0:0:8');
expect(warn).not.toHaveBeenCalled();
});

it('usa o IP da conexão, sem aviso, quando o segredo não vem', () => {
expect(run(guard, { 'x-client-ip': '203.0.113.7' })).toBe('10.0.0.9');
expect(warn).not.toHaveBeenCalled();
});

it('usa o IP da conexão e avisa no log, sem o valor recebido, quando o segredo não confere', () => {
const ip = run(guard, {
'x-client-ip': '203.0.113.7',
'x-internal-secret': 'um-segredo-de-outro-ambiente',
});

expect(ip).toBe('10.0.0.9');
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).not.toContain(
'um-segredo-de-outro-ambiente',
);
});

it('repete o aviso do segredo recusado no máximo uma vez por minuto', () => {
vi.useFakeTimers();
const wrong = { 'x-internal-secret': 'um-segredo-de-outro-ambiente' };

run(guard, wrong);
run(guard, wrong);
vi.advanceTimersByTime(59_000);
run(guard, wrong);
expect(warn).toHaveBeenCalledTimes(1);

vi.advanceTimersByTime(1_000);
run(guard, wrong);
expect(warn).toHaveBeenCalledTimes(2);
});
});
80 changes: 80 additions & 0 deletions apps/api/src/common/client-ip/client-ip.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import type { Request } from 'express';
import { createHash, timingSafeEqual } from 'node:crypto';
import { isIP } from 'node:net';
import { appConfig } from '../../config/app.config.js';
import { canonicalIp } from './canonical-ip.js';

/** A requisição depois de passar pelo `ClientIpGuard`. */
export interface RequestWithClientIp extends Request {
clientIp: string;
}

/** De quanto em quanto tempo o segredo recusado volta ao log, em milissegundos. */
const REJECTED_SECRET_LOG_INTERVAL_MS = 60_000;

/**
* Descobre o IP de quem fez a chamada (seção 1 do docs/lld.md). A web chama a
* API pelo servidor e repassa o IP do navegador em `X-Client-Ip`, com o segredo
* compartilhado em `X-Internal-Secret`. O IP repassado só vale quando o segredo
* confere: sem ele, vale o IP da conexão. O controller o lê com `@ClientIp()`.
*/
@Injectable()
export class ClientIpGuard implements CanActivate {
private readonly logger = new Logger(ClientIpGuard.name);
private rejectedSecretLoggedAt = 0;

constructor(
@Inject(appConfig.KEY)
private readonly config: ConfigType<typeof appConfig>,
) {}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<RequestWithClientIp>();
request.clientIp = canonicalIp(this.clientIp(request));
return true;
}

private clientIp(request: Request): string {
const forwarded = request.headers['x-client-ip'];
const secret = request.headers['x-internal-secret'];
if (typeof secret === 'string') {
if (!sameSecret(secret, this.config.internalApiSecret)) {
this.warnRejectedSecret();
} else if (typeof forwarded === 'string' && isIP(forwarded) !== 0) {
return forwarded;
}
}
return request.socket.remoteAddress ?? 'unknown';
}

/**
* Um segredo diferente entre a web e a API faz todas as pessoas contarem
* como o IP da web e serem bloqueadas juntas. O aviso é o que aponta a causa.
* Ele sai no máximo uma vez por minuto, porque qualquer um pode mandar o
* cabeçalho.
*/
private warnRejectedSecret(): void {
const now = Date.now();
if (now - this.rejectedSecretLoggedAt < REJECTED_SECRET_LOG_INTERVAL_MS) {
return;
}
this.rejectedSecretLoggedAt = now;
this.logger.warn(
'O X-Internal-Secret recebido não confere com INTERNAL_API_SECRET. O IP repassado foi ignorado, e vale o IP da conexão.',
);
}
}

/** Compara pelos hashes, em tempo constante, para o tempo não revelar o segredo. */
function sameSecret(received: string, expected: string): boolean {
const digest = (value: string) => createHash('sha256').update(value).digest();
return timingSafeEqual(digest(received), digest(expected));
}
10 changes: 10 additions & 0 deletions apps/api/src/common/rate-limit/rate-limit.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RateLimitRepository } from './rate-limit.repository.js';
import { RateLimitService } from './rate-limit.service.js';

/** Limite de tentativas. Quem conta tentativas importa este módulo. */
@Module({
providers: [RateLimitService, RateLimitRepository],
exports: [RateLimitService],
})
export class RateLimitModule {}
32 changes: 32 additions & 0 deletions apps/api/src/common/rate-limit/rate-limit.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infra/database/prisma.service.js';

@Injectable()
export class RateLimitRepository {
constructor(private readonly prisma: PrismaService) {}

/**
* Conta mais uma tentativa da chave e devolve o total da janela atual. A
* primeira tentativa abre a janela, e a que chega depois de ela fechar abre
* outra, do um. É um comando só, para duas tentativas simultâneas não lerem o
* mesmo contador. O relógio é o do banco, que é um só para todas as
* instâncias da API.
*/
async hit(key: string, windowSeconds: number): Promise<number> {
const [{ count }] = await this.prisma.$queryRaw<{ count: number }[]>`
INSERT INTO rate_limits (key, window_start, count)
VALUES (${key}, now(), 1)
ON CONFLICT (key) DO UPDATE SET
count = CASE
WHEN rate_limits.window_start <= now() - ${windowSeconds}::int * interval '1 second' THEN 1
ELSE rate_limits.count + 1
END,
window_start = CASE
WHEN rate_limits.window_start <= now() - ${windowSeconds}::int * interval '1 second' THEN now()
ELSE rate_limits.window_start
END
RETURNING count
`;
return count;
}
}
Loading
Loading