config/projects/business/memory/security-audit.md
Hoid 06cdbf5fe2 Clean stale Postfix/old server references from CEO memory files
- sessions.md: Replace dangerous 'do NOT revert to mail.cloonar.com' with correction
- decisions.md: Mark Postfix scaling strategy as superseded
- security-audit.md: Update target IP to K3s LB
- bugs.md already correct (BUG-078 note says no Postfix on K3s)
2026-02-19 19:37:51 +00:00

285 lines
9.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<boolean> {
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}</div> ...`);
```
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 `<script>` that fetches external resources slowly
- Load large images/fonts
- Execute infinite loops (though Puppeteer timeout helps)
**Fix:** Disable JavaScript execution for HTML-to-PDF, or use `waitUntil: "domcontentloaded"`. Add `page.setJavaScriptEnabled(false)` for the HTML endpoint.
---
### 13. MEDIUM — API Key Exposed in Success Page URL
The billing success flow shows the API key in a rendered HTML page. The `session_id` is in the URL query string, which means:
- Browser history contains the session ID
- Referrer headers could leak it
- Anyone with the session ID can re-retrieve the key
**Fix:** Make the success endpoint one-time-use (track consumed session IDs).
---
## LOW Findings
### 14. LOW — No Email Verification on Signup
Free keys are issued instantly without email verification. This means:
- No way to contact users
- Fake emails accepted
- Can't enforce one-key-per-person
### 15. LOW — GDPR Considerations
Data stored in `/app/data/keys.json`:
- Email addresses
- API keys
- Stripe customer IDs
- Timestamps
No privacy policy, no data deletion endpoint, no consent mechanism. For EU launch, need:
- Privacy policy page
- Right to deletion (API or email-based)
- Data processing disclosure
### 16. LOW — No Request Logging/Audit Trail
No access logs beyond nginx. No record of which API key accessed what. Makes abuse investigation difficult.
### 17. LOW — Puppeteer `--no-sandbox` + `--disable-setuid-sandbox`
While these flags are common in Docker, they disable Chromium's security sandbox. Combined with running as root (#4), this amplifies any browser exploit.
---
## Summary
| Severity | Count | Key Issues |
|----------|-------|-----------|
| CRITICAL | 3 | Webhook forgery, SSRF, XSS pattern |
| HIGH | 5 | Root container, no firewall, SSH config, signup abuse, CORS |
| MEDIUM | 5 | In-memory usage, DoS, nginx version, no JS disable, session reuse |
| LOW | 4 | No email verify, GDPR, no audit log, sandbox flags |
## Priority Fix Order
1. **Stripe webhook** — Fix the signature bypass immediately (one-line change)
2. **SSRF** — Add private IP blocking to URL endpoint
3. **Firewall** — Enable UFW, remove CUPS
4. **SSH** — Disable password auth
5. **Dockerfile** — Add non-root user
6. **Signup rate limit** — Prevent free tier abuse
7. **Usage persistence** — Store to disk
8. **PDF concurrency limit** — Prevent DoS