Skip to content

Security ​

Updated every phase. Last update: Phase 8 (security pass before Envato submission, D-127).

Authentication ​

  • Laravel Fortify: email + password with rate-limited login (5 attempts per minute per email and IP), email verification, password reset, password confirmation for sensitive pages.
  • Optional TOTP two-factor authentication with recovery codes, and passkeys (WebAuthn).
  • Rate limits: login 5 per minute per email and IP; two-factor 5 per minute; sign-up and "forgot password" 5 per minute per IP; password reset and password confirmation 10 per minute per IP. Too many attempts show a normal form error with the wait time.
  • Sessions are stored in the database. The session cookie is Secure automatically when APP_URL starts with https:// (override with SESSION_SECURE_COOKIE).

HTTP headers ​

Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin, a Permissions-Policy that turns off camera, microphone, geolocation, payment and USB, and a Content-Security-Policy limited to base-uri 'self'; object-src 'none'; frame-ancestors 'self'. Over HTTPS, Strict-Transport-Security: max-age=15552000 is added (this host only, no preload; DESORIX_HSTS=false turns it off). A full script CSP isn't sent: Inertia, Vite and module pages would need nonces that shared-hosting setups can't be relied on to keep.

Authorization ​

  • Workspaces are the tenant boundary. Workspace routes carry the workspace slug and pass through EnsureWorkspaceMembership, which rejects anyone who isn't a member (403).
  • Roles (owner, admin, agent, viewer) map to explicit permissions in App\Enums\WorkspaceRole. Policies check permissions, not role names.
  • Non-owners can only manage members ranked below them and can only assign roles below their own.
  • Platform admins are a separate flag (users.is_super_admin). It isn't mass-assignable and can only be changed from Admin → Users or the desorix:admin command. The /admin routes are guarded by EnsureSuperAdmin.
  • Workspace names that would produce a slug matching an application route (admin, help, settings, webhooks, …) are rejected.

WhatsApp webhooks ​

  • Every connected account has its own unguessable Callback URL (/webhooks/whatsapp/{uuid}).
  • Deliveries must carry a valid X-Hub-Signature-256 HMAC made with that account's App Secret. Anything else gets 401, and the account page shows a warning. Signatures are compared in constant time.
  • A signed payload can only affect phone numbers and messages that belong to the same account, so one account can't write into another workspace.
  • Raw payloads are kept for 30 days for troubleshooting, then deleted.
  • Genuine webhooks are never rate limited: a large broadcast produces bursts of status updates, and Meta backs off when refused. Instead, an IP address whose requests are rejected (bad signature, wrong verify token, unknown URL) 30 times in a minute gets 429 for the rest of that minute. The same applies to module webhooks (WooCommerce).

Website widget ​

  • Its endpoints are public and stateless: no session, cookie or CSRF. The widget key only reads the widget's display settings and records clicks. It can't read conversations or contacts.
  • Settings are served 120 times per minute per IP, clicks 20 per minute per IP. Unknown keys get "not enabled", never an error that confirms whether a key exists.
  • Clicks store the page without its query string, the referrer's host only, and UTM parameters. No IP address and no cookie. Allowed websites limits which sites may show the button.
  • The "Chats by source" CSV neutralises values that start with =, +, - or @, so a crafted UTM value can't run as a spreadsheet formula.

Tenant isolation ​

  • Workspace routes resolve every record through the workspace (scoped route bindings). A record from another workspace returns 404 even when its ID is known.
  • Tenant models also carry a global workspace scope while a workspace is active, as a second line of defence.
  • Received media is stored on the private disk and served only to members of the owning workspace who can use that conversation's number (D-061).
  • Media is shown in the browser only for formats that can't run code (JPEG, PNG, WebP, GIF, MP4, 3GPP and audio). Everything else, including SVG or HTML sent as a "document", is downloaded as application/octet-stream. Every media response carries Content-Security-Policy: sandbox.

Inbox ​

  • Every inbox request checks the member's role and number access: the list, each conversation page, each action, the people offered for assignment, and the websocket channel for each number.
  • Websocket events carry only a conversation ID. Message content always comes through normal, permission-checked page requests.
  • Viewers are read-only. They can't reply, add notes, assign, tag or trigger read receipts.
  • Uploaded attachments are checked against WhatsApp's accepted file types and size limits, and stored privately.

Contacts and broadcasts ​

  • Consent changes are logged in an append-only table: who, when, source and note. Broadcasts skip opted-out contacts at preparation and again just before each send.
  • GDPR erasure removes personal data everywhere it is stored (contact, messages, media files, notes, WhatsApp user IDs, consent log) and keeps only anonymous cost data.
  • Sending a broadcast needs the confirmed recipient count to match a fresh server-side calculation, so a stale or forged request can't send to a different audience.
  • CSV uploads are stored privately, parsed as data (never executed), and limited to 10 MB. Error reports can only be downloaded by members who can manage contacts.

