# 🔒 Security Audit — SneakerPicks.nl
**Date:** 2026-02-20  
**Auditor:** Claude (automated)  
**Scope:** Full-stack security review

---

## 🔴 CRITICAL

### 1. Admin password stored as plaintext in .env — compared as plaintext
**File:** `src/pages/api/admin/auth/login.ts` (line 15-18)  
**Issue:** The login endpoint compares `ADMIN_PASSWORD` as raw plaintext (`password === envPassword`). The password `SP-Adm1n.R03l-2026x` sits in `.env` in cleartext and is compared directly — no hashing.  
**Fix:** Hash the admin password with bcrypt and store only the hash in `.env`. Use `comparePassword()` (which already exists in auth.ts) for the env-based fallback too.

### 2. PostgreSQL port 5432 exposed to the internet
**Evidence:** `ss -tlnp` shows `0.0.0.0:5432` (docker-proxy). UFW firewall is **inactive**.  
**Impact:** Anyone on the internet can attempt to connect to the database directly with the credentials from the .env file.  
**Fix:** Either bind Postgres to `127.0.0.1` only in docker-compose, or enable UFW and only allow ports 22, 80, 443.

### 3. Firewall (UFW) is completely disabled
**Evidence:** `ufw status` → `Status: inactive`  
**Impact:** All Docker-proxied ports are exposed: 5432 (Postgres), 8000 (Kong), 6543 (PgBouncer), 9000/9001 (Supabase internal), 4000, 8443, 32769, plus Node on 3001/3002.  
**Fix:** `ufw allow 22,80,443/tcp && ufw enable`. Deny everything else.

### 4. PM2 process dump leaks all secrets
**Evidence:** `pm2 jlist` output contains `JWT_SECRET`, `DATABASE_URL` (with password) in plaintext JSON. The staging PM2 config explicitly embeds env vars.  
**Fix:** Use `--env-file=.env` (like production does) instead of inline env vars for staging. Restrict access to `.pm2/dump.pm2`.

---

## 🟠 HIGH

### 5. All processes run as root
**Evidence:** PM2 `user` column shows `root` for both sneakerpicks and sneakerpicks-staging.  
**Fix:** Create a dedicated `sneakerpicks` user, run PM2 as that user. `chown -R sneakerpicks:sneakerpicks /var/www/sneakerpicks*`.

### 6. .env file is world-readable (0666)
**Evidence:** `-rw-rw-rw- 1 root root` on `/var/www/sneakerpicks/.env`  
**Fix:** `chmod 600 /var/www/sneakerpicks/.env`

### 7. JWT fallback secret hardcoded in source
**File:** `src/lib/auth.ts` line 4  
**Code:** `const JWT_SECRET = import.meta.env.JWT_SECRET || process.env.JWT_SECRET || 'sneakerpicks-dev-secret';`  
**Impact:** If env var is unset, a predictable secret is used. Anyone could forge admin JWTs.  
**Fix:** Throw an error if `JWT_SECRET` is not set instead of falling back to a default.

### 8. Admin pages not server-side protected
**File:** `src/middleware.ts` only protects `/api/admin/*` routes. Admin page routes (`/admin/*`) like `index.astro`, `feeds.astro`, `products.astro` are not guarded server-side.  
**Impact:** Admin HTML/JS is served to unauthenticated users. While API calls will fail, it leaks admin UI structure and could assist targeted attacks.  
**Fix:** Add `/admin/` (excluding `/admin/login`) to the middleware check, redirecting to `/admin/login`.

### 9. No rate limiting on login endpoint
**File:** `src/pages/api/admin/auth/login.ts`  
**Impact:** Brute-force attacks on admin login are unrestricted.  
**Fix:** Add rate limiting (e.g., 5 attempts per IP per minute). Consider using a middleware or Caddy's `rate_limit` directive.

### 10. Caddy has no security headers configured
**Evidence:** Caddyfile only has `reverse_proxy` and `log` directives. No CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy.  
**Fix:** Add a `header` block:
```
header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Frame-Options "DENY"
    X-Content-Type-Options "nosniff"
    Referrer-Policy "strict-origin-when-cross-origin"
    Permissions-Policy "camera=(), microphone=(), geolocation=()"
}
```

---

## 🟡 MEDIUM

