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
25 changes: 1 addition & 24 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import Fastify, { type FastifyInstance } from 'fastify'
import cookie from '@fastify/cookie'
import { isProduction, isTest } from './lib/env.ts'
import { clientIp } from './lib/clientIp.ts'
import { withTimeout } from './lib/timeout.ts'
import corsPlugin from './plugins/cors.ts'
import errorHandlerPlugin from './plugins/errorHandler.ts'
Expand Down Expand Up @@ -65,7 +64,7 @@ export async function buildApp(): Promise<FastifyInstance> {
// Kept on so `request.protocol` and `request.hostname` reflect the
// original request behind Render's proxy. NOT relied on for the client
// address: X-Forwarded-For here begins with whatever the client sent
// (see /health/whoami), so anything security-relevant reads the address
// (measured in ADR 50), so anything security-relevant reads the address
// through lib/clientIp.ts instead.
trustProxy: isProduction,

Expand Down Expand Up @@ -145,28 +144,6 @@ export async function buildApp(): Promise<FastifyInstance> {
}
})

/**
* GET /health/whoami — how the API sees the caller.
*
* Returns only the caller's OWN address and forwarding chain, the way
* ifconfig.me does. It exists because "which entry in X-Forwarded-For is
* the real client" depends on the proxy in front of the API, no two hosts
* agree, and the rate limiter and audit log key on the answer. The only
* honest way to configure trustProxy is to look at this from outside.
*/
app.get('/health/whoami', async (request) => {
return {
/** What the rate limiter and audit log will use. */
ip: clientIp(request),
/** What Fastify's trustProxy reading gives. */
fastifyIp: request.ip,
forwardedFor: request.headers['x-forwarded-for'] ?? null,
cfConnectingIp: request.headers['cf-connecting-ip'] ?? null,
trueClientIp: request.headers['true-client-ip'] ?? null,
xRealIp: request.headers['x-real-ip'] ?? null
}
})

// Auth: login, refresh, logout, me.
await app.register(authRoutes)

Expand Down
3 changes: 0 additions & 3 deletions backend/src/docs/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,6 @@ export const paths: Record<string, Record<string, unknown>> = {
'/health': {
get: { tags: ['Health'], summary: 'Liveness — is the process up', responses: { 200: json({ type: 'object', properties: { status: { type: 'string' } } }) } }
},
'/health/whoami': {
get: { tags: ['Health'], summary: 'How the API sees the caller — its own IP and forwarding chain', description: 'For configuring trustProxy honestly: which header entry the proxy controls differs per host.', responses: { 200: json({ type: 'object' }) } }
},
'/health/ready': {
get: { tags: ['Health'], summary: 'Readiness — can it reach PostgreSQL and Redis', responses: { 200: json({ type: 'object' }), 503: { description: 'A dependency is down' } } }
},
Expand Down
53 changes: 52 additions & 1 deletion backend/src/lib/clientIp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
// no `trustProxy` setting reads it safely. What the edge does control is
// its own header: Cloudflare (which fronts Render) overwrites
// cf-connecting-ip on every request, and the origin is not reachable except
// through it. /health/whoami shows all of this from the outside.
// through it. ADR 50 records how this was measured from the outside.
//
// So the address used for rate-limit buckets and audit rows comes from a
// header NAMED IN CONFIGURATION, when the deployment says which one to
// trust, and from request.ip otherwise (local development, tests, a plain
// nginx). Getting this wrong in the permissive direction is how a client
// picks its own rate-limit bucket; ADR 50 records that we did, once.

import { isIP } from 'node:net'
import type { FastifyRequest } from 'fastify'
import { env } from './env.ts'

Expand All @@ -29,6 +30,56 @@ export function clientIp(
const value = request.headers[trustedHeader.toLowerCase()]
const first = Array.isArray(value) ? value[0] : value
if (first) return first.split(',')[0]!.trim()

// The deployment says which header to trust, and it is missing: the
// request did not come through the edge that sets it. Do NOT fall back
// to request.ip — with trustProxy on, that is read from X-Forwarded-For,
// which the client writes. The TCP peer cannot be forged; at worst every
// such request shares the proxy's bucket, which fails safe.
return request.socket?.remoteAddress ?? request.ip
}
return request.ip
}