Automation and chatbot flows ​

  • Only owners and admins (automation:manage) can edit flows, rules and business hours. Agents can start, stop and resume the bot only in conversations they can reply to.
  • Webhook steps call only public addresses: the host must resolve to public IPs (no loopback, private, link-local or reserved ranges), redirects aren't followed, and requests time out after 10 seconds. DESORIX_ALLOW_PRIVATE_WEBHOOKS=true lifts the address check for installs that must call services on their own network. Known limit: a host that re-resolves differently between the check and the request (DNS rebinding) isn't fully prevented.
  • Webhook-step headers marked Secret (the default for new headers) are encrypted with the app key, never sent back to the browser, filled into the request only at run time, and never written to run logs (D-126). Headers not marked secret are stored as plain text, and the builder warns about them. Changing APP_KEY makes stored secrets unreadable: re-enter them.
  • Test flow never sends WhatsApp messages or writes to contacts or conversations. Webhooks are called only when the tester ticks Call webhooks. Test sessions belong to the user who started them.
  • Chatbot answers are personal data: GDPR erasure deletes the contact's runs and step logs.
  • A graph is capped at 200 steps and 512 KB, and a run stops after 60 steps without waiting for the customer (loop protection).

AI ​

  • API keys use Laravel's encrypted cast. They are never sent to the browser (only sk-…1234) and are excluded from serialisation.
  • Only owners and admins (ai:manage) manage keys, the business description, documents, auto-reply and the spend limit; the platform keys are for platform admins only. Agents can request drafts only in conversations they can reply to.
  • The AI prompt is limited to the business description and documents, and hands off to a person otherwise. Customer messages are passed as conversation turns, not as instructions, and a reply is only sent when the model reports high confidence and no need for a person.
  • A monthly spend limit, a per-conversation reply limit and a burst guard stop runaway loops (for example, two bots answering each other).
  • Uploaded documents are stored privately, checked against their extension (PDF signature, zip for .docx, no binary in text files) and parsed as data. They're limited to 10 MB.
  • Provider calls time out (30 s for text, 60 s for embeddings) and never block webhook processing indefinitely.
  • A workspace's OpenAI-compatible base URL must be a public address. It is checked when saved and again before each call, so a tenant can't make the server call its own network. Platform keys (set by the administrator) may point anywhere.

Registration ​

Admins can close public registration. People with a pending, unexpired invitation to their email address can still sign up.

Secrets ​

  • WhatsApp access tokens and app secrets are stored with Laravel's encrypted cast using APP_KEY. Losing APP_KEY makes stored tokens unreadable. Back up .env. The installer generates the key once, and the updater never changes it.
  • No API keys are bundled. AI features are bring-your-own-key.
  • Secrets are never logged. Every log channel runs a redaction step that removes tokens in query strings (access_token=, input_token=, …), Bearer/OAuth values, Meta access tokens, app_id|app_secret pairs and sk-… API keys. An exception whose message contains one is logged by class and redacted message only. Stack traces never include argument values (zend.exception_ignore_args). Connection errors to Meta are redacted before they're shown or stored, because cURL errors include the full URL.

Content ​

  • In-app help is rendered from Markdown shipped with the app, with raw HTML stripped and unsafe links disabled.

Installer ​

  • The installer (/install) is reachable only until storage/app/installed.json exists; after that every installer URL redirects to the app (D-107). Install right after uploading the files: until then, whoever opens the domain first can run the installer.
  • It never deletes data. It refuses a database that already has Desorix tables, except to resume an install interrupted on that same database (the one already in .env, while the installer's in-progress flag exists) (D-108).
  • The administrator password must be 12+ characters with mixed case, a number and a symbol. It is hashed before it is kept in the installer session.
  • .env is written with values quoted and escaped. APP_KEY is generated once and never replaced.
  • When the document root is the application folder, the release's root .htaccess serves everything from public/, so .env, storage/ and the source can't be downloaded.

Releases, updates and licensing ​

  • Every release is signed with Ed25519 (D-111). release.json lists the sha256 of every file and release.sig signs it. The updater checks the signature against config('desorix.release_public_keys') and every file hash before changing anything, whether the zip was downloaded, uploaded or left in storage/app/updates. A zip with a changed, missing or extra file is refused. Paths with .. are refused before extraction.
  • The signing key never leaves the developer's machine. The license server only checks signatures, so a compromised license server can't push code to installs. Several public keys can be trusted at once, for key rotation.
  • Downloads are signed links valid for 10 minutes, issued only to active activations of valid licenses. The updater also checks the zip's sha256 against the update server's.
  • Maintenance during an update: everyone gets 503, sign-in included, except the updater's own pages (which still need a signed-in platform admin). The admin running the update gets Laravel's maintenance bypass cookie (excluded from cookie encryption because Laravel reads it before decryption).
  • The license check only gates one-click updates (D-109). An install sends only its purchase code, domain, random install ID and versions. The license server stores a sha256 of the code and its last 4 characters, and a hash of each activation token (D-110). Purchase codes and tokens are encrypted in the install's licenses table.
  • Updates, license and modules pages are platform-admin only.

Modules ​

  • Modules run with the same privileges as Desorix, so only install modules from sources you trust. Uploaded modules must be signed like releases.
  • Module routes inside the workspace group get the same membership checks as core pages. WooCommerce's pages need the new integrations:manage permission (owners and admins).
  • WooCommerce webhooks (/webhooks/woocommerce/{uuid}) must carry a valid X-WC-Webhook-Signature (base64 HMAC-SHA256 of the body with the store's secret, compared in constant time). Anything else gets 401, except WooCommerce's unsigned creation ping (webhook_id=N), which is only acknowledged. The secret is encrypted at rest. The order log keeps totals and countries, not names or phone numbers; the linked contact is covered by GDPR erasure.
  • Module stylesheets are scoped to their own pages, so a module can't restyle the core UI.

Reporting a vulnerability ​

Email the support address listed on the CodeCanyon item page with the subject "Security". Please don't post details in public comments.