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,
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 (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), signs the cert, and returns it with the cluster CA.node-joinverifies the CA fingerprint (--ca-hash, a MITM guard) before trusting the response. 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), 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 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. 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
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). 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 (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 (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.
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.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 |