# DocFast Security Audit **Date:** 2026-02-14 **Target:** https://docfast.dev (was 167.235.156.214, now K3s LB 46.225.37.135) **Auditor:** Automated Security Subagent --- ## CRITICAL Findings ### 1. CRITICAL — Stripe Webhook Signature Bypass **File:** `src/routes/billing.ts` lines 75-85 The webhook handler has a conditional signature check: ```typescript if (webhookSecret && sig) { // verify signature } else { event = req.body as Stripe.Event; // ← ACCEPTS UNVERIFIED! } ``` If `STRIPE_WEBHOOK_SECRET` is not set OR if the `stripe-signature` header is omitted, **any forged webhook is accepted**. This was confirmed live: ``` curl -X POST https://docfast.dev/v1/billing/webhook \ -H 'Content-Type: application/json' \ -d '{"type":"customer.subscription.deleted","data":{"object":{"customer":"cus_fake"}}}' → {"received":true} ``` **Impact:** An attacker can revoke any customer's API key by sending a forged `customer.subscription.deleted` event with their Stripe customer ID. Could also potentially provision Pro keys if checkout.session.completed handling is added later. **Fix:** Always require signature verification. Remove the `else` fallback: ```typescript if (!webhookSecret || !sig) { res.status(400).json({ error: "Webhook not configured" }); return; } ``` --- ### 2. CRITICAL — SSRF via URL-to-PDF Endpoint **File:** `src/routes/convert.ts` (URL endpoint) + `src/services/browser.ts` The `/v1/convert/url` endpoint only checks that the protocol is `http:` or `https:` but does **not block private/internal IPs**. Puppeteer will navigate to any URL including: - `http://127.0.0.1:3100/` (the app itself) - `http://169.254.169.254/latest/meta-data/` (cloud metadata — Hetzner) - `http://10.x.x.x/`, `http://172.16.x.x/` (internal networks) - `file://` is blocked by protocol check, but DNS rebinding could bypass IP checks **Impact:** An authenticated attacker (even with a free key) can scan internal services, access cloud metadata endpoints, and potentially exfiltrate secrets. **Fix:** Add IP validation after DNS resolution: ```typescript import { lookup } from "dns/promises"; async function isPrivateUrl(urlStr: string): Promise { const parsed = new URL(urlStr); const { address } = await lookup(parsed.hostname); const parts = address.split(".").map(Number); return ( address === "127.0.0.1" || address.startsWith("::") || parts[0] === 10 || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 169 && parts[1] === 254) || parts[0] === 0 ); } ``` Also consider using `--disable-background-networking` and `--host-resolver-rules` in Puppeteer args to block at the browser level. --- ### 3. CRITICAL — XSS in Billing Success Page **File:** `src/routes/billing.ts` (success endpoint) The Pro API key is injected directly into HTML via template literal without escaping: ```typescript res.send(`... onclick="navigator.clipboard.writeText('${keyInfo.key}')" ...>${keyInfo.key} ...`); ``` While the key is server-generated (hex), the `email` field (from Stripe) is also used in key creation. If Stripe returns a malicious email or if the key format ever changes, this becomes exploitable. More importantly, the `session_id` query parameter is passed directly to Stripe's API — if Stripe returns unexpected data, it could inject HTML. **Severity context:** Currently low exploitability since keys are hex-only, but it's a bad pattern. **Fix:** Use proper HTML escaping for all dynamic values, or return JSON and render client-side. --- ## HIGH Findings ### 4. HIGH — Container Runs as Root with --no-sandbox **File:** `Dockerfile` + `src/services/browser.ts` - Container user: `root` (confirmed via `docker exec ... id`) - Chromium launched with `--no-sandbox` If a Chromium exploit is triggered (e.g., via malicious HTML in the convert endpoint), the attacker gets root inside the container. Combined with SSRF, this is dangerous. **Fix:** ```dockerfile RUN groupadd -r docfast && useradd -r -g docfast -d /app docfast RUN chown -R docfast:docfast /app USER docfast ``` And remove `--no-sandbox` (or use `--sandbox` with proper user namespaces). --- ### 5. HIGH — No Firewall Active UFW is **inactive**. Iptables INPUT policy is **ACCEPT**. Exposed services: - Port 22 (SSH) - Port 80/443 (nginx) - Port 631 (CUPS — printing service, unnecessary on a server!) **Fix:** ```bash ufw default deny incoming ufw allow 22/tcp ufw allow 80/tcp ufw allow 443/tcp ufw enable # Also: apt remove cups ``` --- ### 6. HIGH — SSH Root Login with Default Config All SSH security settings are commented out (defaults): - `PermitRootLogin` defaults to `prohibit-password` (key-only, acceptable but not ideal) - `PasswordAuthentication` defaults to `yes` — **password brute force possible** **Fix:** ``` PermitRootLogin no # Create a non-root user first PasswordAuthentication no ``` --- ### 7. HIGH — Unlimited Free Key Signup (Abuse Vector) `POST /v1/signup/free` has no rate limiting beyond the global 100/min limit. An attacker can: - Create unlimited free keys with different emails - Each key gets 100 PDFs/month - Script: 100 signups × 100 PDFs = 10,000 PDFs/month for free **Fix:** Add per-IP rate limiting on signup (e.g., 3/hour), require email verification, or add CAPTCHA. --- ### 8. HIGH — CORS Wildcard on All Routes ``` Access-Control-Allow-Origin: * ``` This is set on **all routes**, including authenticated API endpoints. While the API uses `X-API-Key` header (which is listed in `Access-Control-Allow-Headers`), a malicious website could make cross-origin requests to the API if a user has their API key in a browser extension or similar context. For a public API this is somewhat expected, but the wildcard combined with `Authorization` header exposure is risky. **Fix:** For the landing page/docs: keep `*`. For API routes: either keep `*` (public API pattern) or restrict to known origins. At minimum, do NOT include `Authorization` in allowed headers from `*` origin — use credentialed CORS with specific origins instead. --- ## MEDIUM Findings ### 9. MEDIUM — Usage Tracking is In-Memory Only **File:** `src/middleware/usage.ts` Usage counts are stored in a `Map` in memory. On container restart, all usage resets to 0. Free tier users effectively get unlimited PDFs by waiting for restarts (or triggering them via DoS). **Fix:** Persist usage to the `/app/data` volume (already mounted). Use the same JSON file approach as keys. --- ### 10. MEDIUM — No Concurrent PDF Limit (DoS Risk) PDF generation uses Puppeteer with a shared browser instance. There's no limit on concurrent PDF generations. An attacker with a valid key could: - Send 50+ concurrent requests - Each opens a Chromium tab - Exhausts the 512MB memory limit → OOM kill The container has `mem_limit: 512m` and `cpus: 1.0` which provides some protection, but there's no application-level queuing. **Fix:** Add a concurrency semaphore (e.g., max 5 concurrent PDF renders). Queue additional requests. --- ### 11. MEDIUM — Nginx Server Version Disclosed ``` Server: nginx/1.24.0 (Ubuntu) ``` **Fix:** Add `server_tokens off;` to nginx config. --- ### 12. MEDIUM — No Page Timeout/Resource Limits in HTML Convert The `renderPdf` function uses `waitUntil: "networkidle0"` with a 15s timeout for HTML content. A malicious payload could: - Include `