/**
* The rate-limit bucket for an address.
*
* IPv4: the address itself. IPv6: its /64 network. An ordinary IPv6
* subscriber is handed a whole /64 — 2^64 addresses — so keying on the full
* address gives an attacker a fresh bucket for every request. The /64 is the
* smallest block one customer controls, which makes it the unit to count.
*
* Only for rate-limit keys. Audit rows keep the full address, because an
* investigator wants exactly what was seen.
*/
export function rateLimitKey(ip: string): string {
// ::ffff:198.51.100.7 is an IPv4 client seen through an IPv6 socket. It
// must land in the same bucket as 198.51.100.7, not in a /64 shared with
// every other IPv4 client.
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ip)
if (mapped) return mapped[1]!

if (isIP(ip) !== 6) return ip
return `${ipv6Groups(ip).slice(0, 4).join(':')}::/64`
}

/** The eight 16-bit groups of an IPv6 address, expanded and lower-case. */
function ipv6Groups(ip: string): string[] {
// Drop a zone id (fe80::1%eth0) — it names a local interface, not a host.
const address = ip.split('%')[0]!.toLowerCase()

const [head = '', tail] = address.split('::')
const headParts = head ? head.split(':') : []
const tailParts = tail ? tail.split(':') : []

// An embedded IPv4 in the last position (64:ff9b::198.51.100.7) fills
// two groups. It can only sit at the end, so the /64 prefix is unaffected.
const width = (parts: string[]) => parts.reduce((n, p) => n + (p.includes('.') ? 2 : 1), 0)
const zeros = tail === undefined ? [] : Array(8 - width(headParts) - width(tailParts)).fill('0')

// parseInt then toString: '0db8' and 'db8' are the same group and must
// produce the same key.
return [...headParts, ...zeros, ...tailParts].map((group) =>
group.includes('.') ? group : parseInt(group, 16).toString(16)
)
}
51 changes: 51 additions & 0 deletions backend/src/lib/concurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// concurrency.ts — a bounded gate for expensive work.
//
// Argon2id is slow and memory-hard on purpose (19 MiB and ~50 ms per hash on
// a laptop, far more on a shared free-tier CPU). That cost is the defence
// against offline cracking — and, unbounded, an invitation to online DoS:
// a burst of login attempts queues without limit and every other request
// waits behind it. The per-IP rate limit does not help against many IPs.
//
// So: at most `maxActive` run at once, at most `maxWaiting` queue behind
// them, and anything beyond that is turned away immediately. A fast "busy,
// retry in a moment" keeps the rest of the API responsive; a queue that
// grows forever does not.

import { ServiceBusyError } from './errors.ts'

export class BoundedConcurrency {
private active = 0
private readonly waiting: Array<() => void> = []

constructor(
private readonly maxActive: number,
private readonly maxWaiting: number
) {}

async run<T>(work: () => Promise<T>): Promise<T> {
if (this.active >= this.maxActive) {
if (this.waiting.length >= this.maxWaiting) {
throw new ServiceBusyError(1)
}
// Wait for a finishing task to hand its slot over (see finally below).
await new Promise<void>((resolve) => this.waiting.push(resolve))
} else {
this.active++
}

try {
return await work()
} finally {
// Hand the slot straight to the next waiter rather than releasing it:
// `active` stays the same, and no newcomer can jump the queue in between.
const next = this.waiting.shift()
if (next) next()
else this.active--
}
}

/** For tests and diagnostics. */
get stats(): { active: number; waiting: number } {
return { active: this.active, waiting: this.waiting.length }
}
}
31 changes: 30 additions & 1 deletion backend/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ const envSchema = z.object({

// App. coerce: "3000" arrives as a string, we want a number.
PORT: z.coerce.number().int().positive().default(3000),
FRONTEND_URL: z.url().default('http://localhost:5173'),
// Trailing slashes stripped: the browser's Origin header never has one,
// so "https://x.app/" would fail the CORS and same-origin checks with an
// error that looks like anything but a typo.
FRONTEND_URL: z
.url()
.transform((url) => url.replace(/\/+$/, ''))
.default('http://localhost:5173'),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),

// Week 3 — allowed to be empty for now. .optional() means the key need not
Expand Down Expand Up @@ -163,8 +169,31 @@ const envSchemaWithProductionRules = envSchema.superRefine((value, ctx) => {
})
}

