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 stronggatewaySecret) before exposing an instance to the internet. Seedocumentation/security.mdfor the deeper defenses reference.
WordJS is built with a "Security First" architecture.
- 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 (
max-age=31536000; includeSubDomains),X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy,Cross-Origin-Opener-Policyβ on both the gateway and the backend. Note that the shipped Helmet (^8) emitsX-XSS-Protection: 0, which disables the legacy browser XSS auditor rather than enabling it, so it is not an XSS control; the server-side sanitizer is. (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 (CNin{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.jssends a CSR to a dedicated token-enrollment listener (default port3101, a separate HTTPS listener that does not request a client cert; the strict mTLS/registerlistener is unchanged); the gateway validates the token, forcesCN= the token's role (the CSR subject is ignored), pins the cert's SAN to the host the token pinned (or, for an unpinned token, to the address the node is connecting from β so a token holder cannot mint a cert that impersonates the gateway), signs the cert, and returns it with the cluster CA certificate (never the CA key).node-joinverifies the CA fingerprint (--ca-hash, a MITM guard) before trusting the response when that flag is passed β the enrol call itself runs with TLS verification off, since the node has no trust anchor yet, so an enrolment without--ca-hashis plain trust-on-first-use; the commandcluster.js tokenprints always includes it. The token is burned after first use. Seedocumentation/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 a0600file in the data dir; overridable viaWORDJS_INSTALL_TOKEN, β₯16 chars), compared in constant time and cleared once installed β preventing pre-install takeover. - Import Identifier Allowlist: The JSON
custom_tablesimport validates every table and column name against a strict simple-identifier regex and refuses core tables +sqlite_*reserved tables before any SQL interpolation.
- 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.verifyis pinned toHS256. - Stateless-JWT Revocation: Logout and password change stamp a per-user security epoch (
token_valid_after); the auth middleware rejects any token whoseiatpredates 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_challengetoken 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), and the second factor has its own lockout bucket separate from the password bucket (so an attacker who knows the password can't reset the throttle by logging in). The per-IP limiter is scoped to the login step only βPOST /auth/mfagets the failed-attempts-only backstop; the authenticated self-service routes (/auth/mfa/status,/setup,/enable,/disable,/backup-codes) are deliberately excluded from it, so a user toggling their own 2FA can't burn their own login budget, and rely on that per-account bucket plus the global API limiter instead. Secrets/backup-code hashes live inuser_metaunder 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 aBearerheader 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 (coarseread/writeor per-resourceposts: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.254metadata, 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-Hostto the real clientHost(stripping any client-supplied value), so a remote attacker cannot forge the header to pass the same-origin check. When bothOriginandRefererare absent the unsafe request is now rejected (fail-closed) unless it carries a realBearertoken (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-htmlon 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_datasent as a JSON string is parsed, sanitized, and re-stringified. Shared byposts.tsand the WXR importer. - Token-Gated Metrics: The Prometheus endpoint
GET /metricsis disabled (returns 404) unless a scrape token is configured (config.metrics.token/METRICS_TOKEN); scrapes must presentAuthorization: Bearer <token>, compared in constant time (mismatch β 401). It is never exposed without a token.
- OS-Process Isolation: Every plugin runs in a separate OS process (
child_process.fork, wrapped in a transport-agnostic Worker-like adapter βworker_threadsitself is a blocked module inside plugins) and reaches core ONLY through the permission-checkedwordjscapability bridge, RPC'd over IPC β it never touches rawfs/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: activation is that approval, and it grants exactly what the manifest declares β the same path for every plugin, first-party or uploaded, and only while the plugin holds no grant record, so a revoke survives re-activation. No plugin is 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
networkcapability. While not granted,fetch/WebSocketand the rawnet/tls/http/https/http2/dgrammodules 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.254cloud-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/appanddbAsyncviews with credential-like fields stripped and core credential/role/option tables (users,options, β¦) refused. - Filesystem Confinement (IO Guard): A plugin's raw
fsis 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 plusreadFile/readdir/createReadStream/open/openSync/opendir/readlink(openis flag-aware). Under kernel hardening the read-only root filesystem is the OS-level backstop. - Isolated Themes: A theme's optional server-side
functions.jsruns in the same OS-isolated sandbox as plugins (loadIsolatedPlugin('theme:<slug>')intheme-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.
- 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
execFileand an argument array (no shell string), so manifest dependency names cannot inject shell commands. - License Gate (CI):
license-checker --productionfails the build onAGPL/SSPLdependencies (WordJS is MIT; seeTHIRD-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).
- 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.nodeaddons, 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'snode_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-outconfig.sandbox.useJobObjectMemoryCap), an opt-in preventive cgroup v2MemoryMaxon Linux (config.sandbox.useCgroupMemoryCap), a cross-platform reactive RSS poll, and a looseRLIMIT_ASbackstop; a per-process file-descriptor cap (RLIMIT_NOFILE); and β with the cgroup layer enabled β an opt-in per-plugin CPU quota (config.sandbox.cpuQuotaPercent, cgroupCPUQuota) plus a task/pid cap (TasksMax, fork/thread-bomb containment) in the same systemd scope. The CPU/pid caps require a systemd host with thecpu/pidscontrollers 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). For anetwork-granted plugin, egress is confined by the in-process guard alone β not a kernel network namespace, so a sub-JS escape could reach the host network. A plugin without the grant additionally gets an empty kernel network namespace on Linux under bwrap (--unshare-net,config.sandbox.unshareNetwork, default-on, separately probe-gated), so for that majority case the confinement is no longer JS-only. - Kernel hardening (Linux, default-ON, probe-gated): Beyond the in-process escape blocks, each isolated child is run through
bubblewrap(bwrap) β dropped uid (unprivilegednobodyin 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 (coresrc/,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. Landlock intentionally not used. - Node permission model (every platform, default-ON, probe-gated): where bwrap is unavailable (Windows/macOS, or Linux without unprivileged user namespaces) the child is not left to the in-process guards alone β it is launched under Node's own C++-enforced permission model (
config.sandbox.usePermissionModel, opt-out):--allow-fs-readscoped to the app root,--allow-fs-writescoped to exactly the IO-Guard write-zones, andchild_process/worker_threads/ native addons / WASI never granted. Probe-gated (a child must actually be refused a read before it activates) and not applied underts-node, so it is the compiled production child that is confined. Node's permission model has no--allow-nettoken, so the JS egress guard remains the sole authority on outbound traffic. The live state of all three layers (hardening/netns/permission) is reported on adminGET /health/details. - No independent audit yet: see the posture note above.
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.
Our team is committed to addressing security issues promptly.
- Acknowledge: 24-48 hours.
- Fix: Critical issues are patched within 72 hours.
WordJS is pre-production; only the latest main and the current 1.13.x release line are supported. There is no LTS line yet.
| Version | Supported | Notes |
|---|---|---|
main |
β | Latest development line (the only one patched) |
1.13.x |
β | Current release line (latest tag v1.13.7) |
< 1.13 |
Best-effort; upgrade to 1.13.x or latest main |