Skip to content

Security: jaimemartinez/wordjs

SECURITY.md

Security Policy

Posture (be realistic). WordJS is pre-production and primarily solo-maintained. The defenses below are real and tested, but the project has not had an independent security audit β€” one is recommended before any production deployment. Operators must complete the hardening steps in the checklist (rotate jwtSecret / gatewaySecret / db.password, set a strong gatewaySecret) before exposing an instance to the internet. See documentation/security.md for the deeper defenses reference.

πŸ›‘οΈ Security Features

WordJS is built with a "Security First" architecture.

Active Defenses

  • Rate Limiting: Per-IP brute-force protection on login and API endpoints (backend).
  • Per-Account Login Lockout: A single account is locked for 15 minutes after 10 consecutive failed logins β€” this throttles a distributed/botnet attack that defeats the per-IP limiter.
  • Helmet Headers: HSTS, X-Content-Type-Options, X-Frame-Options, and XSS filtering. (The gateway's helmet CSP is off; the frontend ships a real Content Security Policy on every route β€” see Known Limitations.)
  • IO Guard: Recursive filesystem locks to prevent unauthorized plugin access outside their directory.
  • Zip Slip Protection: Every entry in an uploaded plugin or theme archive has its resolved path verified to stay inside the target directory before extraction.
  • Marketplace Install Integrity: One-click installs from the admin Marketplace tab (backend/src/routes/marketplace.ts) fetch catalog zips server-side (https-only, size-capped, strict filename shape), verify them sha256 against the catalog entry, and hand off to the same guarded zip-install pipeline as manual uploads (zip-bomb budget, Zip Slip, slug validation, manifest + AST scan) β€” the marketplace adds no separate install surface beyond the catalog fetch.
  • SVG Sanitization: Strips malicious scripts from vector images.
  • Identity Isolation: mTLS authentication between Gateway, Backend, and Services. The gateway is the cluster CA (gateway/src/cluster-ca.js); each service holds a per-node certificate (CN in {backend, frontend}) signed by that CA, and the internal control plane (POST /register, health checks) requires a valid client cert. The gatewaySecret is an additional shared-header secret on top of the mTLS channel.
  • Token-Bound Node Enrollment (separate mode): when the three services run on different machines, a new node bootstraps its mTLS identity with a single-use, role-bound, TTL-limited join token minted on the gateway (node scripts/cluster.js token <backend|frontend>). node scripts/node-join.js sends a CSR to a dedicated token-enrollment listener (default port 3101, a separate HTTPS listener that does not request a client cert; the strict mTLS /register listener is unchanged); the gateway validates the token, forces CN = the token's role (the CSR subject is ignored), signs the cert, and returns it with the cluster CA. node-join verifies the CA fingerprint (--ca-hash, a MITM guard) before trusting the response. The token is burned after first use. See documentation/separate-mode.md.
  • One-Time Install Token: The pre-install setup endpoints (POST /setup/install, POST /setup/test-db) are gated by a one-time token printed to the server console (and mirrored to a 0600 file in the data dir; overridable via WORDJS_INSTALL_TOKEN, β‰₯16 chars), compared in constant time and cleared once installed β€” preventing pre-install takeover.
  • Import Identifier Allowlist: The JSON custom_tables import validates every table and column name against a strict simple-identifier regex and refuses core tables + sqlite_* reserved tables before any SQL interpolation.

Authentication & Transport

  • JWT Signing: The signing secret never falls back to a public constant. When none is configured, a per-process ephemeral random secret is used (issued tokens stop working after a restart). Configure a real secret via setup for production.
  • Algorithm Pinning: jwt.verify is pinned to HS256.
  • Stateless-JWT Revocation: Logout and password change stamp a per-user security epoch (token_valid_after); the auth middleware rejects any token whose iat predates it. A stolen token no longer stays valid until expiry after logout/password reset.
  • Password Hashing: bcrypt cost factor of 12.
  • Two-Factor Authentication (TOTP): Opt-in RFC 6238 authenticator codes with single-use backup recovery codes; login is a two-step flow gated by a short-lived mfa_challenge token that cannot itself authenticate a session (the second factor can't be skipped). Codes are anti-replayed (one-time-use per time-step, atomic compare-and-set), the second factor has its own lockout bucket separate from the password bucket, and the MFA endpoints are per-IP throttled. Secrets/backup-code hashes live in user_meta under keys the serializer always strips. An admin can additionally enforce MFA by role with a grace period: a required-role user is nudged during grace, then hard-blocked (except the enrolment flow) by a global gate β€” and only genuine API tokens are exempt, so a session JWT presented as a Bearer header is enforced exactly like the cookie.
  • Scoped API Tokens: Personal access tokens (Authorization: Bearer wjt_…) for headless/machine clients. A token acts as its user but is bounded by both the user's live capabilities and the token's scope (coarse read/write or per-resource posts:write/media:read); effective permission is the intersection (least privilege), and an unrecognized scope is rejected, never silently widened. Only a sha256 of the 256-bit token is stored (plaintext shown once), tokens are revocable with optional expiry, and an API token can never manage tokens. The Bearer path is CSRF-exempt by design (no ambient cookie).
  • SSRF-Safe Outbound Webhooks: Admin-registered webhook endpoints are delivered host-side with the same connect-time egress validation as the plugin network guard β€” the target IP is re-resolved and checked at connect time (blocking loopback, 169.254.169.254 metadata, RFC1918/CGNAT/ULA, etc.) on the initial request and across redirect hops, so a webhook URL can't be used to SSRF internal services. Payloads are HMAC-SHA256 signed.
  • CSRF (Origin pinning): State-changing requests are checked against an exact allowed-origin match. The gateway pins X-Forwarded-Host to the real client Host (stripping any client-supplied value), so a remote attacker cannot forge the header to pass the same-origin check. When both Origin and Referer are absent the unsafe request is now rejected (fail-closed) unless it carries a real Bearer token (server-to-server) β€” previously it failed open.
  • CORS: In production, only configured origins (site / frontend / gateway) are allowed, instead of reflecting arbitrary origins with credentials.
  • Gateway Management Auth: The gateway management secret is accepted header-only (never in the query string), compared in constant time, and the shipped public default is rejected (management endpoints return 503 until a real secret is configured).
  • Stored-XSS Hardening: User-generated HTML is sanitized isomorphically β€” DOMPurify in the browser, sanitize-html on the server (SSR), both fail-closed. Built-in shortcode attribute values are escaped (escAttr/escUrl) before output. The Puck page-tree (_puck_data) sanitizer is value-based: every non-HTML string leaf is run through a scheme blocker (javascript:/data:/vbscript:/file:, incl. control-char obfuscation), so URL props not in any key-name allowlist cannot carry a script URL; _puck_data sent as a JSON string is parsed, sanitized, and re-stringified. Shared by posts.ts and the WXR importer.
  • Token-Gated Metrics: The Prometheus endpoint GET /metrics is disabled (returns 404) unless a scrape token is configured (config.metrics.token / METRICS_TOKEN); scrapes must present Authorization: Bearer <token>, compared in constant time (mismatch β†’ 401). It is never exposed without a token.

Plugin Sandbox (Isolated-Only)

  • OS-Process Isolation: Every plugin runs in a separate OS process (child_process.fork, wrapped in a transport-agnostic Worker-like adapter β€” worker_threads itself is a blocked module inside plugins) and reaches core ONLY through the permission-checked wordjs capability bridge, RPC'd over IPC β€” it never touches raw fs / child_process / dbAsync / secrets. A crash, OOM, or heap escape is contained to the child and the host always survives. Bridge dispatch enforces an exact method allowlist; registration / mail-provider / notify-transport / route flow only through dedicated trust-gated IPC kinds.
  • No Trust Tier β€” Per-Capability Grants (default-deny): There is no trusted tier; every plugin is sandboxed and the admin grants each bridge capability individually in /admin/plugins (Android-style, default-deny, persisted server-side and never self-declarable). A plugin gets nothing until an operator approves it. First-party plugins are pre-granted only their declared capabilities and are not privileged. No plugin bypasses DB scoping, the IO Guard, or these grants.
  • Outbound-Network Confinement: A plugin has no outbound network unless an admin grants the network capability. While not granted, fetch/WebSocket and the raw net/tls/http/https/http2/dgram modules are blocked. When granted, egress is confined to public destinations only by a connect-time guard that blocks loopback, link-local (incl. 169.254.169.254 cloud-metadata), RFC1918, CGNAT (100.64/10), IPv6 ULA/loopback, IPv4-mapped-v6, 0.0.0.0/8 (this-host), and multicast/reserved ranges; denies IPC / unix-socket paths; fails closed on unresolvable hosts; and re-validates the actual resolved IP at connect time (anti DNS-rebinding) and across redirect hops β€” so a network-granted plugin still cannot SSRF the metadata endpoint or internal services.
  • Secret Scrubbing: Sandboxed plugins receive config/app and dbAsync views with credential-like fields stripped and core credential/role/option tables (users, options, …) refused.
  • Filesystem Confinement (IO Guard): A plugin's raw fs is monkey-patched (context-gated to plugin code) to its own dir plus a small set of safe zones β€” reads/writes outside are denied, secret files and the raw DB file are blocked, a per-plugin disk-write quota bounds volume, and it can never create, rename, or copy a file into an executable code extension (.js/.cjs/.mjs/.node/.wasm/…, a scanner-evasion primitive). Covers the write family plus readFile/readdir/createReadStream/open/openSync/opendir/readlink (open is flag-aware). Under kernel hardening the read-only root filesystem is the OS-level backstop.
  • Isolated Themes: A theme's optional server-side functions.js runs in the same OS-isolated sandbox as plugins (loadIsolatedPlugin('theme:<slug>') in theme-engine.ts), pre-scanned by the same AST validator and reaching core only through the same permission-checked bridge. Theme code never executes in-process on the host β€” this closed the former in-process-theme RCE surface.

Vulnerability Management

  • Deep Static Analysis (SAST): AST-based (Acorn) scanning of plugins at install to block Injection, RCE, and Obfuscation. Parse failures are treated as violations (fail-closed).
  • Dependency Conflict Check: Strict SemVer verification to prevent plugin dependency collision.
  • Safe Dependency Install: Plugin dependencies are installed with execFile and an argument array (no shell string), so manifest dependency names cannot inject shell commands.
  • License Gate (CI): license-checker --production fails the build on AGPL/SSPL dependencies (WordJS is MIT; see THIRD-PARTY-NOTICES.md).
  • Supply-Chain Transparency: CodeQL static analysis (SAST) runs on every pull request, a per-release CycloneDX SBOM is generated and attached to each GitHub Release, third-party GitHub Actions are pinned to immutable commit SHAs, and Dependabot tracks dependency advisories across the workspaces. The backend CI also blocks a build on any high/critical production-dependency advisory (npm audit --omit=dev --audit-level=high).

Known Limitations

  • CSP: The frontend (admin UI + public pages) ships a Content Security Policy on every route via next.config.ts (default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'). blob: is required so admin plugin bundles (import(URL.createObjectURL(blob))) and their icons render; 'unsafe-inline'/'unsafe-eval' remain for Next.js + Puck (the server-side sanitizer is the XSS control). The gateway's helmet CSP is still off (helmet({ contentSecurityPolicy: false })); tightening both is a documented follow-up.
  • CSRF: Protection is origin/exact-match based (Origin/Referer + pinned X-Forwarded-Host), not per-request CSRF tokens. Token-based CSRF is future work.
  • Sandbox escapes: Low-level escapes (Module._load, process.binding/_linkedBinding, process.dlopen, native .node addons, deferred timers/event-emitter listeners) are blocked at runtime for plugin contexts (loading native addons is denied to every plugin β€” no trust tier unlocks it). The AST scanner does not inspect a plugin's node_modules. Resource caps are layered per child: memory via a preventive Windows Job Object cap (JOB_OBJECT_LIMIT_PROCESS_MEMORY, default-on, probe-gated, opt-out config.sandbox.useJobObjectMemoryCap), an opt-in preventive cgroup v2 MemoryMax on Linux (config.sandbox.useCgroupMemoryCap), a cross-platform reactive RSS poll, and a loose RLIMIT_AS backstop; a per-process file-descriptor cap (RLIMIT_NOFILE); and β€” with the cgroup layer enabled β€” an opt-in per-plugin CPU quota (config.sandbox.cpuQuotaPercent, cgroup CPUQuota) plus a task/pid cap (TasksMax, fork/thread-bomb containment) in the same systemd scope. The CPU/pid caps require a systemd host with the cpu/pids controllers delegated to the user cgroup (present on bare metal + Proxmox LXC, absent on ephemeral CI/containers); on a stock non-systemd install CPU is uncapped (process isolation still protects the host loop). Network egress is confined by an in-process guard, not a kernel network namespace β€” a sub-JS escape could reach the host network.
  • Kernel hardening (Linux, default-ON, probe-gated): Beyond the in-process escape blocks, each isolated child is run through bubblewrap (bwrap) β€” dropped uid (unprivileged nobody in a rootless user namespace), all capabilities dropped, no-new-privs, PID/IPC/UTS + user namespaces, and a read-only root filesystem where only the plugin's own dir + the IO-Guard write-zones are bound writable (core src/, node_modules, and sibling plugins are read-only at the kernel level, so an escaped plugin cannot persist into core source or another plugin) β€” plus a seccomp-bpf syscall denylist (pure-JS classic BPF, no native dep: ptrace/mount/kexec/*_module/bpf/keyctl/userfaultfd/setns/process_vm_*/pivot_root/reboot + io_uring + the new mount API). Enabled by default on Linux (config.sandbox.useKernelHardening, opt-out); the exact launch is validated per host at startup (bwrap + rootless-userns + a fork-IPC round-trip must all work) and any failure falls back cleanly to the standard isolated launch β€” so default-ON can't break a host lacking the feature (Windows/macOS, or Linux without unprivileged user namespaces, where only the in-process guards apply). Landlock intentionally not used.
  • No independent audit yet: see the posture note above.

πŸ› Reporting a Vulnerability

If you discover a security vulnerability within WordJS, please report it via the GitHub Security Advisories tab or contact the maintainer directly. Do NOT open a public GitHub issue.

Response Time

Our team is committed to addressing security issues promptly.

  • Acknowledge: 24-48 hours.
  • Fix: Critical issues are patched within 72 hours.

πŸ“ Supported Versions

WordJS is pre-production; only the latest main and the current 1.12.x release line are supported. There is no LTS line yet.

Version Supported Notes
main βœ… Latest development line (the only one patched)
1.12.x βœ… Current release line (latest tag v1.12.4)
< 1.12 ⚠️ Best-effort; upgrade to 1.12.x or latest main

There aren't any published security advisories