Whoobe Docs

Security findings register#

Phase 0 (static baseline) - 2026-10-02. Raw reports: scripts/security/reports/2026-10-02/ (gitignored). Values of secrets are never recorded here.

ID Sev Component Finding Status
F-001 Medium (was High) repo / git An env file of a local test site was tracked in git (committed in September 2026, untracked on 2026-10-02, now gitignored) with DB passwords, MIGRATION_SOURCE_ENCRYPTION_KEY, GATEWAY_AUTH_ENCRYPTION_KEY, S3/MinIO keys, DAM_SERVICE_TOKEN, SEED_ADMIN_PASSWORD. All values are for local services (localhost Postgres/MinIO, a local host name); no third-party provider keys in that commit. The old repository's history is unchanged; in the new repository the file is purged from the whole history (2026-10-06, git filter-repo, together with the corporate commit e-mail and the retired whoobe-studio app). Severity lowered because exposure is limited to repo readers - but swina/whoobe_project was a public mirror of this history: High until it is deleted (owner decided on 2026-10-02 to delete it; remote project removed locally). Oracle: done 2026-10-07 - the instance was destroyed and reinstalled with newly generated secrets (workspace dashboard); verified 2026-10-10 by comparing hashes of the old values with the current .env.dashboard: only non-secret configuration (host names, URLs, font list) is shared. Generate new local values at next local reset. purged from the new repository's history; untracked in the old one; rotated on Oracle; the old values still live in the old repository, its backups (incl. the owner's local env-files.tgz) and any copy of the deleted public mirror, but no running instance uses them
F-002 High git config The origin remote URL embeds a GitHub personal access token in clear text (.git/config). Revoke and replace it (use a credential helper / SSH). It was also printed in this session's output. Remotes origin and project switched to SSH on 2026-10-02 (key verified); token revoked on GitHub by the owner on 2026-10-02. fixed
F-003 Medium (was High) deps pnpm audit --prod went from 11 high / 12 moderate to 2 high / 1 moderate (2026-10-02): bumped drizzle-orm 0.36 -> ^0.45.2 (SQL injection via identifiers), nodemailer -> ^10.0.6, sharp -> ^0.35.4, exiftool-vendored -> ^35.19 (argument injection), and pinned postcss, nanoid, underscore, qs, fast-uri, markdown-it to patched same-major versions via root pnpm.overrides. Remaining: xlsx 0.18.5 (prototype pollution + ReDoS in parsing; no fix on npm, SheetJS ships fixes only from its own CDN) - used only to write an export in admin-dashboard/src/utils/data-export.ts, never to read uploaded files, so accepted risk; replace or switch to the SheetJS tarball later. @tiptap/core 2.27 -> >=3.30.4 is a major upgrade (editor), moderate, deferred. mostly fixed
F-004 Low (was High) Docker All images of the root Dockerfile run unprivileged (2026-10-02): core-api/asset-processor/gateway as USER 1000:1000 with HEALTHCHECK; admin-dashboard/whoobe-one-spike on nginxinc/nginx-unprivileged (uid 101, container port 80 -> 8080; compose mappings updated). Generated sites (packages/publish scaffold): Node image runs as 1000, static image uses nginx-unprivileged on 8080 (-p PORT:8080). trivy: 0 HIGH/CRITICAL. Remaining: existing already-generated site folders (e.g. untracked sites/<name>/Dockerfile*) keep their old root Dockerfile until regenerated; no HEALTHCHECK in the generated-site images. fixed
F-005 Low shell shellcheck findings fixed 2026-10-02: unquoted optional flags in publish-site.sh / publish-mobile.sh / deploy.sh are now arrays (values with spaces or globs no longer split), unused ROOT_DIR removed, deploy.sh heredoc documented (SC2087 intentional). shellcheck on all tracked *.sh: 0 findings. fixed
F-006 Info Dockerfile DL3059 kept on purpose (separate layers, # hadolint ignore); hadolint exits 0. fixed
F-007 Low admin/spike window.parent.postMessage(..., '*') for seo-resolved, thumbnail-saved, snippet-saved and the fallback of open-page-preview replaced by parentTargetOrigin() (whoobe-one-spike/src/scripts/parent-origin.ts: ?parentOrigin=, then referrer origin, then own origin; a wrong origin drops the message). Verified by the owner in the browser (thumbnail capture refreshes the Pages list). Remaining '*': FloatingToolbar's play command to an embedded video player (no data). fixed
F-008 Medium (was Info) static + browser v-html/innerHTML inventory (47 sites). Triaged with a real browser (Chromium via Playwright, 2026-10-03): the rich-text sinks were exploitable - see F-045. Search snippets: F-010. Fixed sinks: editor canvas (BlockElement), tables, previews and form messages in the editor, admin document/snippet previews, the Markdown preview (marked passes raw HTML through), Studio previews and SVG elements, the SDK React/Vue adapters, the public API output. Not browser-tested: the editor's table cells and form-result messages, the Studio app, generated Next/Nuxt sites and the mobile/PWA templates (they consume the sanitised public API; React/Vue adapters sanitise again). Keep the inventory in the static scan as a reminder: a new v-html must go through sanitizeRichText/sanitizeSvg. mostly fixed
F-009 Medium deploy deploy.sh excluded only the exact name .env from rsync, so .env.test and any .env.<slug> were copied to the Oracle host on every deploy (and workspace env files were exposed to --delete handling). Fixed 2026-10-02: --include .env.example --exclude .env --exclude '.env.*' (dry-run verified: only .env.example is synced). Existing copies on Oracle were removed by the 2026-10-07 reinstall (checked 2026-10-10: no .env.test or other per-site env file anywhere on the host). Also unexcluded: .claude/ (worktrees). fixed
F-010 Medium admin + spike search Stored XSS in search results: GET /whoobe-one/search and /public/search return title raw (and excerpts with ts_headline <em> markers, not escaped) and DashboardLayout (admin search), SearchResultsView and SearchElement rendered them with v-html after only mapping <em>. Reproduced on the test stack: a data class labelled zzprobe <img src=x onerror=alert(1)> came back verbatim in the search title. Anyone with objects:write could run script in other users' admin sessions (and in published sites' search for published content). Fixed: highlightSearchSnippet() in packages/core escapes everything and only turns the <em> markers into <mark> (4 unit tests); the three call sites use it. Not yet verified in a browser. fixed (verified in the browser by the owner, 2026-10-02)
F-011 High core-api CORS cors({ origin: true, credentials: true }) reflects any Origin (also null) with Access-Control-Allow-Credentials: true. A page on any website can read authenticated responses of a logged-in user (SameSite=Lax only stops cross-site cookie sending on non-GET; same-site pages and subdomains, and any GET, are exposed). Test: cors-csrf-headers.test.ts (known). Fix: allowlist of origins from env (admin/editor/published-site origins). Fixed 2026-10-02 in core-api, asset-processor and gateway: corsPolicy() (packages/core/src/http/security.ts) allows credentials only for WHOOBE_HOSTNAME / CORS_ALLOWED_HOSTS / CORS_ALLOWED_ORIGINS (+ localhost outside production, never null, never host-header based because of DNS rebinding); public routes answer any origin without credentials. 7+ unit tests and suite tests. fixed
F-012 Medium core-api session setSessionCookie hard-codes secure: false; also with X-Forwarded-Proto: https the cookie has no Secure. HttpOnly + SameSite=Lax are set. Test: auth.test.ts (known). Fix: secure when NODE_ENV=production / trust proxy + req.secure. Fixed: Secure when TLS, X-Forwarded-Proto: https or COOKIE_SECURE=true (COOKIE_SECURE=false forces off). fixed
F-013 Medium core-api sessions An admin-initiated password reset (POST /auth/users/:id/reset-password) does not end the user's existing sessions (logout, delete, deactivate and role change do). A stolen session survives the reset. Test: auth.test.ts (known). Fixed: updatePassword() revokes all the user's sessions (revokeUserSessions); note the admin 'reset password' only issues a token, the password changes when the token is used. Reset token single use verified. fixed
F-014 Medium core-api headers No baseline security headers: no X-Content-Type-Options, frame protection or Referrer-Policy; X-Powered-By: Express is sent. Test: cors-csrf-headers.test.ts (known). Fix: helmet + app.disable('x-powered-by'); CSP for the SPA nginx separately. Fixed for core-api, asset-processor, gateway: securityHeaders() (nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: no-referrer, COOP) and x-powered-by disabled. CSP for the SPAs (nginx) is still open. Update 2026-10-03: the SPA headers except CSP are done (F-047); a full CSP stays postponed to the SaaS mode (needs a report-only phase). mostly fixed
F-015 Medium core-api logging (a) With no SMTP configured the full password-reset URL (token) is written to stdout; with SMTP, the first 8 characters of the token are logged. (b) Failed-login warnings log the e-mail and the first 16 characters of the stored argon2 hash. Anyone who can read the logs can take over an account (a) or learn hash parameters/salt prefix (b). Seen in the throwaway stack's logs. Fixed: no token/URL in logs in production (dev-only console link), failed-login lines log only the user id, no hash prefix, no e-mail. fixed
F-016 Low core-api login Lockout is keyed on the e-mail only (5 failures, 15 min, in memory): anyone can lock a known account (denial of service on the admin) while IP spoofing/case/whitespace do not bypass it. Trade-off to decide (add IP component / CAPTCHA / exponential backoff). Test: auth.test.ts (known). Fixed 2026-10-02: lockout is per (e-mail + client address) after 5 failures, plus a per-e-mail ceiling of 25 failures from any addresses; tracking tables are bounded. Needs a correct req.ip: set TRUST_PROXY behind a reverse proxy (never when directly exposed). Residual: a distributed attacker can still lock an account after 25 attempts (accepted). fixed
F-017 Low core-api mail The reset link is built as http://${WHOOBE_HOSTNAME}:3000/reset-password: hard-coded http and port 3000, wrong behind TLS/nginx or on shifted ports (the e-mail link would not work, and sends the token over plain HTTP). Not host-header controlled (good, tested). Fixed: link built from ADMIN_PUBLIC_URL when set (else the old http://<host>:3000). fixed
F-018 Medium core-api errors Handlers answer 5xx with error.message; for a malformed id (/data-objects/not-a-uuid) that is the full SQL text and parameters (table and column names). 30 authenticated GET routes and the public GET /public/data-objects/:id do it. Test: public-surface / cors-csrf-headers (known). Fix: validate ids (400/404) and a generic 5xx body in production. Fixed: sanitizeServerErrors() makes every 5xx error generic in production (DB/stack text hidden in any environment) and malformed :id/:versionId/:websiteId/:translationGroupId now answer 404 before reaching Postgres. fixed
F-019 Low core-api POST /pwa/subscribe is unauthenticated, accepts any websiteId/endpoint and has no rate limit (storage spam). The two public settings GETs only return {enabled} (Info, fine). Test: public-surface.test.ts (known). Fixed: createFixedWindowLimiter (packages/core/src/http/rate-limit.ts), 20 requests / 10 min per client address on POST /pwa/subscribe. fixed
F-020 High Docker build .dockerignore only excluded .env / .env.local: every image contained .env.test and a per-site env file (secrets), the host logs/ directory (4 MB of logs, including failed-login lines) and .claude/.github. Anyone with the image (registry, Oracle host, docker save) had them. Fixed 2026-10-02: .dockerignore now excludes .env.*, **/.env*, logs, .claude, .github, .publish-build, scripts/security/reports; the Dockerfile creates /app/logs for the unprivileged user. Oracle images were rebuilt on 2026-10-07 and checked on 2026-10-10: none contains an .env.* file. fixed
F-021 Low core DB Password-reset tokens are stored in clear in users.reset_token (a database read or backup gives account takeover while a token is valid, 1 h). Store a SHA-256 of the token and compare hashes. Fixed: only sha256(token) is stored (hashResetToken); tokens issued before the upgrade stop working (1 h lifetime). fixed
F-022 High asset-processor Stored XSS: an uploaded HTML/SVG file (anyone with assets:upload) was served by GET /dam-asset/* with its own text/html / image/svg+xml type, inline, from the asset origin (same host as the admin, shared cookie site; in a single-origin nginx deployment the very same origin). Script runs with the visitor's privileges, and the CORS allowlist trusts that host: session takeover. Fixed 2026-10-02: every /dam-asset response carries Content-Security-Policy: ... sandbox; HTML/XML/JS types are forced to application/octet-stream + Content-Disposition: attachment; nosniff already present. Tests: uploads.test.ts. fixed
F-023 High asset-processor POST /api/v1/process, /api/v1/metadata, /api/v1/watermark had no authentication: anyone could run sharp/exiftool on 50 MB uploads (CPU/memory DoS, free image service, parser attack surface) and ask for arbitrary output sizes. Fixed: serviceTokenAuth (session with assets:upload, or DAM_SERVICE_TOKEN, now compared in constant time); width/height 1-8192, quality 1-100, format whitelist, opacity 0-1 (400 otherwise). No in-repo caller used them anonymously. fixed
F-024 High asset-processor GET /dam-asset/* is an unauthenticated proxy that signs with the server's S3 credentials, so assets recorded as private (is_public=false, the default for anything outside the public folders) and the system private/ area (e.g. private/logs/...) were readable by anyone who knew the key. Fixed: the private/ prefix is never served anonymously (session needed, Cache-Control: private, no-store), and with DAM_ENFORCE_ASSET_VISIBILITY=true every asset whose record is not public needs a session (getAssetVisibilityByKey, also for .thumbnails/). The flag defaults to off because published sites load their images through this proxy and today most uploads are private (e.g. the root asset /antonio-nardone.jpg): turn it on once the assets/folders used by sites are marked public (default rule /public/**), or decide to treat the proxy as public by design and drop the private/public distinction. Decision (2026-10-04): on by default everywhere (DAM_ENFORCE_ASSET_VISIBILITY=false switches it off). Public = the asset's folder matches a public rule; /public/** is built into the asset-processor (sub-folders included, lowest priority, a client rule for the same path wins), uploads without an explicit isPublic follow the rules. Found on ws-demo: a private image was readable anonymously. Regression test: api/uploads.test.ts (deep /public/a/b/c asset public, elsewhere private, thumbnails too). fixed
F-025 Low asset-processor Upload input handling: pimcorePath with ../control characters reached MinIO and returned 500; malformed multipart bodies returned 500; empty files accepted. Fixed: isSafeAssetPath (generic guard on every *path* field of /api/v1/dam/* + explicit check on upload), error handler maps multer/JSON/size errors to 400/413. Open: no explicit limitInputPixels in the sharp pipeline (library default 268 MP) - an authorised uploader can still cost a lot of memory with a decompression-bomb image. fixed (pixel limit: F-037)
F-026 Medium gateway / core AI Unrestricted server-side requests (SSRF): a gateway endpoint baseUrl (route test/execute/public routes), the OAuth2 token URL, the OpenAPI specUrl, a custom AI endpoint and the image URL returned by an AI provider were all fetched with a plain fetch(), so an operator (gateway:manage / settings:manage) - or a malicious provider response - could make the server call loopback services, other containers or the cloud metadata address and read the answer. Fixed 2026-10-02 with safeFetch() (packages/core/src/http/safe-fetch.ts, 30 unit tests): http/https only, host resolved by us and the validated address is the one connected to (no DNS-rebinding gap), redirects followed manually and re-checked, credentials dropped across origins, 25 MB / 30 s caps; link-local/metadata, unspecified, multicast and reserved ranges always blocked, loopback blocked in production, private ranges allowed unless OUTBOUND_ALLOW_PRIVATE=false, per-host exemptions via OUTBOUND_ALLOW_HOSTS. The gateway's own calls to core-api / asset-processor stay direct (trusted). Residual: private networks are reachable by default (LAN integrations are a legitimate use) - set OUTBOUND_ALLOW_PRIVATE=false on internet-facing or multi-tenant installs; AI SDK calls with multipart bodies are not routed through the guard. Tests: ssrf.test.ts. fixed
F-027 Medium gateway OpenAPI import dereference() of an imported spec resolved external $refs: a pasted or downloaded spec containing $ref: http://internal/... or file:///... made the server fetch/read it and embed JSON content into the stored schema (a local file that is not JSON/YAML only produced an error, but was still read). Fixed: the spec is downloaded with safeFetch and parsed from text; the parser runs with resolve.external = false, so only same-document $refs are followed (specs split over several files must be bundled first). fixed
F-028 Medium gateway public routes resolve and submit limited only per site (form submissions: 10 per 15 min in total), and after the binding lookup. The bindingId is public (it is in the page content), so one anonymous visitor could use up a site's whole quota and block every real visitor (contact form denial of service), and random binding ids hit the database without any limit. Fixed 2026-10-03: per-client-address limiters checked before any lookup (submit 10 / 15 min, resolve 120 / min) plus much higher per-site ceilings (300 / 15 min, 1200 / min); TRUST_PROXY support in the gateway so req.ip is right behind a proxy. Tests: gateway.test.ts. fixed
F-029 Medium gateway submit The submit route forwarded every field the visitor sent to the upstream API (and parsed strings starting with [/{ into JSON): a visitor could add fields the form does not have, e.g. Brevo listIds, updateEnabled, emailBlacklisted, or CRM status/owner (mass assignment on the site owner's ESP/CRM account). Fixed 2026-10-03: when a page is saved, bindPageGatewayContent records the form's own input names (collectFormFieldNames: data.attributes.name of input/textarea/select/radiogroup/checkbox/radio/range/file elements under the form) in the submit binding (paramKeys, marked with __fields), and submit-handler.ts drops every other field before going upstream; shape limits (100 fields, plain 64-char names, no prototype keys, 20 000 chars per value) remain. Legacy bindings (pages saved before this change) have no marker and still forward everything (a warning is logged): save the page again and republish the site to get the allowlist; set GATEWAY_REQUIRE_FIELD_ALLOWLIST=true to refuse legacy bindings (409) once pages are migrated. Tests: gateway.test.ts, core unit tests. fixed (legacy bindings until re-saved)
F-030 Low gateway resolve (a) resolveFieldPath walked the prototype chain (constructor.name, __proto__...): own properties only now; (b) a path parameter of . / .. survived URL encoding and was collapsed by the URL parser, letting a caller climb out of the route path on the upstream host: rejected; (c) a malformed bindingId reached Postgres and answered 502: now 404 before the database. Tests: unit/gateway-helpers.test.ts, gateway.test.ts. fixed
F-031 Info gateway Checked and sound: site keys are sha256(pepper + key) compared in constant time, generated with 24 random bytes, rotation invalidates the old key immediately, key id/key mismatch and wrong key give identical 403 bodies, keys never echoed. Noted, not changed: the site key is optional per website (no key = the binding alone is the policy); rate-limiter.ts advertises a Redis backend that is a stub falling back to process memory (single instance only); plugin-loader.ts uses require() in an ESM package so plugins from GATEWAY_PLUGINS_PATH are skipped silently (functional bug; they are operator-supplied code, trusted by design). info
F-032 Medium core-api MFA (a) The guess limit of a second-factor challenge was per challenge token (5 tries), but anyone holding the password can start unlimited new challenges: 6-digit codes could be guessed without bound. (b) A TOTP code could be replayed within its ~90 s window. (c) POST /auth/mfa/disable checked the password with no limit (a stolen session became a password oracle). Fixed 2026-10-03: per-account lock (10 failed codes / recovery codes -> 15 min, createFailureLock in packages/core/src/http/rate-limit.ts), used codes remembered for 2 min, 5 wrong passwords lock MFA-disable for 15 min. Tests: mfa.test.ts (12, with an RFC 6238 TOTP generator). fixed
F-033 High core-api public search GET /public/search?q=...&includeUnpublished=true returned unpublished pages, objects, classes and assets (titles and excerpts) to anonymous callers: the query parser shared with the admin search honoured the flag on the public route too. Found while reviewing 3.15; the earlier public-surface test did not send the flag. Fixed: the public handler forces includeUnpublished=false. Test: limits-and-publish.test.ts (with an admin positive control). fixed
F-034 Medium core-api public pages ?editor=true on /public/pages/* and /public/page/* returned the raw page (gateway endpoint names, route paths, filters, unresolved SEO templates) instead of the sanitised public copy, to anyone. Fixed: it now needs a valid session (isEditorRequest), otherwise the sanitised copy is returned. Test: limits-and-publish.test.ts. fixed
F-035 Medium core-api authorization objects:publish was not enforced: any holder of objects:write could set published on pages and data objects (PATCH, and the /data-objects/batch publish action), i.e. put content live without the publishing permission (the default editor role has no objects:publish). Fixed: changing the flag needs objects:publish (403, batch reports it per object); sending the unchanged value, as editors saving a published page do, still works. Behaviour change: roles that relied on publishing with only objects:write must be granted objects:publish; ENFORCE_PUBLISH_PERMISSION=false restores the old behaviour. Workflow transitions (setPublished) are a separate path and were not changed. Tests: limits-and-publish.test.ts. fixed (behaviour change)
F-036 Medium core-api resource use No throttling on the anonymous search/query endpoints (/public/data-objects/query filters and sorts every row of a class in memory per call) nor on AI generation (cost). Fixed: per-address limits on /public/* (3000/min), /public/search (120/min) and /public/data-objects/query (600/min), per-user limit on the three /ai/generate* POSTs (20/min); all env-tunable (PUBLIC_RATE_LIMIT_PER_MINUTE, PUBLIC_SEARCH_RATE_LIMIT_PER_MINUTE, PUBLIC_QUERY_RATE_LIMIT_PER_MINUTE, AI_RATE_LIMIT_PER_MINUTE, 0 = off). A server-side rendering host calls from one address: keep the defaults generous or raise them. AI settings endpoints verified not to return stored keys. Tests: limits-and-publish.test.ts. fixed
F-037 Low core DAM The sharp pipeline had no explicit pixel cap (library default 268 MP): a small file with huge dimensions cost ~800 MB while decoding. Fixed: sharpInput() applies limitInputPixels (default 100 MP, ASSET_MAX_INPUT_PIXELS, 0 = library default) in thumbnails, processing, re-encode and metadata; the asset-processor answers 413. Test: uploads.test.ts (12 000 x 12 000 1-bit PNG). Closes the open part of F-025. fixed
F-038 Low core DB TOTP secrets were stored in clear in users.totp_secret (a database read or backup yielded every user's second factor). Fixed 2026-10-03: AES-256-GCM at rest with its own key TOTP_ENCRYPTION_KEY (packages/core/src/auth/totp-crypto.ts, stored as enc:v1:...), transparent in findUserById/ByEmail; legacy plaintext values keep working and are encrypted at database bootstrap (backfillTotpEncryption, also tsx src/db/scripts/backfill-totp-encryption.ts). Verified on the throwaway stack: an enrolled secret stored in clear (16 chars) became enc:v1:... after setting the key and restarting core-api (log: "Encrypted 1 MFA secret(s) at rest"); MFA suite passes on the encrypted path; 5 unit tests (round trip, fresh IV, legacy passthrough, fail closed on wrong/missing key and tampering). Operational: without the key nothing is encrypted (a startup warning is logged); losing or changing the key makes encrypted secrets unreadable (users must re-enrol MFA) - keep it with the other secrets. No key rotation yet. fixed (needs the key set)
F-039 High site generator (packages/publish, also used by core-api generate-pwa) CMS content was written unescaped into generated source files that are then built and run on the operator's host / CI and served to visitors: Tailwind class names went into tailwind.config.ts inside single quotes (p-1'],y:globalThis.x(),z:[' became code), theme font names into JSX attributes, CSS and URLs (Roboto"... closed the attribute: attribute injection / XSS in every page of the site and code in app.vue), theme colour values into the @theme block, class names into public/safelist.html, page slugs and the site's default locale into redirect('...'), <html lang> and getPageData('...'). Anyone able to edit pages or themes (objects:write / settings) could therefore run code on whoever builds the site and plant XSS on the published site. Reproduced with a hostile website (payloads in name, locale, theme, fonts, colours, classes, slug) and fixed 2026-10-03: class names via JSON.stringify / HTML-attribute escaping, fonts and colours whitelisted (isSafeFontName, isSafeCssColor), locales restricted to BCP 47 tags (safeLocale), redirects via JSON.stringify. Tests: api/publish.test.ts generates Next and Nuxt sites from the hostile website and checks the TypeScript AST (no payload identifier), HTML attributes, CSS and shell/nginx files. Also fixed on the way: the generated Next layout.tsx contained an HTML comment inside JSX (not valid JSX, the file did not parse). fixed
F-040 Medium generator options generateNextSite / generateNuxtSite wrote operator and request-derived options (domain, API/gateway/asset URLs, base path, site key, website id, revalidate) into TS, .env, nginx and shell files without validation; the admin's generate-pwa route builds apiUrl/domain from the request's Host header. Fixed: validateGenerateOptions() refuses anything that could leave a string, line or directive (host names, http(s) URLs without quotes/backslashes/angle brackets, UUID, /path, key charset, integer). Generated .env / .env.local are written 0600. Tests: unit/publish-hygiene.test.ts. fixed
F-041 Medium workspace scripts provision-workspace.sh: the per-workspace env file (DB/S3 passwords, encryption keys, seed admin password) was created world-readable (0644); SITE_API_KEY_PEPPER and TOTP_ENCRYPTION_KEY were not generated (workspaces shared the change_me placeholder pepper); hostname, seed e-mail and password went unvalidated into sed expressions, the env file and nginx vhosts (nginx directive / env line injection); remove-workspace.sh and enable-tls.sh did not validate the slug (path in rm -f deploy/nginx/<slug>.conf) or the e-mail. Fixed: validation up front (before docker, nginx or the env file are touched), umask 077 + chmod 600, both missing secrets generated. Threat model: operators only. Tests: unit/workspace-scripts.test.ts (10 hostile-argument cases, static checks). fixed
F-042 Medium compose / example env docker-compose.yml defaulted SEED_ADMIN_PASSWORD to admin123 (a compose deployment without that variable got a known admin password; the seed itself generates a random one when empty). .env.example carried a non-placeholder MIGRATION_SOURCE_ENCRYPTION_KEY (my_secret_key_…, apparently from a real project): any install made by copying the example kept a publicly known key. Fixed: empty default (random password printed once in the log), change_me_... placeholder. If that key was ever used for real data, rotate it and re-enter the migration-source passwords it encrypts. fixed
F-043 Medium compose (plan 3.12) docker-compose.yml and docker-compose.data.yml published Postgres (5432) and MinIO (API 9000, console 9001) on all interfaces and fell back to well-known credentials (postgrespassword, minioadmin / minioadminpassword, hard-coded in the data file) when the env file did not set them; .env.example and README showed the same values. Fixed 2026-10-03 (option chosen by the owner: data services private, application ports configurable): Postgres and MinIO bind to 127.0.0.1 by default (WS_DATA_BIND_ADDRESS to open them), the credentials have no defaults (docker compose refuses to start without POSTGRES_PASSWORD, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY), the examples carry change_me/<choose…> placeholders; the application ports (admin, gateway, asset-processor, core-api, editor) go through WS_APP_BIND_ADDRESS (default 0.0.0.0 so LAN development keeps working) and provision-workspace.sh pins every workspace to 127.0.0.1 (the reverse proxy runs on the same host). Verified by rendering the compose files (docker compose config) and on the live test stack (data ports on 127.0.0.1, application ports unchanged). Tests: unit/compose-hardening.test.ts (7). Behaviour change: recreating the data containers publishes them on loopback only (a database client on another machine must set WS_DATA_BIND_ADDRESS=0.0.0.0); a .env without the three credentials makes compose fail loudly. fixed (behaviour change)
F-044 Info core-api DELETE /whoobe-one/websites/:id answers 500 when the website's pages, once detached, collide on the unique (locale, slug) index of unassigned pages (found while cleaning up test data). Functional, not security. open
F-045 High editor, admin, public renderers, SDK Stored XSS through rich text. Page content (content of element nodes), Markdown fields, snippet and document previews and form-result messages were rendered with v-html / innerHTML / dangerouslySetInnerHTML without sanitising. Reproduced in Chromium: a page whose text blocks carry <img onerror>, <svg onload>, <iframe srcdoc>, javascript: links, <details ontoggle> and mXSS payloads executed in the editor (origin :3106, which the CORS allowlist trusts with the admin's session) and on every published page rendered by the public route. Anyone who may write content (objects:write) could therefore act as the admin who opens the page, and anyone submitting a public form whose data is later bound into a page could reach other visitors. Fixed 2026-10-03 in layers: (1) sanitizeRichText() / sanitizeSvg() (@whoobe-cms/schema, sanitize-html, allow-list: formatting, links with http/https/mailto/tel, raster images, no ids, styles, handlers, frames, forms; 25 unit tests with a payload corpus); (2) applied at every sink in the editor, admin and Studio and in the Markdown preview; (3) the public API (sanitizePublicGatewayContent) sanitises element content of every published page, snippet and template, so published sites, SDK consumers and the mobile/PWA templates receive clean content; (4) the React/Vue adapters sanitise again; (5) stripBlockLevelTags parses in an inert document (a detached <div> still fires onerror). Stored data is not rewritten. Tests: browser/xss-sinks.spec.ts (5 Playwright tests with positive controls: formatting and links survive, hostile text appears as text), sanitize.test.ts, sanitize-public-content.test.ts, limits-and-publish/API assertion on the public output. Behaviour change: raw HTML in rich text that is not on the allow-list (iframes/embeds, <style>, inline style=, id, forms, custom attributes such as data-*) is removed when rendered. fixed (behaviour change)
F-046 Low admin nginx The admin image redirected /assets (an SPA route, also an empty static directory of the build) to http://localhost:8080/assets/ - the container's own port, unreachable from the browser - and a direct visit to a deep link /assets never reached the app. Found by the browser suite. Fixed: absolute_redirect off; and location = /assets serving index.html in nginx/spa-fallback.conf. fixed
F-047 Medium admin + editor nginx Found by the first DAST run (OWASP ZAP baseline): the two SPAs served by nginx sent no anti-clickjacking header (any site could frame the admin), no nosniff, Referrer-Policy or Permissions-Policy, and announced the nginx version. Fixed 2026-10-03: separate configs nginx/admin-spa.conf (X-Frame-Options: DENY + frame-ancestors 'none') and nginx/editor-spa.conf.template (frame-ancestors = this host and itself, from EDITOR_FRAME_ANCESTORS via envsubst, because the admin embeds the editor), baseline headers on both, server_tokens off. Tests: api/spa-headers.test.ts (5) and a browser test that the editor still loads inside the admin and that the admin cannot be framed by a foreign page. Remaining ZAP alerts on the SPAs are the missing full CSP (F-014) and SRI for Google Fonts, baselined with a review date. fixed
F-048 Medium supply chain / availability MinIO no longer publishes its container images: minio/minio on Docker Hub answers "pull access denied" and quay.io/minio/minio answers 401 (checked 2026-10-03). The compose files referenced minio/minio:latest, so any machine that has not already cached the old image - a fresh server, a CI runner, the next workspace provisioned - cannot start the stack, and latest was an unpinned moving target anyway. Found while wiring CI. Fixed: the former docker/minio/Dockerfile builds MinIO and mc from source at pinned releases (RELEASE.2025-10-15T17-29-55Z, the build the stack already ran) on digest-pinned bases; both compose files build it (whoobe-minio:<release>). Verified: the test stack runs on it (healthy, bucket creation with mc, all suites green; the data volume is unchanged). Residual: MinIO is AGPL-3.0 (fine to run internally; publish the source if you distribute the image), the project is source-only now so updates need a manual version bump, and replacing it with another S3-compatible store is worth evaluating. The image runs unprivileged (uid 10001, with a HEALTHCHECK; trivy flagged the first version as root): a data volume written by an older root container is handed over by the one-shot minio-perms compose service (chown), verified on the test stack's pre-existing volume. Existing containers keep running until recreated. fixed; MinIO removed from the repository 2026-10-10 (overlay, build context, stack/test switches)
F-049 Medium TLS testssl.sh against the production edge template (deploy/nginx/workspace-ssl.conf.template, rendered with the real locations, on nginx:alpine and on nginx:1.18-alpine, the version an older host would run) showed that the template relied on nginx defaults: on 1.18 TLS 1.0 and 1.1 were offered and TLS 1.3 was not; on every version CBC and static-RSA (no forward secrecy) ciphers were offered (Lucky13, BEAST on TLS 1.0) and no HSTS was sent. Fixed: the template now sets ssl_protocols TLSv1.2 TLSv1.3, an AEAD + ECDHE-only cipher list, session cache with tickets off, server_tokens off and Strict-Transport-Security: max-age=31536000 (no includeSubDomains/preload, on purpose). Verified: both images now offer only TLS 1.2/1.3, no finding above INFO, HSTS 365 days. Behaviour change: clients limited to TLS 1.0/1.1 or CBC-only ciphers (very old Android/Java/Windows XP) can no longer connect; browsers will refuse plain HTTP for the host for a year after the first HTTPS visit. Existing workspaces get it when their -ssl.conf is re-rendered (scripts/enable-tls.sh) and nginx is reloaded. Residual: certificate trust/OCSP are not assessed on the self-signed test cert; the HTTP-only vhost and the app ports (3100-3106) are not TLS endpoints. fixed
F-050 High (images) / Medium (reachability) supply chain trivy image (HIGH/CRITICAL, fixed-only) on the built images found 163 findings in each Node service image: the service images copied the whole build workspace, so they shipped devDependencies (vitest, tar, glob, minimatch...), esbuild binaries (Go stdlib) and pnpm/npm with their own CVEs; asset-processor used multer 1.x (six HIGH CVEs, incl. DoS on malformed multipart: CVE-2025-47935/-47944/-48997, CVE-2025-7338, CVE-2026-2359/-3304); the SPA images had pcre2 10.48 (CVE-2026-103111); MinIO 2025-09-07 had CVE-2025-62506 (privilege escalation through service accounts). Fixed: multer ^2 (code unchanged, suites green); Dockerfile gets a prod stage (pnpm prune --prod, esbuild binaries removed) and a runtime base without pnpm/corepack/npm for core-api, asset-processor and gateway; pino-pretty moved to dependencies (the logger uses it by default, the pruned image failed to start without it); SPA images run apk upgrade pcre2; MinIO bumped to RELEASE.2025-10-15T17-29-55Z. Result: the five own images PASS (0 fixable HIGH/CRITICAL; xlsx CVE-2023-30533 / CVE-2024-22363 accepted in scripts/security/images/.trivyignore with a review date, see F-003). Residual (advisory, not gating): postgres:16-alpine (22, all Go stdlib of its gosu helper, upstream) and MinIO (62, in MinIO's own go.mod: amqp091-go, grpc, x/crypto, prometheus...; the 2025-10-15 tag is the last one upstream published). MinIO is only reachable on loopback by default (F-043) and holds object data: Decision taken: Garage is the default local/LAN store (docker/garage, automatic bucket/key; its image has 0 fixable HIGH/CRITICAL; API and browser suites pass on it and on the MinIO overlay), production uses a cloud/enterprise S3 by configuration only, and self-hosting MinIO is documented as discouraged (docs/release/operations/storage-backends.md, docs/release/operations/deployment.md). MinIO remains available through docker-compose.minio.yml; keep it off public networks. fixed (own images) / open (MinIO, postgres); MinIO removed from the repository 2026-10-10 (overlay, build context, stack/test switches)
F-051 Low public API Found 2026-10-06 by the test of the new Alpine presets catalog: GET /public/websites/:id (no authentication) returns the website row with every key of settings - an administration blob that also holds the editor-only values logging, autosave, cdnMirror (provider, upload preset, gateway route id), defaultPlaceholderImage and, from now on, the Alpine presets catalog. No secret is among them (the public-surface suite asserts that no key, secret or hash is returned), but the endpoint publishes settings that visitors have no use for, and a future setting would be published by default. Fixed 2026-10-06: the route returns an allow-list of settings (PUBLIC_WEBSITE_SETTINGS in apps/core-api/src/server.ts: themes, defaultThemeId); the publish CLI and the mobile-app generator read the themes from the separate top-level fields and the website columns, never from settings, so nothing they use changed. A setting added later is private by default. Covered by alpine-presets.test.ts and public-surface.test.ts. fixed
F-052 Medium core-api website import Found 2026-10-07 while writing the version 2 website bundle (export/import with snippets, data objects and assets), before it was released: restoring published and the workflow state of imported pages and objects through the record layer would have let a user with only objects:write put content live by importing a bundle that says published: true, bypassing the separation of duties of F-035 (objects:publish). Fixed in the same change: importWebsiteBundle restores publication only when the route says the caller may (mayChangePublished, the rule of the page routes, ENFORCE_PUBLISH_PERMISSION included); otherwise everything lands as a draft and the report counts what was live. Also by design: the import writes only through the caller's own permissions (objects:write for classes and objects, the ordinary DAM upload for files, run by the client), reuses a class or object that exists instead of overwriting it, removes what it created if a step fails, and caps the bundle at 50 000 entries. Tests: api/portability.test.ts (editor gets drafts, admin gets published, reader 403, anonymous 401), core roundtrip.integration.test.ts. fixed
F-053 Medium core-api public forms Found 2026-10-07 while deciding how the public website should receive its contact form (named as a "later task" in docs/plans/api-gateway/gateway-auth-authz.md, never registered). POST /public/forms/:classId/submit is anonymous by design and took the form's notify block - recipient address, subject, mail template or template page - from the visitor's own request body (the page's script sends the block it has in hand). Anyone could therefore post any block and make the server send mail to an address and with a subject of their choosing, through the installation's own mail settings (a mail relay; the per-address submit limit of 10 per 15 minutes was the only brake). Dormant on an installation without mail settings. Fixed 2026-10-07 (apps/core-api/src/form-notify.ts): the block is honoured only if it is the one an author saved in a form of a page for that target class (the same tuple of class, template, template page, recipient and subject; read from the pages, cached 30 s) - an invented block is ignored, the submission is still stored and answers 201; a recipient must be one plain address (no list, display name, angle brackets, CR/LF), also when it comes from a template's {form.email} (a confirmation mail), and each address gets at most 3 mails an hour. Behaviour change: a form's notification works from the moment its page is saved (published or not); a block edited in the browser's request only, or left over from a form that no longer exists, sends nothing. Not changed: a confirmation template whose recipient is the visitor can still be pointed at a third party's address within those limits (3 an hour per address, 10 submissions per 15 minutes per client). Tests: api/form-notify.test.ts (5, on the stack: delivered attempt logged for the authored block, invented recipient/subject, legacy template page and unknown template id ignored, one plain address, a list and header injection refused, 3 an hour), unit/form-notify.test.ts (18). fixed (behaviour change)
F-054 Medium core-api / database Found 2026-10-08 while adding mail providers: the SMTP password of Settings > Outgoing Mail was stored in clear text in mail_settings.password (the other stored credentials - gateway endpoints, migration sources, MFA secrets - are encrypted at rest). A database read (backup, replica, SQL injection elsewhere) handed over a working mailbox credential. The API never returned it (hasPassword only), so exposure was limited to the database and its copies. Fixed 2026-10-08: SMTP passwords and the API keys of HTTP mail providers are encrypted with AES-256-GCM (MAIL_SETTINGS_ENCRYPTION_KEY, its own variable, generated by provision-workspace.sh); the old column is no longer written and is emptied by a startup backfill (backfillMailSettingsEncryption, idempotent, a no-op without the key); without the key a secret cannot be saved (clear message), and an old plaintext password keeps working until the backfill. Secrets never appear in an API answer, an error or the mail log (errors are scrubbed of the key). Behaviour change: an installation must set MAIL_SETTINGS_ENCRYPTION_KEY to save a password or API key. Expand step only: the plaintext column is dropped by a later migration once every environment has been through the backfill. Tests: core mail-settings.integration.test.ts (12, on a disposable database: stored encrypted, kept when blank, backfill, no key), mail-settings-crypto.test.ts (5), api/mail-settings.test.ts (11: the key and the password are in no answer), browser mail-settings.spec.ts. fixed (behaviour change)
F-055 Medium core-api / database Found 2026-10-09 in the documentation review (same class as F-054). The credentials of third-party services saved through the dashboard were stored in clear text: AI provider API keys (ai_settings, ai_image_settings, ai_translate_settings, one row per user), the Figma access token (figma_settings) and the push-notification provider key (pwa_config.push.onesignalApiKey of a website). Fixed 2026-10-09: AES-256-GCM with its own key (INTEGRATION_SECRETS_ENCRYPTION_KEY, enc:v1: values, scrypt-derived key), applied in the records layer so no caller changes; a startup backfill encrypts existing values (idempotent, a no-op without the key); without the key new values are stored as before with a one-time warning, so an existing installation keeps working (set the key to get the protection; provision-workspace.sh generates it for new workspaces); an encrypted value cannot be read without the key (fails closed). Tests: db/integration-secrets.integration.test.ts (6, on a disposable database), unit/integration-secrets-crypto (7). Behaviour change: set INTEGRATION_SECRETS_ENCRYPTION_KEY and keep it; losing it means entering the credentials again. fixed (behaviour change)
F-056 Low core-api / database Found 2026-10-09. (a) Expired and revoked rows of sessions were never deleted: the table grew with every login and kept old IP addresses and user agents. (b) The session id was the cookie value and was stored as is, so a database read or a backup yielded live sessions for up to seven days. Fixed 2026-10-09: sessions.id now holds sha256(cookie value) (hashSessionToken; the token has 256 bits of entropy, so a fast hash is enough; the stored hash cannot be replayed as a cookie), and core-api deletes sessions that expired or were revoked more than a week ago at start and every six hours (purgeStaleSessions). Migration 0053_sessions_hashed removes the old rows. Behaviour change: everyone logs in once more after the upgrade. Tests: auth/session.integration.test.ts (5, disposable database), the auth and mfa API suites. fixed (behaviour change)
F-057 Low packages/core Found 2026-10-09. getConnectionConfig() fell back to postgres/postgrespassword when DB_PASSWORD was unset. The Compose files refuse to start without credentials, so a normal install was not affected; a service started outside Compose (the older PM2 deployment) with an incomplete env file would have tried that password. Fixed 2026-10-09: resolveDbPassword - the built-in default exists only when NODE_ENV is development or test; anywhere else the service stops at start with "DB_PASSWORD is not set". Tests: db/client.test.ts (3). Behaviour change for anyone who ran a service without DB_PASSWORD and without NODE_ENV=development. fixed (behaviour change)
F-058 Medium core-api / authorization Found 2026-10-09 in the documentation review. The permission model had two families but only one was enforced: the 8 broad keys were what the 214 routes checked; the 34 function keys (pages:edit, snippets:create, websites:delete, themes:*, components:*, data_objects:* ...) were created, seeded and editable in the roles screen but no route checked them, so granting or withholding one had no effect. In addition the seeded editor and viewer roles contained no objects:* key and got 403 on most routes (verified on a fresh database). Fixed 2026-10-09 in two steps: (1) the seed gives editor objects:read + objects:write and viewer objects:read; (2) 90 of the 172 permission-guarded routes (pages, websites, data objects, snippets and kits, themes, components) now use requireAnyPermission(<function key>, <broad key>): the function key or the broad key that always covered the route, so existing roles keep working and a role built from function keys is limited to those functions. The other 82 routes (settings, mail, AI, imports, users, defining data classes ...) stay on the broad keys on purpose. The model is functions, not divisions or sites: permissions apply to the whole workspace (one workspace per organisation). Tests: api/granular-permissions (182: for each of the 90 routes, a user holding only that function key passes, users holding another action of the area or a key of another area get 403), authz-matrix (any-of aware), route-drift (the contract records mode: any). fixed
F-059 High core-api / PWA Found 2026-10-09 while verifying F-055. The push provider key (OneSignal REST API key) saved in a website's PWA configuration was returned in clear text by the unauthenticated GET /public/apps/:websiteId (the whole pwaConfig was served) and by GET /whoobe-one/websites/:id and the list to every user with objects:read. With that key anyone can send push notifications to all subscribers of the OneSignal app and read its subscriber data. Reproduced on the test stack before the fix. Fixed 2026-10-09: the website record never carries the key (it is removed in the records layer and replaced by push.onesignalApiKeySet: true), the public route additionally drops the flag, the key is stored encrypted (F-055), a save without the key keeps the stored one (blank = keep, as in the dashboard card) and clearOnesignalApiKey: true removes it; the push send route reads and decrypts the key on its own (getWebsitePushApiKey). Tests: api/third-party-credentials (public, reader, admin, list and export responses never contain it; keep/replace/clear), integration-secrets.integration.test.ts. If a push key was ever saved on an installation that was reachable from the internet, treat it as exposed: rotate it at OneSignal after upgrading. fixed

Triaged as false positives (no action): gitleaks hit in docs/release/security/security-testing-plan.md and packages/core/src/dam/s3-provider.ts:269 (prose/comment); semgrep tainted-sql-string at apps/core-api/src/server.ts:886 and apps/asset-processor/src/server.ts:594 (no SQL there, parameterised helpers called); semgrep gcm-no-tag-length in gateway-auth-crypto.ts / migration-sources-crypto.ts (auth tag is a fixed-length slice, setAuthTag always receives 16 bytes).

Exit criterion of Phase 0 ("secrets/CVE blockers fixed or ticketed"): not met until F-001..F-003 are handled.

Last updated