Skip to content

GuestCraft deployment threat model

Status: adopted from the independent audit of main at 43cfecd9, 28 October 2026; corrected after the verifications of PR #66 (account lockout storage, the Docker default, the API reference script; then the order of the sign-in limits, the lockout key, the audit of locks and the operating requirements). Update this model when authentication, payment handling, Channex ownership, hosting or the public API changes. This model describes an operator-owned deployment; a guest-facing portal and direct card processing are not implemented.

  • Anonymous internet user: can load public pages, call guarded auth routes, send malformed API requests and guess a setup code while an instance is empty.
  • Guest/data subject: their name, contact details, stays, notes and possibly identity-related information are stored by the PMS or arrive from an OTA through Channex. They do not currently have a GuestCraft account flow of their own.
  • Staff member: an authenticated user with built-in or custom workspace grants and possibly a selected set of properties. Their session and organization selection determine the current tenant context.
  • Workspace administrator: can configure properties, channels, users and roles within an organization, subject to the live grant ceiling. Organization owner/admin: can administer organization membership and inherits access to its workspaces.
  • Channex and OTA: external sources of booking and rate data, including untrusted guest-facing strings; Channex receives API-key requests and sends webhook signals. Channex or an OTA can be unavailable or send malformed content without becoming an authority over GuestCraft roles.
  • Hosting/database provider: controls the reverse proxy, TLS, environment, PostgreSQL availability, backups and deployment logs. This is a privileged operational trust boundary, not a GuestCraft user role.
  • Malicious contributor or compromised dependency: can alter source, migration, workflow or build output unless repository review, pinned actions, lockfile policies and CI prevent promotion.

