This document describes the security controls in place for ReplyIQ and the reasoning behind each decision.
Every database table that stores user-owned data implements BelongsToTenant (see app/Models/Concerns/BelongsToTenant.php). This trait:
- Applies a global Eloquent scope (
TenantScope) that addsWHERE organization_id = ?to every query when a current organization is bound. - Automatically sets
organization_idon everycreate()call, so developers cannot accidentally insert a cross-tenant row.
Route model binding returns 404 for cross-tenant resources (not 403) to avoid leaking resource existence to attackers.
Tests: tests/Feature/Security/TenantIsolationTest.php covers every major resource type.
The dashboard API uses Laravel Sanctum PATs (Bearer tokens). Tokens are stored as bcrypt hashes in the database. The Authorization: Bearer {token} header is required — browsers cannot set custom headers in cross-site requests, so CSRF is not applicable to token-authenticated routes.
- Login / register / forgot-password: 5 req/min per IP (
throttle:auth). - Widget send-message: 30 msg/min per conversation + 1000 msg/min per chatbot (
throttle:widget-send). - Public widget endpoints: 60 req/min per IP (
throttle:widget).
- Minimum 12 characters (enforced server-side).
- HaveIBeenPwned k-anonymity check on registration: the first 5 characters of the SHA-1 hash are sent to
api.pwnedpasswords.com/range/{prefix}. The full hash never leaves the server. Fails open — a network error does not block registration.
- Login: always returns
"The provided credentials are incorrect."regardless of whether the email exists. - Forgot password: always returns the same generic message.
- Registration: duplicate email returns a generic error that does not state "email already taken".
Widget sessions use short-lived JWT tokens scoped to a single (chatbot, conversation, visitor) triple. The token is signed with the chatbot's widget_secret (per-tenant HMAC key). No cookies are involved.
For the /api/v1/public/* endpoints:
widget:configmode — origin check only (read-only + start conversation).widget:tokenmode — Bearer JWT (mutating operations).widgetmode — HMAC per-request (broadcasting auth only).
See docs/architecture/decisions/0004-widget-auth.md.
The PromptBuilder wraps every user query in <<<USER>>> / <<<END>>> delimiters and the system prompt includes explicit rules:
- User input is never treated as instructions.
- The model must never reveal the system prompt.
- Jailbreak patterns ("ignore previous instructions", "DAN", etc.) are explicitly called out.
- The model must stay in character at all times.
Tests: tests/Feature/Security/PromptInjectionTest.php runs 10 canonical adversarial prompts and verifies structural guardrails.
Uploads are restricted by:
- MIME allowlist:
mimes:pdf,doc,docx,txt— validates actual file content, not just extension. - Size limit: 25 MB maximum.
- Storage path: Laravel's
store()generates a UUID-based path; the original filename never influences the storage path, preventing path traversal attacks. - No execution: uploaded files are processed by text-extraction libraries (pdfparser, phpword). They are never served directly from the application server — they go to S3/Supabase Storage.
Tests: tests/Feature/Security/FileUploadSecurityTest.php.
The API is JSON-only — the server never renders HTML from user input. XSS mitigations are at the client layer:
- Dashboard (React): all string interpolation uses JSX which escapes by default. No
dangerouslySetInnerHTMLusage except forMessageBubblein the widget (see below). - Widget (React + DOMPurify): assistant message markdown is parsed by
markedand then sanitised byDOMPurifybefore being injected viadangerouslySetInnerHTML. - HTTP headers:
X-Content-Type-Options: nosniffprevents MIME-type confusion attacks.X-Frame-Options: DENYprevents clickjacking.
Tests: tests/Feature/Security/XSSAuditTest.php — verifies that XSS payloads are stored verbatim as JSON strings and that all responses carry Content-Type: application/json.
The dashboard SPA uses Bearer token authentication (not cookies). Token-authenticated requests are inherently CSRF-safe because browsers cannot set the Authorization header in cross-site requests.
If Sanctum cookie-based authentication is enabled in future, the Sanctum CSRF cookie (/sanctum/csrf-cookie) must be fetched before any state-mutating request, and SANCTUM_STATEFUL_DOMAINS must list only trusted frontend origins.
config/cors.php (managed by fruitcake/laravel-cors):
allowed_origins: restricted toFRONTEND_URLfor all routes.supports_credentials: truefor the dashboard API.- The public widget endpoints (
/api/v1/public/*) use Bearer tokens not cookies, soAccess-Control-Allow-Origin: *is safe for those paths. The widget's fetch calls do not setcredentials: 'include'.
Stripe webhooks are processed by App\Http\Controllers\Api\V1\Webhooks\StripeWebhookController which extends Cashier's WebhookController. Cashier automatically validates the Stripe-Signature header using the STRIPE_WEBHOOK_SECRET env variable before processing any event.
Requests without a valid signature are rejected with 400.
Tests: tests/Feature/Security/WebhookSecurityTest.php.
Applied globally via SecurityHeaders middleware:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
X-XSS-Protection |
1; mode=block |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
X-Powered-By and Server headers are removed to reduce fingerprinting.
Programmatic API keys follow the rk_live_{32-char-random} format. The server stores a SHA-256 hash of the key, never the plain-text key. The plain key is shown once at creation time and cannot be retrieved again.
last_used_at is updated at most once per hour to prevent a DB write on every request.
SESSION_ENCRYPT=true— session data stored in Redis is encrypted.SESSION_LIFETIME=120— sessions expire after 120 minutes of inactivity (configurable).BCRYPT_ROUNDS=12— password hashing cost factor.
| Issue | Status | Notes |
|---|---|---|
| CSP headers | Deferred to M5.2 (Cloudflare config) | Requires CDN-level configuration |
| Annual billing proration | Handled by Stripe portal | Not implemented in code |
| Multi-org SSO | Enterprise tier (Phase 5+) | Not in scope |
| WAF rules | Deferred to Cloudflare config | Next milestone |
| pnpm moderate CVEs | Not fixed | All are dev-only (Vite dev server, PostCSS build tool). Not exploitable in production builds. See pnpm audit output. |
Three moderate vulnerabilities found, all in dev toolchain:
| Package | CVE | Severity | Exploitable in prod? |
|---|---|---|---|
vite@5.4.x |
GHSA-4w7w-66w2-5vf9 | Moderate | No — dev server only |
vite@5.4.x |
Path traversal in optimized deps | Moderate | No — dev server only |
postcss@8.4.31 |
GHSA-qx2v-qp2m-jg93 | Moderate | No — build tool only |
No high or critical vulnerabilities. The postcss issue is transitive via next@16.2.6 and cannot be overridden without breaking the Next.js build.
Three Symfony CVEs patched in M5.1 by running composer update symfony/http-foundation symfony/polyfill-intl-idn symfony/routing:
| CVE | Package | Fixed |
|---|---|---|
| CVE-2026-48736 | symfony/http-foundation | ✅ Updated |
| CVE-2026-46644 | symfony/polyfill-intl-idn | ✅ Updated |
| CVE-2026-48784 | symfony/routing | ✅ Updated |