Skip to content

Security design

GuestCraft stores guest personal data, reservations, payments and Channex credentials. This document records the secure-design principles the maintainers apply, the common vulnerability classes of a web application with their countermeasures, and every use of cryptography. Each line names the code or decision record where the mechanism lives, so it can be checked. Gaps are listed as gaps.

The principles are those of Saltzer and Schroeder, plus a limited attack surface and allowlist input validation.

Principle How GuestCraft applies it Where
Economy of mechanism One modular monolith, one PostgreSQL database, one HTTP API. Identity uses an established library (Better Auth) instead of custom code. ADR 0002, ADR 0017
Fail-safe defaults Public sign-up is closed: only the one-time setup, protected by a setup code, creates the first operator. The runtime refuses to start with a missing or short secret. Row-level security is forced, so a query without a workspace context returns no rows. setup.ts, settings.ts, packages/db/migrations
Complete mediation Every API route authenticates before it validates input, then checks the permission of the operation. PostgreSQL row-level security checks workspace isolation again under the runtime roles. ADR 0016 (rule 10), ADR 0036
Open design The code is public. Security depends on per-installation secrets (AUTH_SECRET, ENCRYPTION_KEYS, CRON_SECRET), which can be rotated. .env.example, ADR 0017
Separation of privilege Migrations run under a separate role. The web and worker roles cannot migrate and cannot bypass row-level security (NOBYPASSRLS). ENCRYPTION_KEYS must be independent of the session secrets. Multi-factor authentication is not implemented yet. ADR 0010, settings.ts
Least privilege Runtime database roles get only the grants of the table inventory. The audit log is append-only for the application role. Containers run as the unprivileged node user. Every CI workflow declares read-only or empty default permissions, and only individual jobs get write access. ADR 0011, Dockerfile, .github/workflows
Least common mechanism Workspaces share tables but are separated by composite foreign keys and row-level security. Each stored Channex credential has its own random data key. ADR 0017, section 3 below
Psychological acceptability Secrets come from an offline generator page. The setup code avoids characters that are easy to confuse. Configuration errors say which setting is wrong and how to generate it. deploy-secrets.html, secrets.ts
Limited attack surface No public sign-up and no bearer API keys yet. Request bodies are capped at 1 MiB and must be JSON. The Channex base URL is fixed per environment and never taken from a request. body.ts, transport.ts
Input validation with allowlists Every route validates its parameters and body with Zod schemas, which also generate the OpenAPI contract. Identifiers are parsed as typed TypeIDs. Codes, currencies, time zones and roles are checked against known values. packages/api/src/routes, ADR 0007

2. Common errors and their countermeasures

Section titled “2. Common errors and their countermeasures”

The list follows the OWASP Top 10 and the CWE Top 25 for a server-rendered web application with an HTTP API.

Error Countermeasure Tested or enforced by
Broken access control, cross-tenant reads (CWE-639, CWE-862) Permission check on every operation. Workspace row-level security with composite foreign keys. Property-scoped access for custom roles. RLS matrix and permission integration tests on a real PostgreSQL 17
SQL injection (CWE-89) Parameterized queries only (pg placeholders and Drizzle). Migrations are static SQL files. Code review, CodeQL
Cross-site scripting (CWE-79) React escapes rendered values, and no source file uses dangerouslySetInnerHTML. The API reference page sends a nonce-based Content-Security-Policy and Subresource Integrity for its script. reference.ts, CodeQL
Cross-site request forgery (CWE-352) Session cookies are HttpOnly, SameSite=Lax and Secure over HTTPS. Better Auth checks trusted origins on its own endpoints. Every cookie-authenticated API write must come from a configured origin (or Sec-Fetch-Site: same-origin) and is otherwise refused with request.untrusted_origin. auth.ts, app.ts
Authentication failures (CWE-287, CWE-307) Better Auth sessions, passwords of at least 12 characters hashed with scrypt, and database-backed rate limiting. Setup codes and signatures are compared in constant time. auth.ts, secrets.ts
Sensitive data exposure (CWE-200, CWE-532) Channex credentials are encrypted at rest. Invitation tokens are stored only as digests. Logs, fixtures and error messages never contain secrets, guest data or upstream bodies. AGENTS.md, gitleaks, GitHub secret scanning
Regular expression denial of service (CWE-1333) Patterns on request input avoid ambiguous repetition and are bounded by schema lengths. CodeQL default setup
Server-side request forgery (CWE-918) Outbound HTTP goes only to fixed Channex URLs. transport.ts
Vulnerable and malicious dependencies (CWE-1395) pnpm minimumReleaseAge of 7 days, trustPolicy: no-downgrade, build scripts allowed one by one, osv-scanner, Dependabot alerts, dependency review. Actions are pinned by commit SHA and tool binaries by SHA-256. quality program, tools.json
Leaked credentials in the repository (CWE-798) gitleaks over the whole history in the required quality check, plus GitHub secret scanning with push protection. quality.yml