Primary assets are guest personal data and stay history; property inventory and prices; manually recorded financial movements (the app does not process card numbers); Channex API keys and mapping state; session cookies, invitation bearer links, setup code and encryption/auth/cron secrets; role grants and audit records; and the deployed image and migration journal. Confidentiality and integrity matter across organization, workspace and property scopes. Availability matters because failed booking intake or inventory synchronization affects operations.

  1. Browser to Next.js/BFF: page navigation, server actions, /api/auth/*, /api/v1/*, cookies and workspace headers. A request body, URL, Origin, forwarded-IP header, X-Forwarded-Proto and Request-Id are attacker controlled until validated. The BFF and API must enforce authentication, CSRF, input limits and output encoding independently of UI visibility. Every page, including the API reference at /api/v1/docs, loads its scripts from the instance itself: Scalar’s bundle is served by the web app at a versioned path, pinned by the lockfile and by its integrity hash, so a reader’s browser contacts no third-party host and the reference does not depend on a CDN’s availability. Before this correction the reference loaded the bundle from jsDelivr, which saw each reader’s address and could withhold the file.
  2. External sender to webhook: POST /api/channex/signal/:org/:ws is reached without a user session, verifies a per-connection secret, and treats its body as routing metadata only. A signal asks a worker to fetch authoritative data; it must never directly establish a booking or tenant scope.
  3. Scheduler to cron endpoint: GET /api/internal/channex/drain and GET /api/internal/domains/check (ADR 0055’s domain checks) accept an independent bearer secret. They wake durable jobs or run the due DNS checks, and must be inaccessible to cookies alone. Their bearer secret must not enter URLs, logs or caches.
  4. App/worker to PostgreSQL: app and worker use derived least-privilege roles; migrations use an owner connection. Unit-of-work transactions set organization, workspace and property context locally; RLS, composite keys and grants are the final isolation layer. A migration is privileged code and must be reviewed as such.
  5. App/worker to Channex, then OTA: the application sends an encrypted-at-rest workspace credential only to the fixed Channex host. Channex responses and OTA strings are untrusted data. Redirects and user-controlled destination URLs must not turn the integration into SSRF or key disclosure.
  6. Repository to CI to deployment: commits, lockfile, action SHAs, install scripts, image layers, build-time migrations and deploy secrets cross an operator-controlled supply-chain boundary. A successful build does not mean the public instance has a safe proxy, headers or current migrations.
Threat Plausible attack Existing control and remaining work
Spoofing Forged session, setup or invitation token; forged webhook or cron request; forged client IP to evade auth limits; forged X-Forwarded-Proto. Signed host-only session cookies, 80-bit one-time setup code, hashed invitation token, webhook secret and cron bearer protect identity. Outside Vercel, the web refuses to start without GUESTCRAFT_CLIENT_IP_HEADERS and GUESTCRAFT_TRUSTED_PROXIES whenever the instance is reachable from other machines: GUESTCRAFT_ENV=production, or a GUESTCRAFT_BASE_URL that uses https: or a host other than the loopback, whatever GUESTCRAFT_ENV says. The published Compose file still defaults to development on http://localhost:3000 with a loopback-only port, a local trial in which Better Auth reads x-forwarded-for as sent; the check above stops that default from being exposed under a public address. Vercel uses its platform-set client IP header. The account lockout is keyed by account, not address, so rotated addresses do not help; its key is a random value stored in the database, so rotating AUTH_SECRET keeps every lock. HSTS follows the configured HTTPS origin, never a request’s X-Forwarded-Proto. Operators must still make the proxy overwrite forwarding headers and the only ingress: Better Auth interprets the forwarded chain but cannot check the TCP peer against the trusted proxy list.
Tampering Cross-origin user action; mass assignment; unauthorized rate/inventory edits; migration or job payload manipulation. Strict request schemas, role checks, transactional writes, checksums and strict job schemas exist. The common origin gate for unsafe cookie-authenticated v1 requests landed in PR #55; new write paths must keep its coverage.
Repudiation Staff denies role, booking, folio or channel changes. Audit entries are written with business changes and include actor/scope; request IDs support correlation. A forwarded IP remains a weak attribution signal even with a trusted proxy, so an IP audit field alone cannot prove origin.
Information disclosure Cross-tenant IDOR, cached guest data, API-key leak, XSS, overbroad export or logs; whether an address has an account. Live workspace/organization probes, RLS, no-store responses, encrypted Channex keys and React text rendering held in sampled cases. A nonce CSP and route-wide response headers constrain browser behavior; deployment logs and backups remain privileged. Sign-in answers the same status for known and unknown addresses, and Better Auth hashes a dummy password for an unknown one. One timing difference remains: the fifth failure of an existing account also writes its team.sign_in_locked entries. A single set-based statement writes one row per affected workspace, so its duration depends on workspace count and database load; no fixed millisecond bound is claimed. Measuring it costs five attempts per address and a full lock of the account, which its managers see in the audit log. If the audit write fails, the server logs a sanitized error, returns the usual failed-password answer and keeps the account attempt reserved; the response does not disclose whether the address has a membership.
Denial of service Brute-force sign-in, oversized body, job replay, Channex outage or expensive report; lockout of a known staff account; growth of the lockout table. Body limits, job budgets, retry policies and bounded report dates exist. Sign-in has two database-backed limits shared by every runtime, in this order. First, Better Auth’s per-client rule: 3 sign-in requests per 10 seconds per client address, answered with a finite Retry-After; a refused request goes no further. Then, only for an admitted request, GuestCraft’s account lockout reserves an attempt before the password is checked: five per account in a fixed 15-minute window, then 429 with Retry-After for the rest of the window, the same answer whether or not the account exists. It lives in its own table (guestcraft_auth.sign_in_lockouts, keyed by an HMAC of the folded address under a random key that migration 0020 stores in guestcraft_auth.sign_in_lockout_key, readable by the app role only), whose rows go only when their own window has ended, on a successful sign-in, or by an unlock; Better Auth’s cleanup of per-client buckets cannot touch it. Bound per client address: each admitted sign-in writes at most one lockout row and deletes at most 100 expired account rows and at most 100 expired client buckets, so one address adds at most 3 rows per 10 seconds and holds at most 270 live account rows (one 15-minute window); its own per-client bucket is one row of Better Auth’s table. Expired rows are removed lazily on later admitted requests. The cleanup drains expired rows faster than reservations add them. Rows grow only with the number of distinct client addresses an attacker controls, which is why the edge must overwrite the forwarding header and be the only ingress. Without a trusted client address, every client shares one per-client bucket. Residual risk: whoever knows a staff address can keep that account locked, 15 minutes at a time. Each lock is audited as team.sign_in_locked (when, and locked_until; no e-mail or client address) in every workspace whose team lists the person, so managers can see a targeted lock-out. Recovery: the lock ends on its own, and a team manager can lift it at once from Users and roles (DELETE /api/v1/members/{member_id}/sign_in_lock, audited as team.sign_in_unlocked); an organization owner’s or administrator’s lock is lifted only by an organization owner or administrator. A manager may lift their own lock from a session opened before it: that session is the legitimate user’s, and the unlock is audited. If every administrator is locked out, each lock still ends within 15 minutes of its first failed attempt, with no database edit. There are no progressive delays and no e-mail alerts. The application clock decides when a window ends: instance clocks must be synchronized, or an instance far ahead could end other instances’ locks early. A distributed attacker can still spread guesses across many accounts. Channex/OTA availability and database/provider outages remain operational risks.
Elevation of privilege Staff widens their own grants, selects another workspace, or uses worker/system context. Live permission resolution, property-scoped grants, transaction-local RLS context and separate worker role exist; two-workspace/two-organization probes denied cross-scope reads. Review new system-scope call sites and migrations before merge.

For every new write route, verify a real browser cannot send it from an untrusted origin with the user’s cookie; verify a denied role and foreign organization/workspace/property leave data and audit unchanged; test malformed and oversized input; inspect logs, response bodies and job payloads for secrets or guest data. For every page displaying external text, render an HTML payload in the production build and check DOM nodes, while retaining a dashboard CSP. For new external fetches, prove destination allowlisting and redirect refusal. Run a built-app and real-PostgreSQL check after migration changes. Recheck the proxy’s forwarded-header behavior and deployment response headers on each supported hosting path.