// Demo mode publishes admin credentials at GET /demo and wipes every table
// nightly. With a live Stripe key, anonymous visitors could open real
// checkout sessions. Checked in every environment: the combination is
// wrong everywhere. (sk_ = secret key, rk_ = restricted key.)
if (value.DEMO_MODE && /^(sk|rk)_live_/.test(value.STRIPE_SECRET_KEY ?? '')) {
ctx.addIssue({
code: 'custom',
path: ['STRIPE_SECRET_KEY'],
message: 'DEMO_MODE får aldrig köras med en live-nyckel från Stripe — använd sk_test_'
})
}

if (value.NODE_ENV !== 'production') return

// In production the frontend is a real, public https origin. The default
// (localhost) or plain http here means the variable was forgotten, and
// the symptom — every browser request failing CORS — points elsewhere.
if (!value.FRONTEND_URL.startsWith('https://')) {
ctx.addIssue({
code: 'custom',
path: ['FRONTEND_URL'],
message: 'FRONTEND_URL måste vara en https-adress i produktion (saknas den?)'
})
}

// Production traffic crosses the internet: without TLS, the database
// password and every query travel in clear text. Either the URL asks for
// TLS (a host with a public certificate) or we verify against a CA file.
Expand Down
21 changes: 21 additions & 0 deletions backend/src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type ErrorCode =
| 'NOT_FOUND'
| 'CONFLICT'
| 'RATE_LIMITED'
| 'SERVICE_BUSY'
| 'BUSINESS_RULE'
| 'INTERNAL_ERROR'

Expand Down Expand Up @@ -155,6 +156,26 @@ export class RateLimitError extends AppError {
}
}

// ─────────────────────────────────────────────────────────────
// 503 — we are busy, not you
// ─────────────────────────────────────────────────────────────

/**
* The server is at capacity for some expensive operation (password hashing,
* see lib/concurrency.ts). Different from 429: the caller did nothing wrong,
* and the right response is a short wait, not a slowdown.
*/
export class ServiceBusyError extends AppError {
readonly retryAfterSeconds: number

constructor(retryAfterSeconds: number) {
super('Tjänsten är tillfälligt hårt belastad. Försök igen om en stund.', 503, 'SERVICE_BUSY', {
details: { retryAfterSeconds }
})
this.retryAfterSeconds = retryAfterSeconds
}
}

// ─────────────────────────────────────────────────────────────
// 422 — the shape is valid, but a business rule says no
// ─────────────────────────────────────────────────────────────
Expand Down
16 changes: 14 additions & 2 deletions backend/src/lib/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// find out from a customer.

import { Resend } from 'resend'
import { env, isTest } from './env.ts'
import { env, isProduction, isTest } from './env.ts'

export type EmailMessage = {
to: string
Expand Down Expand Up @@ -70,6 +70,18 @@ async function sendViaResend(message: EmailMessage): Promise<SendResult> {
}
}