GuestCraft does not implement cryptographic algorithms. It calls Node.js node:crypto and the Web Crypto API, which are backed by OpenSSL, and Better Auth for password hashing. All of these are free software. Every algorithm below is public and expert-reviewed. No default mechanism uses MD4, MD5, SHA-1, DES, RC4 or Dual_EC_DRBG.

Purpose Algorithm and parameters Key material Where
Operator passwords scrypt, N = 16384, r = 16, p = 1, 64-byte output, 16-byte random salt per password — Better Auth (@better-auth/utils/password), called from setup.ts and team.ts
Session cookies HMAC-SHA-256 signature (Better Auth) AUTH_SECRET, at least 32 characters; older secrets in BETTER_AUTH_SECRETS only verify, during rotation auth.ts, settings.ts
Channex credentials at rest AES-256-GCM envelope. A random 256-bit data key per record is wrapped by a key derived with HMAC-SHA-256 from the versioned ENCRYPTION_KEYS keyring. Random 96-bit IVs; the AAD binds instance, organization, workspace and key version. ENCRYPTION_KEYS, at least 32 characters per version, independent of AUTH_SECRET integration-secrets.ts
Webhook signing secrets at rest The same AES-256-GCM envelope under its own derivation label and AAD, which also binds the endpoint; a sealed value of one kind never opens as another ENCRYPTION_KEYS integration-secrets.ts
Payment provider secrets at rest The same AES-256-GCM envelope under its own derivation label and AAD, which also binds the payment connection and which secret it is (the restricted key or the webhook signing secret); re-encrypted by credentials:reencrypt ENCRYPTION_KEYS integration-secrets.ts
API keys 256 random bits behind gc_live_/gc_test_; only the SHA-256 digest is stored and looked up — api-keys.ts, api-keys.ts
Outbound webhook signatures Standard Webhooks: HMAC-SHA-256 of {id}.{timestamp}.{body} with Node.js crypto; the SDK verifies with Web Crypto in constant time, five-minute tolerance Per-endpoint whsec_ secret, 256 random bits webhook-sender.ts, webhooks.ts
Database role passwords and setup code HMAC-SHA-256 of AUTH_SECRET with a separate purpose string for each; the setup code keeps 80 bits and is compared in constant time AUTH_SECRET secrets.ts
Invitation links 256 random bits; only the SHA-256 digest is stored — team.ts
Sign-in lockout keys HMAC-SHA-256 of the folded e-mail address, so the lockout table holds no address 32 random bytes generated once by migration 0020 from PostgreSQL’s gen_random_uuid and stored in guestcraft_auth.sign_in_lockout_key, readable by the app role only; independent of AUTH_SECRET sign-in-limits.ts
Channex webhook verification HMAC-SHA-256, verified with Web Crypto Per-connection webhook secret signature.ts
Scheduled tick endpoint Constant-time comparison of the bearer secret CRON_SECRET, at least 32 characters runtime.ts, booking-import.ts
Pagination cursors and migration checksums SHA-256, for integrity, not secrecy — property-cursor.ts, runner.ts
Webhook transport node:https with default certificate verification; a lookup refuses the connection when any resolved address is not public, at connect time; literal private hosts refused before; no redirects; 10-second timeout, 64 KiB of the answer read — destinations.ts, webhook-sender.ts
Transport HTTPS to Channex through Node.js fetch, with default certificate verification, which no code disables. TLS for the web application is terminated by Vercel or by the operator’s reverse proxy in Docker. TLS to PostgreSQL follows the DATABASE_URL the operator provides. Platform certificates transport.ts, installation guide

Randomness. Keys, IVs, salts, tokens and identifiers come from crypto.getRandomValues or randomBytes, the cryptographically secure generators of Node.js. The domain receives them through an injected RandomSource. A Biome rule rejects Math.random in the core. The only Math.random in shipped code is the default retry jitter of the Channex transport, which protects nothing.

Key lengths. Symmetric keys are 256 bits. Every configured secret must have at least 32 characters, and the runtime refuses to start with a shorter one, so no shorter key can be configured. The generator page and the documentation produce 256-bit random secrets.

Known limits.

  • The scrypt cost (N = 2^14, r = 16, about 32 MiB) is below the current recommendation of the OWASP Password Storage Cheat Sheet (N = 2^17, r = 8). Raising it is a change to Better Auth’s password options and needs a re-hash on sign-in.
  • Better Auth compares password hashes with ordinary string equality. Both values are derived hashes, not secrets an attacker controls.
  • There is no multi-factor authentication yet.