Skip to content

Security: Iya15/ReplyIQ

Security

docs/security.md

Security Model — ReplyIQ

This document describes the security controls in place for ReplyIQ and the reasoning behind each decision.


1. Tenant Isolation

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 adds WHERE organization_id = ? to every query when a current organization is bound.
  • Automatically sets organization_id on every create() 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.


2. Authentication

Sanctum Token Auth

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.

Rate Limiting

  • 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).

Password Policy

  • 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.

Email Enumeration Prevention

  • 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".

3. Widget Auth

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:config mode — origin check only (read-only + start conversation).
  • widget:token mode — Bearer JWT (mutating operations).
  • widget mode — HMAC per-request (broadcasting auth only).

See docs/architecture/decisions/0004-widget-auth.md.


4. Prompt Injection Defence

The PromptBuilder wraps every user query in <<<USER>>> / <<<END>>> delimiters and the system prompt includes explicit rules:

  1. User input is never treated as instructions.
  2. The model must never reveal the system prompt.
  3. Jailbreak patterns ("ignore previous instructions", "DAN", etc.) are explicitly called out.
  4. The model must stay in character at all times.

Tests: tests/Feature/Security/PromptInjectionTest.php runs 10 canonical adversarial prompts and verifies structural guardrails.


5. File Upload Security

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.


6. XSS

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 dangerouslySetInnerHTML usage except for MessageBubble in the widget (see below).
  • Widget (React + DOMPurify): assistant message markdown is parsed by marked and then sanitised by DOMPurify before being injected via dangerouslySetInnerHTML.
  • HTTP headers: X-Content-Type-Options: nosniff prevents MIME-type confusion attacks. X-Frame-Options: DENY prevents 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.


7. CSRF

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.


8. CORS

config/cors.php (managed by fruitcake/laravel-cors):

  • allowed_origins: restricted to FRONTEND_URL for all routes.
  • supports_credentials: true for the dashboard API.
  • The public widget endpoints (/api/v1/public/*) use Bearer tokens not cookies, so Access-Control-Allow-Origin: * is safe for those paths. The widget's fetch calls do not set credentials: 'include'.

9. Stripe Webhook Security

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.


10. HTTP Security Headers

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.


11. API Key Security

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.


12. Session Security

  • 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.

13. Known Issues / Deferred

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.

14. Frontend CVE Audit (pnpm audit)

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.


15. PHP Dependency Audit (composer audit)

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

There aren't any published security advisories