diff --git a/.env.example b/.env.example index a136f20..71f3cdd 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,25 @@ POSTGRES_DB=shhh PORT=3000 +# --- Reverse proxy --- +# How many proxies you control sit in front of this app. 0 (the default) ignores X-Forwarded-For and +# uses the connection's own address — correct when nothing is in front, and safe when something is. +# Set it to 1 behind a single nginx/Caddy/Traefik, 2 behind Cloudflare plus your own proxy, and so on. +# Rate limits, the IP allow/blocklist and automatic bans all key on the address this resolves, so a +# value larger than your real chain would let a caller choose their own address. +TRUSTED_PROXY_DEPTH=0 + +# How long an automatic ban lasts (probe paths, untrusted bots). 0 bans permanently. +# Bans an admin places by hand from the dashboard are always permanent and are never shortened here. +AUTO_BAN_DURATION_HOURS=72 + + +# Optional. GET /api/health always reports whether the instance and its database are up, which is +# all a monitor needs. Set a token to also expose the mail provider and storage usage, readable with +# `Authorization: Bearer ` — without it those two fields are simply absent. +HEALTH_TOKEN= + + # --- Cloudflare Turnstile (anti-bot on paste creation) --- # Required in production: paste creation rejects requests without a valid token. # Get a pair at https://dash.cloudflare.com/?to=/:account/turnstile and add your domain to the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c39e519..68691e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,13 +58,48 @@ jobs: run: ./node_modules/.bin/drizzle-kit check docker: - name: Docker image + name: Docker image (${{ matrix.name }}) runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - name: app + dockerfile: docker/Dockerfile + - name: docs + dockerfile: docker/docs.Dockerfile steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Build image - run: docker build -f docker/Dockerfile -t shhh:ci . + run: docker build -f ${{ matrix.dockerfile }} -t shhh-${{ matrix.name }}:ci . + + # Building only proves it compiles. The docs image built cleanly for a long time while being + # unable to boot at all, because nothing ever started it. + # + # Any HTTP response passes: this asks whether the server came up, not whether it is healthy. + # The app has no database here, so /api/health legitimately answers 503 — hence SKIP_MIGRATIONS, + # without which the migrate plugin exits before Nitro ever listens. + - name: Boot it + run: | + docker run -d --name probe -p 3000:3000 \ + -e SKIP_MIGRATIONS=true \ + -e DATABASE_URL=postgres://unused \ + -e BETTER_AUTH_SECRET=ci-secret-not-used-for-anything-000 \ + -e BETTER_AUTH_URL=http://localhost:3000 \ + shhh-${{ matrix.name }}:ci + + for _ in $(seq 1 30); do + if curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ | grep -qE '^[2345]'; then + echo "the ${{ matrix.name }} image answers" + exit 0 + fi + sleep 2 + done + + echo "::error::the ${{ matrix.name }} image never answered" + docker logs probe + exit 1 diff --git a/README.md b/README.md index 43e0ca9..aba9c5f 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,13 @@ Nothing else changes — the app only ever reaches the database through that one ### Behind a reverse proxy -Terminate TLS at your proxy and set `BETTER_AUTH_URL` to the public HTTPS address. The proxy must -**overwrite** `X-Forwarded-For` rather than append to a client-supplied value — rate limiting and IP -banning both trust that header. +Terminate TLS at your proxy and set `BETTER_AUTH_URL` to the public HTTPS address. Set +`TRUSTED_PROXY_DEPTH` to the number of proxies you control in front of the app — `1` behind a single +nginx or Caddy, `2` with Cloudflare in front of that. It defaults to `0`, which ignores +`X-Forwarded-For` and uses the connection address. + +A worked nginx config, including the `client_max_body_size` that file uploads need, is in the +[deployment docs](apps/docs/content/2.self-hosting/1.installation.md). ### Turnstile @@ -166,8 +170,8 @@ docker Dockerfile and docker-compose scripts Dev tooling wrappers ``` -Health check for monitoring: `GET /api/health` returns database status, mail provider and storage -usage — point Uptime Kuma at it. +Health check for monitoring: `GET /api/health` reports whether the instance and its database are up +— point Uptime Kuma at it. Storage usage and the mail provider sit behind `HEALTH_TOKEN`. Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md), which covers the checks to run and how AI-assisted changes are handled. diff --git a/SECURITY.md b/SECURITY.md index 3479a08..ac9c729 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,11 +50,18 @@ Independent layers, all active: - Automatic banning of IPs requesting known probe paths (`wp-admin`, `.git`, …) or identified as untrusted bots, with an admin-managed allowlist and blocklist. - Atomic decrement of the read counter, so concurrent requests cannot over-read a one-read link. +- A Content-Security-Policy restricting outbound requests to the instance itself and Turnstile. It + is the layer that matters most here: an XSS in a zero-knowledge app does not leak a session, it + leaks the decryption key of every paste opened while it runs. The policy still allows inline + script, so it contains exfiltration rather than preventing injection. +- `Referrer-Policy: no-referrer`, so the paste id in the path never travels to a third party, plus + `X-Frame-Options`, `X-Content-Type-Options`, `Cross-Origin-Opener-Policy` and `Permissions-Policy`. ## Deployment requirements -- **Put it behind a reverse proxy that overwrites `X-Forwarded-For`.** Rate limiting and IP banning - trust that header; a proxy that appends to a client-supplied value lets callers forge their address. +- **Set `TRUSTED_PROXY_DEPTH` to the number of proxies you control.** It defaults to `0`, which + ignores `X-Forwarded-For` and uses the connection address. Setting it higher than your real chain + lets a caller write their own address into the header, and with it get another address banned. - **Serve over HTTPS.** The fragment key is in the URL. Without TLS it is exposed in transit. - **Set a strong `BETTER_AUTH_SECRET`** and keep it stable — changing it invalidates all sessions. - Secrets are only ever read from the environment. Nothing sensitive is stored in the database, so diff --git a/apps/app/nuxt.config.ts b/apps/app/nuxt.config.ts index c09f97d..7c04789 100644 --- a/apps/app/nuxt.config.ts +++ b/apps/app/nuxt.config.ts @@ -11,6 +11,40 @@ export default defineNuxtConfig({ devtools: { enabled: true }, css: ['~/assets/css/main.css'], + + // Defence in depth for a design where the browser holds the only decryption key: an XSS here does + // not leak a session, it leaks every key that passes through the page. + routeRules: { + '/**': { + headers: { + 'Content-Security-Policy': [ + 'default-src \'self\'', + 'base-uri \'none\'', + 'object-src \'none\'', + 'frame-ancestors \'none\'', + 'form-action \'self\'', + // 'wasm-unsafe-eval' is not optional: Argon2id runs through hash-wasm, so without it every + // password-protected paste stops opening. 'unsafe-inline' covers Nuxt's SSR bootstrap. + 'script-src \'self\' \'unsafe-inline\' \'wasm-unsafe-eval\' https://challenges.cloudflare.com', + 'style-src \'self\' \'unsafe-inline\'', + 'img-src \'self\' data: blob:', + 'font-src \'self\' data:', + // The directive that carries the most weight here: Turnstile is the only third party the + // page may reach, so injected script has nowhere to post a key it managed to read. + 'connect-src \'self\' https://challenges.cloudflare.com', + 'frame-src https://challenges.cloudflare.com', + // A decrypted file is handed to the user as a blob: URL. + 'worker-src \'self\' blob:' + ].join('; '), + // The paste id lives in the path, so it must never travel in a Referer to a third party. + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()' + } + } + }, compatibilityDate: '2025-07-15', nitro: { @@ -41,5 +75,13 @@ export default defineNuxtConfig({ ], defaultLocale: 'en', strategy: 'no_prefix' + }, + + // Without this, icons are fetched at runtime from /api/_nuxt_icon and log `[Icon] failed to load + // icon` when that misses. Scanning bundles every icon written literally in the source instead. + icon: { + clientBundle: { + scan: true + } } }) diff --git a/apps/app/server/api/health.get.ts b/apps/app/server/api/health.get.ts index b84186a..e399e8d 100644 --- a/apps/app/server/api/health.get.ts +++ b/apps/app/server/api/health.get.ts @@ -1,31 +1,47 @@ -// Public and unauthenticated: no counts beyond storage usage, and a failing dependency is reported as a status, never a stack trace. +// Public and unauthenticated: whether the instance answers and whether its database does, which is +// everything a monitor needs to decide up or down. A failing dependency is reported as a status, +// never as a stack trace. +// +// Storage usage and the mail provider describe the instance to whoever asks, so they are behind +// HEALTH_TOKEN. With no token configured the fields are absent rather than empty — a monitor that +// never asked for them sees no difference. +function isAuthorised(event: Parameters[0]) { + const expected = process.env.HEALTH_TOKEN + if (!expected) return false + const header = getRequestHeader(event, 'authorization') + return header === `Bearer ${expected}` +} + export default defineEventHandler(async (event) => { let dbOk = true - let usedBytes = 0 try { - const [row] = await db - .select({ - usedBytes: sql`coalesce(sum(coalesce(octet_length(${schema.pastes.ciphertext}), 0) + coalesce(octet_length(${schema.pastes.fileBlob}), 0)), 0)` - }) - .from(schema.pastes) - usedBytes = Number(row?.usedBytes ?? 0) + await db.execute(sql`select 1`) } catch (error) { dbOk = false console.error('[health] database check failed:', error instanceof Error ? error.message : error) } - // Falls back to the hardcoded default when unset, but only if the database answered — otherwise the quota would read as a configured value. - const quotaBytes = dbOk ? await getSetting('max_total_storage_bytes').catch(() => null) : null - if (!dbOk) { setResponseStatus(event, 503) } - return { - status: dbOk ? 'ok' : 'degraded', - db: dbOk ? 'ok' : 'down', - mail: getMailProvider(), - storage: { usedBytes, quotaBytes } + const base = { status: dbOk ? 'ok' : 'degraded', db: dbOk ? 'ok' : 'down' } + if (!isAuthorised(event)) return base + + let usedBytes = 0 + if (dbOk) { + const [row] = await db + .select({ + usedBytes: sql`coalesce(sum(coalesce(octet_length(${schema.pastes.ciphertext}), 0) + coalesce(octet_length(${schema.pastes.fileBlob}), 0)), 0)` + }) + .from(schema.pastes) + usedBytes = Number(row?.usedBytes ?? 0) } + + // Falls back to the hardcoded default when unset, but only if the database answered — otherwise + // the quota would read as a configured value. + const quotaBytes = dbOk ? await getSetting('max_total_storage_bytes').catch(() => null) : null + + return { ...base, mail: getMailProvider(), storage: { usedBytes, quotaBytes } } }) diff --git a/apps/app/server/api/pastes/index.post.ts b/apps/app/server/api/pastes/index.post.ts index 3ae6d33..086b974 100644 --- a/apps/app/server/api/pastes/index.post.ts +++ b/apps/app/server/api/pastes/index.post.ts @@ -92,7 +92,7 @@ export default defineEventHandler(async (event) => { await assertTwoFactorCompliance(session.user, settings.require_2fa) } - const identifier = session ? session.user.id : (getRequestIP(event, { xForwardedFor: true }) ?? 'unknown') + const identifier = session ? session.user.id : (getClientIp(event) ?? 'unknown') const rateLimit = await checkRateLimit({ scope: tier === 'anonymous' ? 'ip' : 'user', diff --git a/apps/app/server/api/setup/complete.post.ts b/apps/app/server/api/setup/complete.post.ts index e2c5db2..f41ee94 100644 --- a/apps/app/server/api/setup/complete.post.ts +++ b/apps/app/server/api/setup/complete.post.ts @@ -19,13 +19,28 @@ const setupSchema = z.object({ settings: settingsSchema }) +// Distinct from the migrator's key: this one serialises the wizard, not the schema. +const SETUP_LOCK_KEY = 4_827_302 + export default defineEventHandler(async (event) => { + const body = await readValidatedBody(event, setupSchema.parse) + + // The check and the write have to be one critical section. Without it two requests arriving + // together both see an empty instance and both create a super_admin — the account that owns + // everything, so "unlikely" is not a good enough guarantee. + await db.execute(sql`select pg_advisory_lock(${SETUP_LOCK_KEY})`) + try { + return await completeSetup(event, body) + } finally { + await db.execute(sql`select pg_advisory_unlock(${SETUP_LOCK_KEY})`) + } +}) + +async function completeSetup(event: Parameters[0], body: z.infer) { if (await isSetupComplete()) { throw createError({ statusCode: 409, statusMessage: 'Setup already completed' }) } - const body = await readValidatedBody(event, setupSchema.parse) - const { headers, response } = await auth.api.signUpEmail({ body: { name: body.name, email: body.email, password: body.password }, headers: event.headers, @@ -35,9 +50,12 @@ export default defineEventHandler(async (event) => { // Not a signup input (`input: false` in auth.ts), so it is set directly. Auto-verified: completing the wizard proves control of both the instance and this address. await db.update(schema.users).set({ role: 'super_admin', emailVerified: true }).where(eq(schema.users.id, response.user.id)) - await db.insert(schema.appSettings).values( - Object.entries(body.settings).map(([key, value]) => ({ key, value, updatedBy: response.user.id })) - ) + // A retry after a partially failed setup would otherwise hit the primary key and surface as a + // 500 rather than finishing the job. + await db + .insert(schema.appSettings) + .values(Object.entries(body.settings).map(([key, value]) => ({ key, value, updatedBy: response.user.id }))) + .onConflictDoNothing({ target: schema.appSettings.key }) for (const cookie of headers.getSetCookie()) { appendResponseHeader(event, 'set-cookie', cookie) @@ -45,4 +63,4 @@ export default defineEventHandler(async (event) => { setResponseStatus(event, 201) return { id: response.user.id, name: response.user.name, email: response.user.email } -}) +} diff --git a/apps/app/server/middleware/ip-sec.ts b/apps/app/server/middleware/ip-sec.ts index e922ec7..b945053 100644 --- a/apps/app/server/middleware/ip-sec.ts +++ b/apps/app/server/middleware/ip-sec.ts @@ -42,7 +42,7 @@ export default defineEventHandler(async (event) => { return } - const ip = getRequestIP(event, { xForwardedFor: true }) + const ip = getClientIp(event) if (!ip || IGNORED_IPS.has(ip)) { return } diff --git a/apps/app/server/utils/auth.ts b/apps/app/server/utils/auth.ts index 7d324df..1c0d3a1 100644 --- a/apps/app/server/utils/auth.ts +++ b/apps/app/server/utils/auth.ts @@ -29,8 +29,13 @@ export const auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL, advanced: { ipAddress: { - // Without this, Better Auth falls back to one shared rate-limit bucket for every caller behind a proxy, so a single attacker throttles everyone's sign-in attempts. - // Same X-Forwarded-For trust as the rest of the app, and the same requirement: the proxy must overwrite the header, not append to a client-supplied value. + // Without this, Better Auth can't resolve a caller behind a proxy and falls back to one shared + // rate-limit bucket, so a single attacker throttles everyone's sign-in attempts. + // Better Auth resolves the header itself and fails closed: with more than one entry and no + // `trustedProxies` configured it returns no address rather than guessing, so a forged entry + // cannot be mistaken for the client. That also means the shared-bucket fallback comes back + // when two proxies are chained — the warning in the container logs is the symptom. + // Our own abuse controls don't go through this; they use `getClientIp` and TRUSTED_PROXY_DEPTH. ipAddressHeaders: ['x-forwarded-for'] } }, diff --git a/apps/app/server/utils/client-ip.ts b/apps/app/server/utils/client-ip.ts new file mode 100644 index 0000000..0a11b42 --- /dev/null +++ b/apps/app/server/utils/client-ip.ts @@ -0,0 +1,49 @@ +import type { H3Event } from 'h3' + +/** + * How many proxies you control sit between the internet and this app. 0 — the default — never reads + * X-Forwarded-For and uses the socket address, which is the only value a client cannot influence. + * + * Anything above 0 is a promise about your deployment, so it is opt-in: the danger is not reading the + * header, it is reading it when nothing trustworthy wrote it. + */ +function trustedProxyDepth(): number { + const raw = process.env.TRUSTED_PROXY_DEPTH + if (!raw) return 0 + const depth = Number(raw) + return Number.isInteger(depth) && depth >= 0 ? depth : 0 +} + +/** + * Resolves the client address from the socket address and the X-Forwarded-For chain. + * + * Each proxy appends the peer it received the request from, so the chain reads left to right from + * least to most trustworthy: the leftmost entries may have been forged by the client, the rightmost + * were written by the hop closest to us. Counting `depth` entries from the right therefore lands on + * the address our own infrastructure observed, and anything a client prepended is ignored. + * + * A chain shorter than `depth` means the request did not come through the expected proxies at all — + * a direct hit, or a misconfiguration. Falling back to the socket address is the safe reading; using + * whatever is there would accept a forged header from a client that bypassed the proxy. + */ +export function resolveClientIp(socketIp: string | undefined, forwardedFor: string | undefined, depth: number): string | undefined { + if (depth <= 0 || !forwardedFor) return socketIp + + const chain = forwardedFor.split(',').map(part => part.trim()).filter(Boolean) + if (chain.length < depth) return socketIp + + return chain[chain.length - depth] ?? socketIp +} + +/** + * The address every abuse control in this app is keyed on: the rate limiter, the allowlist, the + * blocklist, and the automatic bans. Always go through this rather than `getRequestIP`, whose + * `xForwardedFor` option takes the leftmost entry — the one a client can write. + */ +export function getClientIp(event: H3Event): string | undefined { + return resolveClientIp( + event.node.req.socket.remoteAddress, + getRequestHeader(event, 'x-forwarded-for'), + trustedProxyDepth() + ) +} diff --git a/apps/app/server/utils/ip-security.ts b/apps/app/server/utils/ip-security.ts index 36d4738..0025dd9 100644 --- a/apps/app/server/utils/ip-security.ts +++ b/apps/app/server/utils/ip-security.ts @@ -1,3 +1,17 @@ +// Bans posted by the middleware expire on their own. A scanner simply comes back and is banned +// again; a real person caught by the heuristic — a shared office address, a curious employee +// poking at /wp-admin — gets back in without the operator having to notice and intervene. +// Set AUTO_BAN_DURATION_HOURS=0 to keep the old behaviour and ban permanently. +const DEFAULT_AUTO_BAN_HOURS = 72 + +function autoBanExpiry(): Date | null { + const raw = process.env.AUTO_BAN_DURATION_HOURS + const hours = raw === undefined || raw.trim() === '' ? DEFAULT_AUTO_BAN_HOURS : Number(raw) + if (!Number.isInteger(hours) || hours < 0) return new Date(Date.now() + DEFAULT_AUTO_BAN_HOURS * 3_600_000) + if (hours === 0) return null + return new Date(Date.now() + hours * 3_600_000) +} + export async function isIpAllowlisted(ip: string) { const [row] = await db.select({ id: schema.allowedIps.id }).from(schema.allowedIps).where(eq(schema.allowedIps.ip, ip)).limit(1) return !!row @@ -13,5 +27,17 @@ export async function isIpBanned(ip: string) { } export async function banIp(ip: string, reason: string) { - await db.insert(schema.bannedIps).values({ ip, reason }).onConflictDoNothing({ target: schema.bannedIps.ip }) + // An update rather than `onConflictDoNothing`: a lapsed row still occupies the unique index, so + // doing nothing would leave a repeat offender permanently unbannable after their first ban expired. + // + // `setWhere` on a non-null expires_at is what keeps that from working in reverse: a permanent ban + // is one an admin placed by hand, and the middleware must never quietly shorten it to 72 hours. + await db + .insert(schema.bannedIps) + .values({ ip, reason, expiresAt: autoBanExpiry() }) + .onConflictDoUpdate({ + target: schema.bannedIps.ip, + set: { reason, bannedAt: new Date(), expiresAt: autoBanExpiry() }, + setWhere: isNotNull(schema.bannedIps.expiresAt) + }) } diff --git a/apps/app/tests/client-ip.test.ts b/apps/app/tests/client-ip.test.ts new file mode 100644 index 0000000..f1dbe62 --- /dev/null +++ b/apps/app/tests/client-ip.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { resolveClientIp } from '../server/utils/client-ip' + +const SOCKET = '10.0.0.5' + +describe('resolveClientIp', () => { + describe('with no trusted proxy (depth 0, the default)', () => { + it('uses the socket address and ignores the header entirely', () => { + expect(resolveClientIp(SOCKET, '1.2.3.4', 0)).toBe(SOCKET) + }) + + // The whole point of defaulting to 0: an app reachable directly must not believe a header. + it('cannot be talked into trusting a forged header', () => { + expect(resolveClientIp(SOCKET, '6.6.6.6, 6.6.6.7', 0)).toBe(SOCKET) + }) + }) + + describe('behind one proxy (depth 1)', () => { + it('takes the address the proxy appended', () => { + expect(resolveClientIp(SOCKET, '1.2.3.4', 1)).toBe('1.2.3.4') + }) + + // A proxy that appends rather than overwrites leaves the client's own value in front of the + // real one. Counting from the right is what makes that harmless. + it('ignores an entry the client prepended', () => { + expect(resolveClientIp(SOCKET, '6.6.6.6, 1.2.3.4', 1)).toBe('1.2.3.4') + }) + }) + + describe('behind two proxies (depth 2)', () => { + it('skips the intermediate proxy and returns the client', () => { + expect(resolveClientIp(SOCKET, '1.2.3.4, 9.9.9.9', 2)).toBe('1.2.3.4') + }) + + it('still ignores a forged entry in front of the chain', () => { + expect(resolveClientIp(SOCKET, '6.6.6.6, 1.2.3.4, 9.9.9.9', 2)).toBe('1.2.3.4') + }) + }) + + describe('when the chain is shorter than the configured depth', () => { + // A request that skipped the proxies, so the header proves nothing. Falling back to the socket + // is what stops someone reaching the app directly from choosing their own address. + it('falls back to the socket address rather than trusting what is there', () => { + expect(resolveClientIp(SOCKET, '6.6.6.6', 2)).toBe(SOCKET) + }) + + it('falls back when the header is absent', () => { + expect(resolveClientIp(SOCKET, undefined, 2)).toBe(SOCKET) + }) + + it('falls back when the header is empty or only separators', () => { + expect(resolveClientIp(SOCKET, '', 1)).toBe(SOCKET) + expect(resolveClientIp(SOCKET, ' , , ', 1)).toBe(SOCKET) + }) + }) + + it('tolerates the whitespace real proxies emit around commas', () => { + expect(resolveClientIp(SOCKET, ' 1.2.3.4 , 9.9.9.9 ', 2)).toBe('1.2.3.4') + }) + + it('returns undefined when there is no socket address and nothing to fall back on', () => { + expect(resolveClientIp(undefined, undefined, 0)).toBeUndefined() + }) +}) diff --git a/apps/docs/app/app.config.ts b/apps/docs/app/app.config.ts new file mode 100644 index 0000000..085b2f6 --- /dev/null +++ b/apps/docs/app/app.config.ts @@ -0,0 +1,10 @@ +export default defineAppConfig({ + // Same pair as the app (apps/app/app/app.config.ts) so the docs and the instance a reader lands + // on look like one product. Both names come from the list Docus declares in its nuxt.schema.ts. + ui: { + colors: { + primary: 'amber', + neutral: 'zinc', + }, + }, +}) diff --git a/apps/docs/content/2.self-hosting/1.installation.md b/apps/docs/content/2.self-hosting/1.installation.md index 733cae1..cfb8147 100644 --- a/apps/docs/content/2.self-hosting/1.installation.md +++ b/apps/docs/content/2.self-hosting/1.installation.md @@ -13,22 +13,39 @@ navigation: ## Quick start +No checkout needed — the image is published on Docker Hub. The installer fetches the compose file, +generates the secrets, writes a `.env` with mode 600 and starts the stack: + +```bash +mkdir shhh && cd shhh +curl -fsSLO https://raw.githubusercontent.com/thoda-dev/shhh/master/install.sh +less install.sh && sh install.sh +``` + +It asks for the public URL, whether to use the bundled PostgreSQL, how many proxies sit in front, +and the Turnstile keys. Set `BETTER_AUTH_URL`, `SHHH_BUNDLED_DB`, `SHHH_PROXY_DEPTH`, +`TURNSTILE_SITE_KEY`, `TURNSTILE_SECRET_KEY` and `SHHH_START` in the environment and it asks +nothing, which is what makes it usable from cloud-init or Ansible. + +### By hand + +Two files, no script: + ```bash -git clone https://github.com/thoda-dev/shhh.git && cd shhh -cp .env.example .env +curl -O https://raw.githubusercontent.com/thoda-dev/shhh/master/docker/docker-compose.yml +curl -o .env https://raw.githubusercontent.com/thoda-dev/shhh/master/.env.example ``` -Edit `.env` and set the two required values: +Then fill in `.env` and bring it up: ```bash [.env] -BETTER_AUTH_SECRET= # openssl rand -base64 32 +BETTER_AUTH_SECRET= # openssl rand -base64 32 BETTER_AUTH_URL=https://shhh.example.com +TRUSTED_PROXY_DEPTH=1 # see "Behind a reverse proxy" below ``` -Then bring it up: - ```bash -docker compose -f docker/docker-compose.yml up -d +docker compose up -d ``` Open your instance. The **setup wizard** walks you through creating the super admin account and the @@ -58,35 +75,81 @@ permission to create tables in the target schema. Terminate TLS at your proxy and set `BETTER_AUTH_URL` to the public HTTPS address. That value is also used to build the links sent by email, so it has to be the address your users actually reach. +Set `TRUSTED_PROXY_DEPTH` to the number of proxies you control in front of the app: `1` for the +setup below, `2` if Cloudflare sits in front of it too. Rate limiting, the IP allow/blocklist and +automatic bans all key on the address it resolves. + ::warning -Your proxy must **overwrite** `X-Forwarded-For`, not append to a client-supplied value. Rate -limiting and IP banning both trust that header; a proxy that appends lets callers forge their -address. +Counting wrong breaks one of the two things. Too low, and every request looks like it came from your +proxy, so a single caller exhausts everyone's rate limit. Too high, and the app reads an entry the +client wrote, so a caller picks their own address — and can get somebody else's banned. :: -```nginx [nginx] -location / { - proxy_pass http://127.0.0.1:3000; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; +```nginx [/etc/nginx/sites-available/shhh] +server { + listen 443 ssl; + server_name shhh.example.com; + + # Certificates: certbot --nginx writes these two lines for you. + ssl_certificate /etc/letsencrypt/live/shhh.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/shhh.example.com/privkey.pem; + + # Uploads travel as base64 inside JSON, so the body is roughly 1.4x the file. nginx defaults to + # 1m and would reject them with its own 413 before the app could answer with a useful message. + # Keep this comfortably above max_upload_size_bytes in the admin dashboard. + client_max_body_size 10m; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + # Appends this connection's address to whatever the client sent. Harmless: with + # TRUSTED_PROXY_DEPTH=1 the app counts one entry from the right and lands on this one, + # ignoring anything prepended. `$remote_addr` works too, and overwrites instead. + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} + +# Anything on port 80 exists only to send people to 443. The fragment key lives in the URL. +server { + listen 80; + server_name shhh.example.com; + return 301 https://$host$request_uri; } ``` -Note `$remote_addr` rather than `$proxy_add_x_forwarded_for` — the latter appends. +Then `TRUSTED_PROXY_DEPTH=1` in `.env`, and restart. To check it took, ban an address from the admin +dashboard from another network and confirm only that address is refused. ## Upgrading ```bash -git pull -docker compose -f docker/docker-compose.yml up -d --build +docker compose pull +docker compose up -d ``` Migrations run on boot. Take a database backup first; there is no automatic rollback. +`latest` never points at a pre-release. Pin the tag to a version in `docker-compose.yml` to control +when you move. + ## Health checks -`GET /api/health` reports database status, the configured mail provider and storage usage: +`GET /api/health` is public and reports whether the instance and its database are up: + +```json +{ "status": "ok", "db": "ok" } +``` + +It returns `503` when the database is unreachable. The path is exempt from bot detection, so +monitoring agents are not banned for probing it. + +Storage usage and the mail provider describe your instance to anyone who asks, so they sit behind +`HEALTH_TOKEN`. Set one and pass it to read them: + +```bash +curl -H "Authorization: Bearer $HEALTH_TOKEN" https://shhh.example.com/api/health +``` ```json { @@ -97,8 +160,7 @@ Migrations run on boot. Take a database backup first; there is no automatic roll } ``` -It returns `503` when the database is unreachable. The path is exempt from bot detection, so -monitoring agents are not banned for probing it. +With no token configured those two fields are simply absent. ## Building from source @@ -111,5 +173,6 @@ pnpm dev # http://localhost:3000 pnpm typecheck ``` -`pnpm dev` and `pnpm build` route through a wrapper that injects secrets via the Infisical CLI when -it is installed and logged in, and runs Nuxt directly otherwise. +`pnpm dev` and `pnpm build` route through a wrapper that injects secrets via the Infisical CLI when a +`.infisical.json` is present, and runs Nuxt directly otherwise. That file is gitignored and +per-developer, so a fresh checkout uses the `.env` above. diff --git a/apps/docs/content/2.self-hosting/2.configuration.md b/apps/docs/content/2.self-hosting/2.configuration.md index b510ecc..d13b5f7 100644 --- a/apps/docs/content/2.self-hosting/2.configuration.md +++ b/apps/docs/content/2.self-hosting/2.configuration.md @@ -22,6 +22,17 @@ No credential is ever stored in the database, so the dashboard cannot leak one. | `BETTER_AUTH_SECRET` | Signs session cookies. `openssl rand -base64 32`. Changing it logs everybody out | | `BETTER_AUTH_URL` | Public URL of the instance, no trailing slash. Also used to build emailed links | +### Deployment + +| Variable | Default | Description | +|---|---|---| +| `TRUSTED_PROXY_DEPTH` | `0` | Proxies you control in front of the app. `0` ignores `X-Forwarded-For` and uses the connection address | +| `AUTO_BAN_DURATION_HOURS` | `72` | How long an automatic ban lasts. `0` bans permanently | +| `HEALTH_TOKEN` | *(empty)* | Unlocks the mail provider and storage fields on `/api/health` | + +Automatic bans are the ones the middleware places on probe paths and untrusted bots. Bans an admin +places by hand from the dashboard are always permanent and are never shortened by this setting. + ### Anti-bot | Variable | Description | diff --git a/apps/docs/content/2.self-hosting/3.security.md b/apps/docs/content/2.self-hosting/3.security.md index df17c46..898f551 100644 --- a/apps/docs/content/2.self-hosting/3.security.md +++ b/apps/docs/content/2.self-hosting/3.security.md @@ -51,6 +51,14 @@ Independent layers, all active by default: - **Automatic IP banning** for requests to known probe paths (`wp-admin`, `.git`, `phpinfo.php`, …) or from bots identified as untrusted, with an admin-managed allowlist and blocklist. - **Atomic read-counter decrement**, so concurrent requests cannot over-read a one-read link. +- **A Content-Security-Policy** limiting outbound requests to the instance itself and Turnstile, + alongside `Referrer-Policy: no-referrer` so the paste id never leaves in a `Referer`. + +::note +The CSP is the layer that matters most in a zero-knowledge design: an XSS here does not leak a +session, it leaks the decryption key of every paste opened while it runs. The policy still allows +inline script, so it contains exfiltration rather than preventing injection. +:: ## Accounts @@ -74,8 +82,10 @@ it instance-wide. ## Deployment requirements ::warning -**Put it behind a reverse proxy that overwrites `X-Forwarded-For`.** Rate limiting and IP banning -trust that header; a proxy that appends to a client-supplied value lets callers forge their address. +**Set `TRUSTED_PROXY_DEPTH` to match your actual setup.** It defaults to `0`, which ignores +`X-Forwarded-For` entirely and uses the connection's own address — right when nothing sits in front, +and safe when something does. A value larger than your real chain lets a caller write their own +address into the header, and with it get another address banned. :: - **Serve over HTTPS.** The fragment key is in the URL — without TLS it is exposed in transit. diff --git a/apps/docs/content/2.self-hosting/4.administration.md b/apps/docs/content/2.self-hosting/4.administration.md index 0387b64..7262a5b 100644 --- a/apps/docs/content/2.self-hosting/4.administration.md +++ b/apps/docs/content/2.self-hosting/4.administration.md @@ -45,8 +45,11 @@ bots. Two screens let you manage the result: - **`/admin/banned-ips`** — the list of bans, automatic and manual. You can add a ban yourself with a reason and an optional expiry; leaving the expiry empty makes it permanent. -Automatic bans are permanent. An allowlist entry wins over a ban, and removing it re-applies the ban -on the next request. +Automatic bans expire after `AUTO_BAN_DURATION_HOURS`, 72 by default — long enough to discourage a +scanner, short enough that somebody caught by mistake gets back in without you noticing. Set it to +`0` to make them permanent. Bans you place by hand are never shortened by that setting. + +An allowlist entry wins over a ban, and removing it re-applies the ban on the next request. ::warning Command-line HTTP clients with their default user agent are identified as untrusted bots and banned. diff --git a/apps/docs/nuxt.config.ts b/apps/docs/nuxt.config.ts index 6cbaa94..c6f7ec6 100644 --- a/apps/docs/nuxt.config.ts +++ b/apps/docs/nuxt.config.ts @@ -1,3 +1,51 @@ export default defineNuxtConfig({ extends: ['docus'], + + // @nuxt/content bakes an absolute build-time path for its SQLite file, which does not exist in a + // runtime image that only carries .output. A relative path resolves against the working directory + // instead, so the same build runs anywhere — see docker/docs.Dockerfile for the writable mount. + content: { + database: { + type: 'sqlite', + filename: '.data/content/contents.sqlite', + }, + }, + + // Nitro externalises this one but its dependency tracing misses `dist/i18n-runtime.mjs`, so the + // built server cannot boot: `ERR_MODULE_NOT_FOUND` on the first request to node .output/server. + // Inlining it puts the file in the bundle instead of relying on the trace. + nitro: { + externals: { + inline: ['nuxtseo-shared'], + }, + }, + + // Same reason as the app: bundle icons at build time rather than fetching them at runtime. + icon: { + clientBundle: { + // Only sees icons written literally in the sources. + scan: true, + // The rest come from content frontmatter, .navigation.yml, and code-block filenames, which the + // scanner never reads. Add an entry here when a new one shows up as `[Icon] failed to load`. + icons: [ + 'lucide:arrow-right', + 'lucide:book-open', + 'lucide:container', + 'lucide:eye-off', + 'lucide:file-lock', + 'lucide:info', + 'lucide:lock-keyhole', + 'lucide:rocket', + 'lucide:server', + 'lucide:shield', + 'lucide:shield-check', + 'lucide:sliders-horizontal', + 'lucide:timer', + 'lucide:user', + 'lucide:users', + 'simple-icons:github', + 'vscode-icons:file-type-dotenv', + ], + }, + }, }) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e8040a8..aa8ac66 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -21,6 +21,9 @@ services: DATABASE_URL: ${DATABASE_URL:-postgres://shhh:shhh@db:5432/shhh} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} BETTER_AUTH_URL: ${BETTER_AUTH_URL:?set BETTER_AUTH_URL in .env} + TRUSTED_PROXY_DEPTH: ${TRUSTED_PROXY_DEPTH:-0} + AUTO_BAN_DURATION_HOURS: ${AUTO_BAN_DURATION_HOURS:-72} + HEALTH_TOKEN: ${HEALTH_TOKEN:-} NUXT_PUBLIC_TURNSTILE_SITE_KEY: ${NUXT_PUBLIC_TURNSTILE_SITE_KEY:-} NUXT_TURNSTILE_SECRET_KEY: ${NUXT_TURNSTILE_SECRET_KEY:-} MAIL_PROVIDER: ${MAIL_PROVIDER:-none} diff --git a/docker/docs.Dockerfile b/docker/docs.Dockerfile new file mode 100644 index 0000000..a9dc207 --- /dev/null +++ b/docker/docs.Dockerfile @@ -0,0 +1,59 @@ +# Build from the repo root, not from docker/: +# docker build -f docker/docs.Dockerfile -t shhh-docs . +# +# The documentation site. Separate image from the app: it shares no runtime, no database and no +# release cadence reason to be bundled together, and an instance operator has no use for it. +FROM node:24-alpine AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +RUN corepack enable +WORKDIR /app + + +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/app/package.json apps/app/ +COPY apps/docs/package.json apps/docs/ +# `--filter docs...` is the mirror of the app image: the Nuxt application is never served here. +RUN pnpm install --frozen-lockfile --filter docs... + + +FROM deps AS build +COPY . . +# Nuxt's own binary rather than `pnpm build:docs`, which routes through scripts/nuxt.mjs and its +# Infisical wrapper. The docs need no secrets at all. +RUN pnpm --filter docs exec nuxt build + + +# Same reasoning as the app image: the runtime needs no package manager, and inheriting one is what +# drags its vulnerabilities in. +FROM node:24-alpine AS runtime +WORKDIR /app + +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \ + /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ + /usr/local/bin/pnpm /usr/local/bin/pnpx \ + /usr/local/bin/yarn /usr/local/bin/yarnpkg /opt/yarn-v* + +ENV NODE_ENV=production +ENV NUXT_PORT=3000 +ENV NUXT_HOST=0.0.0.0 + +# Nitro's output carries its own node_modules, native bindings included — @nuxt/content's SQLite and +# the sharp build for this image's architecture. Nothing is installed in this stage. +COPY --from=build /app/apps/docs/.output ./.output + +# @nuxt/content opens its SQLite database read-write at boot, so unlike the app this image does need +# one writable directory. The relative path set in nuxt.config resolves from Nitro's server +# directory, hence this location rather than /app/.data. Everything else stays root-owned. +RUN mkdir -p /app/.output/server/.data/content && chown -R node:node /app/.output/server/.data + +USER node + +EXPOSE 3000 + +# No database and no /api/health here, so the site's own root is the only meaningful signal. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + +CMD ["node", ".output/server/index.mjs"] diff --git a/install.sh b/install.sh index 2d319b4..68e3dc7 100755 --- a/install.sh +++ b/install.sh @@ -12,6 +12,7 @@ # BETTER_AUTH_URL public HTTPS address of the instance, no trailing slash # SHHH_BUNDLED_DB 1 to use the PostgreSQL from the compose file, 0 for your own # DATABASE_URL connection string, required only when SHHH_BUNDLED_DB=0 +# SHHH_PROXY_DEPTH proxies you control in front of the app (default: 1 for https, else 0) # TURNSTILE_SITE_KEY Cloudflare Turnstile site key # TURNSTILE_SECRET_KEY Cloudflare Turnstile secret key # SHHH_START 1 to start the stack, 0 to only write the files @@ -77,6 +78,23 @@ printf '\n' PUBLIC_URL="${BETTER_AUTH_URL:-$(ask 'Public URL of the instance, no trailing slash' 'https://shhh.example.com')}" +printf '\n' +note 'Rate limiting, the IP allow/blocklist and automatic bans all key on the address a request' +note 'really came from. Count the proxies you control in front of this app: 0 if it is exposed' +note 'directly, 1 behind a single nginx/Caddy/Traefik, 2 behind Cloudflare plus your own proxy.' +note 'Too low and one proxy IP absorbs everyone rate limit; too high and a caller picks their own.' +printf '\n' + +# An https URL means something terminates TLS in front, since the app itself only speaks plain HTTP. +case "$PUBLIC_URL" in + https://*) PROXY_DEFAULT=1 ;; + *) PROXY_DEFAULT=0 ;; +esac +PROXY_DEPTH="${SHHH_PROXY_DEPTH:-$(ask 'Trusted proxies in front of the app' "$PROXY_DEFAULT")}" +case "$PROXY_DEPTH" in + '' | *[!0-9]*) die "the number of trusted proxies must be a whole number, got '$PROXY_DEPTH'." ;; +esac + # The bundled database is the point of the compose file, so it is the default. Answering no is for a cluster you already back up and monitor. if [ -n "${SHHH_BUNDLED_DB:-}" ]; then BUNDLED="$SHHH_BUNDLED_DB" @@ -127,6 +145,10 @@ set_env() { # set_env say 'Generating secrets' set_env BETTER_AUTH_SECRET "$(rand_b64)" set_env BETTER_AUTH_URL "$PUBLIC_URL" +set_env TRUSTED_PROXY_DEPTH "$PROXY_DEPTH" +# Unlocks two read-only fields on /api/health. Generated rather than left blank so the operator can +# point a dashboard at it later without regenerating anything; leaving it empty is equally valid. +set_env HEALTH_TOKEN "$(rand_hex)" set_env NUXT_PUBLIC_TURNSTILE_SITE_KEY "$TS_SITE" set_env NUXT_TURNSTILE_SECRET_KEY "$TS_SECRET" @@ -158,6 +180,11 @@ if [ "$USE_BUNDLED" -eq 1 ]; then else note "Database external, $(redact "$DB_URL")" fi +if [ "$PROXY_DEPTH" -eq 0 ]; then + note 'Proxies none trusted, using the connection address' +else + note "Proxies $PROXY_DEPTH trusted in front" +fi if [ -n "$TS_SITE" ] && [ -n "$TS_SECRET" ]; then note 'Turnstile configured' else diff --git a/scripts/release.mjs b/scripts/release.mjs index 16ebc82..f7f9399 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -16,6 +16,7 @@ import { consola } from 'consola' */ const IMAGE = 'thodadev/shhh' +const DOCS_IMAGE = 'thodadev/shhh-docs' const PLATFORMS = 'linux/amd64,linux/arm64' const BUILDER = 'shhh-release' const BRANCH = 'master' @@ -168,9 +169,17 @@ if (problems.length) { // The tag ladder every official image publishes: `:1` keeps receiving 1.x fixes without ever crossing into a breaking 2.0, and `:1.2` narrows that to patches. // A prerelease gets only its exact version, so no moving tag ever resolves to it. const [major, minor] = version.split('.') -const imageTags = isPrerelease - ? [`${IMAGE}:${version}`] - : [`${IMAGE}:${version}`, `${IMAGE}:${major}.${minor}`, `${IMAGE}:${major}`, `${IMAGE}:latest`] +const tagsFor = image => isPrerelease + ? [`${image}:${version}`] + : [`${image}:${version}`, `${image}:${major}.${minor}`, `${image}:${major}`, `${image}:latest`] + +// The docs ride the app's version rather than carrying their own: they document that exact release, +// so a reader can pin both to the same tag and know they match. +const images = [ + { name: 'app', dockerfile: 'docker/Dockerfile', tags: tagsFor(IMAGE) }, + { name: 'docs', dockerfile: 'docker/docs.Dockerfile', tags: tagsFor(DOCS_IMAGE) } +] +const imageTags = images.flatMap(i => i.tags) consola.box([ `Version ${current} → ${version}`, @@ -244,21 +253,23 @@ if (!flags.has('--skip-docker')) { run('docker', ['buildx', 'create', '--name', BUILDER, '--driver', 'docker-container'], { onFailure: undo }) } - consola.start(`Build et push de l'image (${PLATFORMS}) — c'est l'étape longue`) - run('docker', [ - 'buildx', 'build', - '--builder', BUILDER, - '--platform', PLATFORMS, - '-f', 'docker/Dockerfile', - ...imageTags.flatMap(t => ['-t', t]), - '--label', `org.opencontainers.image.version=${version}`, - '--label', `org.opencontainers.image.revision=${sha}`, - '--label', `org.opencontainers.image.source=${REPO_URL}`, - '--label', 'org.opencontainers.image.licenses=MIT', - '--push', - '.' - ], { onFailure: undo }) - consola.success(`Image publiée : ${imageTags.join(', ')}`) + for (const image of images) { + consola.start(`Build et push de l'image ${image.name} (${PLATFORMS}) — c'est l'étape longue`) + run('docker', [ + 'buildx', 'build', + '--builder', BUILDER, + '--platform', PLATFORMS, + '-f', image.dockerfile, + ...image.tags.flatMap(t => ['-t', t]), + '--label', `org.opencontainers.image.version=${version}`, + '--label', `org.opencontainers.image.revision=${sha}`, + '--label', `org.opencontainers.image.source=${REPO_URL}`, + '--label', 'org.opencontainers.image.licenses=MIT', + '--push', + '.' + ], { onFailure: undo }) + consola.success(`Image publiée : ${image.tags.join(', ')}`) + } } // ---------------------------------------------------------------- GitHub