/**
* What the console transport may print.
*
* Locally the printed link IS the way to finish an invite, so it is shown in
* full. In production (the demo on Render runs this transport) the log is a
* third party's storage: a set-password token there is a standing key to the
* account for anyone with log access. Redact it.
*/
export function printableBody(text: string, production: boolean = isProduction): string {
return production ? text.replace(/token=[^\s&#]+/g, 'token=[REDACTED]') : text
}

function sendViaConsole(message: EmailMessage): SendResult {
if (!isTest) {
console.log(
Expand All @@ -79,7 +91,7 @@ function sendViaConsole(message: EmailMessage): SendResult {
`│ To: ${message.to}`,
`│ Subject: ${message.subject}`,
'├─────────────────────────────────────────────────────────────',
message.text
printableBody(message.text)
.split('\n')
.map((line) => `│ ${line}`)
.join('\n'),
Expand Down
13 changes: 9 additions & 4 deletions backend/src/plugins/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import fp from 'fastify-plugin'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { ZodError } from 'zod'
import { Prisma } from '../generated/prisma/client.ts'
import { AppError, RateLimitError, isAppError } from '../lib/errors.ts'
import { AppError, RateLimitError, ServiceBusyError, isAppError } from '../lib/errors.ts'
import { isProduction } from '../lib/env.ts'
import { captureException } from '../lib/sentry.ts'

Expand Down Expand Up @@ -83,8 +83,8 @@ async function errorHandlerPlugin(app: FastifyInstance) {
'Domain error'
)

// A 429 must say WHEN the caller may try again.
if (error instanceof RateLimitError) {
// A 429 or 503 must say WHEN the caller may try again.
if (error instanceof RateLimitError || error instanceof ServiceBusyError) {
reply.header('Retry-After', String(error.retryAfterSeconds))
}

Expand Down Expand Up @@ -133,8 +133,13 @@ async function errorHandlerPlugin(app: FastifyInstance) {
// ── 4. Fastify's own errors (body too large, malformed JSON, ...) ──
// These already carry a sensible status code. A 4xx is the caller's
// fault and its message is harmless; a 5xx we silence.
//
// Only FASTIFY's (code FST_...). Other libraries set statusCode too — a
// Stripe SDK error carries 400 and a message naming session ids and
// parameters. That is not the caller's mistake but ours, so it belongs
// in branch 5: logged, reported, and a generic 500 to the client.
const fields = readErrorFields(error)
if (fields.statusCode >= 400 && fields.statusCode < 500) {
if (fields.statusCode >= 400 && fields.statusCode < 500 && fields.code.startsWith('FST_')) {
app.log.warn({ err: error }, 'Client error')
return send(reply, request, fields.statusCode, fields.code, fields.message)
}
Expand Down
10 changes: 5 additions & 5 deletions backend/src/plugins/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
// Layer 2 (services/loginAttempts.service.ts) covers that.

import fp from 'fastify-plugin'
import { clientIp } from '../lib/clientIp.ts'
import { clientIp, rateLimitKey } from '../lib/clientIp.ts'
import rateLimit from '@fastify/rate-limit'
import type { FastifyInstance } from 'fastify'
import { RateLimitError } from '../lib/errors.ts'
Expand All @@ -34,10 +34,10 @@ async function rateLimitPlugin(app: FastifyInstance) {
max: 100,
timeWindow: '1 minute',

// The key is the client IP. request.ip only respects X-Forwarded-For
// when trustProxy is on — which we enable in production. Without it,
// everyone behind a proxy would share ONE counter and lock each other out.
keyGenerator: (request) => clientIp(request),
// The key is the client's address as the edge reports it (lib/clientIp),
// widened to its /64 for IPv6 — otherwise one IPv6 subscriber holds
// 2^64 separate buckets and the limit means nothing.
keyGenerator: (request) => rateLimitKey(clientIp(request)),

// NOTE: the plugin THROWS whatever errorResponseBuilder returns. Return a
// plain object and it has no statusCode, so our central error handler
Expand Down
Loading
Loading