# Security in SiteCraft

How the platform protects merchants, shoppers and itself, and what you must do when you run it. Design details are in `ARCHITECTURE.md`; each control below has automated tests (see `TESTING.md`).

## Tenant isolation

- Every tenant-owned table has `tenant_id`. Tenant-scoped models add `WHERE tenant_id = <current tenant>` to every query and stamp it on every insert; the tenant comes from the signed-in user, **never** from the request.
- Controllers load records through the current tenant (`404` when the ID belongs to someone else) — the IDs in URLs are never trusted on their own.
- **Proof:** `tests/smoke/isolation_sweep.py` signs in as one merchant and calls every dashboard/builder route (GET and POST, with a valid CSRF token) using another merchant's IDs. Every GET must return 403/404, and a hash of all the other merchant's rows must be identical afterwards. It also checks every admin route returns 403 for merchants and every private page redirects signed-out visitors.
- Storefronts resolve the website from the `Host` header (subdomain or a custom domain whose DNS ownership was verified) and read only that website's published data.

## Accounts and sessions

- Passwords: bcrypt via `password_hash`, minimum 10 characters, rehashed when the algorithm improves. The first admin must change the seeded password.
- Brute force: 5 failures lock an account for 15 minutes; 20 failures per IP in 15 minutes are throttled; every failure is logged as `SECURITY failed-login ip=…` for fail2ban (`deploy/fail2ban`). Sign-up, password reset and other sensitive forms are rate limited.
- Sessions: HttpOnly, SameSite=Lax, Secure in production cookies; the session ID is regenerated at sign-in (no fixation); sign-out destroys it. Password reset, invitation and email verification tokens are random, stored only as hashes, single-use and short-lived.
- Roles and permissions (RBAC): every route is guarded by a permission filter; super-admin routes need platform permissions that merchant roles can't have.
- Shopper accounts are separate from platform users (own table, own cookie, scoped to one store); their order history appears only after the email address is confirmed.

## Web attacks

- **CSRF:** every state-changing platform form carries a token (session-bound). Excluded only: payment callbacks and webhooks, which are verified by signature instead. Storefront forms use an HMAC token tied to the website, plus a same-origin check and a honeypot.
- **XSS:** all views escape output (`esc()`). Merchant-authored HTML (website designs, product descriptions, blog posts, custom code blocks) goes through `HtmlSanitizer` (allow-list of tags and attributes; no scripts, event handlers, `javascript:` URLs or foreign iframes) when it is saved or published; product and blog text is sanitised again when shown. On top of that, storefronts can't run scripts at all (CSP below).
- **Content-Security-Policy:**
  - Storefronts: `script-src 'none'` — no JavaScript at all (the payment page alone allows Razorpay Checkout; Google Analytics, if the merchant adds it, is allowed only on public cacheable pages).
  - Dashboard/admin: scripts only from the platform itself (and Razorpay Checkout); there are **no inline scripts or event handlers** anywhere. Checked in a real browser by `tests/e2e/ux.e2e.js`.
  - Also sent: `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS over HTTPS. `X-Powered-By` is removed.
- **SQL injection:** the query builder with bound parameters everywhere; the few raw fragments use integers or `escape()`.
- **Open redirects:** post-login and account redirects only accept same-site paths; merchant redirects can't target `javascript:` or protocol-relative URLs.
- **SSRF:** the platform makes outbound requests only to Razorpay and DNS lookups for domain verification.

## Files

- Uploads are images only (JPEG, PNG, WebP, GIF — checked by content, not name), size-limited, and **re-encoded** with GD, which drops metadata and any hidden payload. Stored under random names in `public/media/<tenant-uuid>/`. SVG is refused.
- `public/media` never executes or serves scripts (bundled `.htaccess`; equivalent rules in the Caddy and Nginx configs). Deleting a file is confined to that folder.
- The web root is `public/`; `.env`, `writable/` and code are outside it.

## Payments

- Two separate Razorpay setups: the platform's (plans) and each merchant's (their store). Separate keys, webhook URLs, secrets and tables; one system's webhook secret is rejected by the other.
- Amounts always come from the server's records. A payment counts only after the **signature** is verified **and** the payment is fetched back from Razorpay and its order, amount and currency match. Webhooks are verified with HMAC over the raw body and de-duplicated by event ID; completion is idempotent.
- Merchants' Razorpay secrets are encrypted at rest with the app's encryption key and never shown again.

## Custom domains

- A domain serves a store only after a TXT record proves ownership; routing is checked separately. Certificates are issued only for hosts the platform confirms (`/__sc/tls-check`).

## Privacy

- Storefronts set no cookies except the shopper's cart (and sign-in, if they create an account). Built-in analytics store no IP addresses or cookies (a daily-rotating hash, deleted after two days). Logs never contain passwords or card data (cards never touch the platform — Razorpay handles them).

## Operator checklist

- [ ] `CI_ENVIRONMENT = production`, HTTPS everywhere, `cookie.secure = true`
- [ ] Strong unique database password; MySQL bound to 127.0.0.1
- [ ] `encryption.key` generated once and stored safely offline
- [ ] Seeded admin password changed; `ADMIN_PASSWORD` removed from `.env`; no DemoSeeder data
- [ ] `.env` owned by root, group www-data, mode 640
- [ ] Firewall: only 22, 80, 443 open; SSH keys only; unattended upgrades on
- [ ] fail2ban jail `sitecraft` active
- [ ] Nightly backups copied off the server; a restore tested
- [ ] `php spark sitecraft:doctor` shows no problems
- [ ] Uptime monitor on `/health`

## Reporting a vulnerability

Email the platform operator's security contact (set it in *Admin → Settings → Support email*). Please include steps to reproduce; don't access other people's data beyond what's needed to demonstrate the issue.