### 11. Node apps listen on 0.0.0.0 — directly accessible bypassing Caddy
**Evidence:** Ports 3001 and 3002 are bound to `0.0.0.0` and exposed to the internet (no firewall).  
**Impact:** Attackers can bypass Caddy and access the Node app directly, skipping any future Caddy-level security (headers, rate limiting, WAF).  
**Fix:** Bind to `127.0.0.1` or `172.18.0.1` only, or block with firewall.

### 12. Staging environment accessible at staging.sneakerpicks.nl
**Evidence:** Caddyfile routes `staging.sneakerpicks.nl` to port 3002 with no auth.  
**Impact:** Staging may have debug features, test data, or weaker security.  
**Fix:** Add basic auth to the staging subdomain in Caddy, or restrict by IP.

### 13. Click tracking endpoint has no rate limiting
**File:** `src/pages/api/track/click.ts`  
**Impact:** Could be abused for click fraud or DB flooding.  
**Fix:** Add rate limiting per IP/session.

### 14. Public API endpoints lack pagination limits
**File:** `src/pages/api/categories/[slug]/products.ts`  
**Code:** `limit` is parsed from query params with no max cap.  
**Impact:** `?limit=999999` could cause expensive DB queries / OOM.  
**Fix:** Cap limit to a reasonable maximum (e.g., 100).

### 15. Error responses may leak internal details
**Multiple files** return `err.message` in JSON responses (e.g., feeds/index.ts, categories.ts).  
**Impact:** Database errors, stack traces could leak to attackers.  
**Fix:** Log errors server-side, return generic error messages to clients.

### 16. Session cookie (sp_sid) lacks Secure and HttpOnly flags
**File:** `src/pages/api/track/click.ts` line ~55  
**Code:** `sp_sid=${sessionId}; Path=/; Max-Age=...; SameSite=Lax` — missing `Secure` and `HttpOnly`.  
**Fix:** Add `Secure; HttpOnly` to the cookie.

---

## 🟢 LOW

### 17. npm audit: 5 moderate vulnerabilities (lodash prototype pollution)
**Impact:** In dev dependency chain only (`@astrojs/check` → `lodash`). Not in production runtime.  
**Fix:** `npm audit fix --force` when convenient, or ignore (dev-only).

### 18. JWT expiry is 7 days — no refresh/revocation mechanism
**File:** `src/lib/auth.ts` — `expiresIn: '7d'`  
**Impact:** Stolen tokens remain valid for a week. No way to revoke.  
**Fix:** Reduce to 1-2 hours with refresh token rotation, or implement a token blacklist.

### 19. .env.example missing ADMIN_EMAIL, ADMIN_PASSWORD, AWIN_API_TOKEN
**Impact:** New developers may not know all required env vars.  
**Fix:** Add all env var keys (with placeholder values) to `.env.example`.

### 20. No Content Security Policy
**Impact:** XSS attacks could load arbitrary scripts.  
**Fix:** Add CSP header via Caddy (see #10).

---

## ✅ What's Good

- ✅ `.env` is properly gitignored (`.env*` pattern with `!.env.example` exception)
- ✅ All DB queries use parameterized queries (`$1, $2...`) — no SQL injection risk
- ✅ Admin cookie has `HttpOnly; Secure; SameSite=Strict` flags
- ✅ Password hashing uses bcrypt with cost 10 (for DB-stored users)
- ✅ JWT implementation uses standard `jsonwebtoken` library
- ✅ HTTPS via Caddy with automatic cert management
- ✅ Middleware properly protects all `/api/admin/*` routes (except login)

---

## 📋 Priority Action Plan

| Priority | Action | Effort |
|----------|--------|--------|
| 1 | Enable UFW firewall (allow 22,80,443 only) | 5 min |
| 2 | Fix .env permissions to 600 | 1 min |
| 3 | Hash admin password in .env, fix plaintext comparison | 15 min |
| 4 | Remove JWT fallback secret, throw on missing | 5 min |
| 5 | Add security headers to Caddyfile | 10 min |
| 6 | Create non-root user for PM2 processes | 20 min |
| 7 | Bind Node to 127.0.0.1 instead of 0.0.0.0 | 5 min |
| 8 | Add admin page protection to middleware | 10 min |
| 9 | Add rate limiting to login + click endpoints | 30 min |
| 10 | Fix staging PM2 config to use --env-file | 10 min |
