The security posture of Nginx Flow Manager: how the panel authenticates operators, how secrets are protected at rest, how the managed-host channel is confined, and where the trust boundaries sit.
This document describes the post-hardening state of the code. It is the reference for understanding why each control exists; for setup-time choices that affect security (TLS, bind host, trustProxy) see CONFIGURATION.md, and for the on-server agent see AGENT.md. For the surface that these controls protect, see the HTTP API reference and the architecture overview.
The management panel is a privileged tool: an authenticated session can rewrite the nginx configuration of a remote production host and issue TLS certificates. The controls below assume an attacker who can reach the panel over the network and may also lure an authenticated operator to a malicious page. They are organised around four boundaries:
| Boundary | Primary control |
|---|---|
| Network → panel | HTTPS-only, security headers, strict CSP |
| Anonymous → operator | Cookie session auth, scrypt hashing, login rate-limit |
| Operator page → forged request | SameSite=Strict cookie + X-NFM-CSRF header |
| Panel host → managed host | nfm-agent forced-command + HMAC, or legacy SSH with host-key pinning; path confinement + injection hardening |
The session token travels in an HttpOnly cookie named nfm_session, never in a bearer header and never in the response body. Because it is HttpOnly, page JavaScript cannot read it, so an XSS bug cannot exfiltrate the session. The cookie is set with the full set of protective attributes (server.ts, setSessionCookie):
nfm_session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200
Secure— the cookie is only sent over HTTPS (the panel is HTTPS-only).SameSite=Strict— the browser withholds the cookie on cross-site requests, which is the first line of CSRF defence.Max-Age=43200(12 h) — matches the session idle TTL.
The token itself is 32 random bytes (crypto.randomBytes(32)), generated server-side in createSession(). The server parses the incoming Cookie header manually (sessionToken()) — there is no cookie-parser dependency — matching the exact cookie name and tolerating spaces and = in the value.
On login and after first-time setup the server calls setSessionCookie and returns no token to the client; the cookie is the credential. Logout (POST /api/logout) and reinstall (POST /api/reinstall) call clearSessionCookie, which expires the cookie with Max-Age=0. Logout is intentionally allowed through the auth middleware even for a stale or expired session, so the browser can always clear its cookie; the handler only acts on a real token.
Passwords are stored as salted scrypt key-stretched hashes in the form scrypt$<saltHex>$<hashHex> (makePasswordHash): a fresh 16-byte random salt per password and a 64-byte derived key. Verification (verifyPassword) recomputes the candidate hash and compares it with crypto.timingSafeEqual, so verification time does not leak how many leading bytes matched.
Legacy unsalted SHA-256 hashes (a bare 64-hex string) are still accepted for verification and are transparently upgraded to salted scrypt on the next successful login (isLegacyHash → re-hash → saveConfig).
At first-time setup (POST /api/setup-install) the server enforces a minimal credential policy before accepting the admin password:
- reject passwords shorter than 8 characters;
- reject the shipped default
admin123; - reject a password equal to the username.
After setup, a credential that matches a known weak default is flagged but not blocked: isDefaultCredential (e.g. admin/admin123, or password equal to the username) sets a cached credentialIsWeak flag, which GET /api/me returns as passwordIsDefault so the UI can keep nudging the operator to change it. The flag is cached at setup/login/reinstall to avoid re-running scrypt on every /api/me call.
- Gating. All
/api/*routes except a small public allowlist (/api/setup-status,/api/login,/api/reinstall,/api/logout) require a valid session cookie, enforced by the auth middleware viaisSessionValid(sessionToken(req)). Setup-phase endpoints (/api/setup-install,/api/setup-install-nginx,/api/validate-path,/api/test-ssh) are open only before setup is completed; once configured they require a session.GET /api/mereports session and credential state. - Rate-limiting. The credential endpoints (
/api/login,/api/reinstall) are throttled per client IP: at mostAUTH_MAX(8) failures within a 15-minute window, after which the endpoint returns 429 until the window rolls over. A successful login clears the counter. clientIp/trustProxy. The throttle key is the real socket peer (req.socket.remoteAddress) by default.X-Forwarded-Foris attacker-controllable — honouring it blindly would let one client rotate the throttle key to bypass the limit — so it is trusted only when the operator has explicitly setappConfig.trustProxy(panel genuinely behind a trusted reverse proxy).
Sessions are bounded by two independent limits (server.ts):
- Sliding idle TTL —
SESSION_TTL_MS= 12 h. Each valid use renewsexp = now + 12h. - Absolute cap —
SESSION_ABSOLUTE_MAX_MS= 24 h, fixed at issuance viacreated. Once a token is older than the cap it is rejected even if the sliding TTL is still fresh, forcing periodic re-authentication. A token can no longer live forever just by being used.
The session map is bounded (MAX_SESSIONS = 1000) and pruned (pruneSessions) on every issuance, evicting expired/over-cap entries first and then the oldest-expiring ones, so a flood of logins cannot grow it without bound. Reinstall calls activeSessions.clear(), revoking every session at once.
CSRF is defended in depth with two independent controls:
SameSite=Stricton the session cookie — the browser will not attachnfm_sessionto a cross-site request at all.- A custom request header. Every state-changing request (any method other than
GET/HEAD/OPTIONS) must carryX-NFM-CSRF: 1or the auth middleware rejects it with 403CSRF. A cross-site HTML form cannot set custom headers, so this header is un-forgeable from a malicious page. The check runs before the public-endpoint allowance, so even pre-auth POSTs (login, reinstall, setup-install) are protected; the frontend's API helper attaches the header to every request.
| File | Contents |
|---|---|
app-config.json |
Admin scrypt hash; in remote (non-agent) mode, the SSH password / private key |
agent-config.json |
App↔agent SSH private key + HMAC secret |
certs/ |
Panel TLS private key (*.key), certificate, and known_hosts.json (pinned SSH host keys) |
workspace-state.json |
Saved topology / workspace state |
All of these are excluded from version control by .gitignore (app-config.json, agent-config.json, workspace-state.json, certs/, .env*, and *.pem/*.key/*.crt/*.p12/*.pfx).
Files written at runtime are created with owner-only permissions 0600:
app-config.json— written bysaveConfigwithmode: 0o600plus an explicitfs.chmodSync(..., 0o600).- the panel TLS private key — written with
mode: 0o600ingenerateSelfSigned. certs/known_hosts.json— written withmode: 0o600in the SSH helper (saveKnownHosts).
Windows caveat: POSIX modes are advisory on Windows. The
chmodcall is wrapped in atry/catch(/* Windows ignores POSIX perms */) and is effectively a no-op there. On Windows, rely on filesystem ACLs / a non-shared account to protect these files.
The static/SPA handler is rooted at process.cwd(), which is where the secret files live, and the auth middleware only gates /api/*. To stop the static server from ever serving a secret to an unauthenticated client, a dedicated middleware runs before it (isSensitivePath):
- It normalises the request path (strips query, URL-decodes, collapses
.., resolves against cwd) and returns 404 for any path that resolves to a sensitive file or escapes the project root. - Matching is case-insensitive — on a case-insensitive filesystem (Windows/macOS) a variant like
/APP-CONFIG.JSONwould otherwise resolve to the real secret. - Always denied:
app-config.json,workspace-state.json,agent-config.json,metadata.json,known_hosts.json; anything starting with.env; any*.key/*.crt/*.pem; and thecerts/and.git/directories. In production it additionally hides build/meta files (package.json,tsconfig*.json, top-level*.json) and thenode_modules/,agent/, andsrc/trees.
In development, Vite's /@fs/ escape hatch is closed separately with server.fs.strict plus an fs.deny glob list (viteFsDeny) covering the same secret files and certs/**.
The panel serves HTTPS only (https.createServer). On first boot it generates a self-signed certificate (generateSelfSigned, ~10-year validity, SANs for localhost / hostname / 127.0.0.1); the private key is written 0600. An operator can supply a custom cert/key, which is validated (validateCertKey confirms the key matches the cert) before use and can be hot-swapped at runtime via setSecureContext without a restart. If a configured custom cert is unreadable or invalid, the panel falls back to a freshly generated self-signed pair so it always comes up on TLS. See CONFIGURATION.md for the TLS options.
A baseline middleware sets, on every response:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: no-referrerStrict-Transport-Security: max-age=31536000; includeSubDomains
The panel emits its own strict CSP (distinct from any CSP emitted into managed nginx sites). In production (NODE_ENV=production):
default-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'
This blocks inline/injected script execution and framing. In development the policy is relaxed only as far as the Vite HMR client requires ('unsafe-inline' 'unsafe-eval' for scripts, inline styles, and ws:/wss: connect) — another reason to run the panel with NODE_ENV=production.
The panel writes config and runs privileged commands on a remote host. There are two channels; the hardened path is the agent.
The on-server nfm-agent replaces raw SSH command execution with a narrow, audited RPC surface. Its defences:
- Forced command. The app↔agent SSH key is installed with
command="sudo -n /usr/local/bin/nfm-agent serve --stdio",restrict(authorizedKeysLineinagent-install.ts). A stolen key can do nothing but launch the agent — no shell, no SFTP, no port forwarding. The agent user's login shell is a root-owned forced-command wrapper (nfm-agent-shell) that ignores its arguments andexecs the agent, so evenssh user '<cmd>'cannot reach a general-purpose shell. The user's home and rc files are chowned to root so the agent user cannot plant a malicious profile. - Scoped sudo. A single
NOPASSWDsudoers rule (sudoersFile) lets thenfm-agentuser run onlynfm-agent serve --stdioas root — nothing else. - Per-request HMAC. On top of the SSH channel, every RPC carries an HMAC-SHA256 over
id\nmethod\nts\nnonce\nsha256(params)keyed by a secret in/etc/nfm-agent/token(0600 root). The verifier (agent/src/security.ts,RequestVerifier) checks the MAC withcrypto.timingSafeEqual, enforces a ±90 s freshness window, and rejects replayed nonces from a bounded sliding cache (MAX_NONCES= 100 000, fail-closed when full). The app-side signer (agent-client.ts) produces the identical payload. - No generic exec. The agent exposes only high-level intents (
config.read,config.deploy,nginx.test,certs.issue, …). There is no "run command" and no "write arbitrary path". Privileged binaries are invoked withexecFileand argument arrays — never a shell string — so there is no shell to inject into. - Path confinement. Every filesystem operation goes through
confinePath, which resolves a requested path against an allowlist of roots and rejects traversal (..) or any path that escapes confinement (also rejecting NUL bytes). Reads are confined to/etc/nginx,/etc/letsencrypt, and the log dir; writes only to/etc/nginx.config.readadditionallyrealpaths the target and re-confines the resolved path, then refuses anything matching a private key (privkey*,*.key, ACMEaccounts/keysdirs) so it can never become a private-key oracle. O_NOFOLLOWwrites. Config writes open files withO_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, so a symlink planted at the target cannot redirect the write outside the root; the parent directory'srealpathis re-confined first to catch an intermediate dir-symlink thatO_NOFOLLOW(final component only) would miss.- Atomic deploy.
config.deploybacks up/etc/nginx, writes, runsnginx -t, and reloads — rolling back to the backup on any failure, so a bad config never stays live. Input validators (validateDomains,validateEmail,validateWebroot) guard the certbot path. - No open port. The agent listens on nothing; the only network listener on the managed box remains
sshd.
When the agent is not installed, the panel falls back to direct SSH (ssh-helper.ts) using the operator-supplied credentials in app-config.json. This path is less confined (it uses SFTP and ssh.exec), so the agent is strongly preferred for any internet-exposed host.
Both channels pin the managed host's SSH host key trust-on-first-use (ssh-helper.ts, hostVerifier with hostHash: 'sha256'). The first connection to a host:port records the SHA-256 of its host key in certs/known_hosts.json (0600) and logs the fingerprint prominently for out-of-band verification; every later connection must present the same key or it is rejected. This blocks man-in-the-middle interception of the SSH channel (credentials and the config/logs it carries). Reinstall calls clearKnownHosts() so reconfiguring the target host re-pins deliberately. The currently pinned fingerprint(s) are exposed read-only to the authenticated UI via getKnownHosts() (a public-key digest, not a secret) so an operator can confirm them.
shQuote(ssh-helper.ts) POSIX single-quotes any request-derived value interpolated into a legacy SSH command string (wrapping in'…'and replacing each'with'\''), so a crafted value cannot break out and inject metacharacters. Double quotes are not sufficient — they do not suppress$()/backtick expansion — which is why single-quoting is used.isSafeNginxPathrejects an operator-supplied nginx path/binary that is not an absolute POSIX path free of shell metacharacters, before it is ever interpolated into a command.- Compiler escaping/sanitization (
src/utils/nginxCompiler.ts) hardens the generated config against injection and quote-breakout, including from a hostile imported config:escapeNginxQuoted/escapeNginxSingleQuotedbackslash-escape"/'inside quoted tokens;sanitizeToken/sanitizeMultiTokendrop the breakout characters; { } #and newlines from unquoted structured fields (server_name, access rules, timeouts, upstream addresses, etc.); andisValidHeaderNamerestricts header names to[A-Za-z0-9-]. These helpers leave a legitimate value byte-identical, preserving the parser ↔ compiler fidelity invariant (see ARCHITECTURE.md).
Operational guidance
- Do not expose the panel port publicly. The panel binds
0.0.0.0by default for container/remote use. When the host is not otherwise network-isolated, setNFM_HOST=127.0.0.1and front the panel with a reverse proxy (and settrustProxyonly then). See CONFIGURATION.md. - Run with
NODE_ENV=productionso the strict CSP and the production static-deny rules apply. - Rotate SSH credentials / the agent secret if a panel host or
app-config.jsonis ever exposed; reinstall (/api/reinstall) clears sessions and re-pins host keys, and re-running the agent install rotates the HMAC token. - Change a flagged weak default immediately. If
/api/mereportspasswordIsDefault, the live credential is a known weak value. - Rebuild before deploy. Deploys validate the candidate config with
nginx -tin a throwaway sandbox before applying; always rebuild/validate the topology before pushing so a bad config is caught in the sandbox, not on the live host. - Verify the pinned SSH fingerprint out-of-band against the value logged on first connection / shown in the UI before trusting a new managed host.
Deferred items
- The legacy direct-SSH fallback is intentionally less confined than the agent; prefer the agent for any internet-reachable managed host.
- TOFU host-key pinning trusts the first connection. If an attacker is already in path on first contact, the wrong key is pinned — hence the out-of-band fingerprint verification step above.
- On Windows, the 0600 file modes are advisory only; protect the secret files with filesystem ACLs / a dedicated account.
Do not include real credentials, private keys, tokens, or host addresses in issue reports — use placeholders.