Nginx UI wave (7 GHSAs): the node secret is a master credential¶
Wave in one line: the node secret is a master credential, the 2FA UI flag is not the login enforcement, and the fix missed the parallel router
Signal: GitHub Security Advisories published a coordinated seven-advisory wave against 0xJacky/nginx-ui (CVE-2026-107807 through CVE-2026-107813) at 2026-10-09T17:07–17:08Z, all fixed in late-July 2026 builds (pseudo-versions 1.9.10-0.20260728*). Nginx UI is a widely deployed web management panel sitting directly in front of the reverse proxy that fronts everything else — its own compromise is an engagement-level pivot. The wave is a textbook cluster of reusable axes: a machine-to-machine shared secret that upgrades to a superuser identity, a capability flag that says "2FA on" while the enforcement code checks a different flag, a security fix applied to every router except its physically separate sibling, an archive restore that materializes files before it consults the restore flags, and a self-upgrade whose only integrity check is a digest fetched from the same endpoint as the binary.
Advisory table¶
| GHSA | CVE | CVSS | Boundary |
|---|---|---|---|
| GHSA-32gc-wf3m-78w9 | CVE-2026-107811 | 8.8 (high) | /api/nodes list/detail mounted in the shared authenticated group returns node objects with the token field unredacted — that token is the X-Node-Secret node-to-node credential, and presenting it upgrades the request to initUser, bypassing the normal user-auth path. Any low-privileged authenticated user → read node token → impersonate a trusted node → admin on any peer node. |
| GHSA-pvgv-gcp7-v38g | CVE-2026-107807 | 8.8 (high) | Same node secret is also accepted via URL query parameter (?node_secret=) as a fallback for the header, in both HTTP and WS middleware → plaintext in access logs, proxy logs, browser history, Referer. Official cluster config embeds it in app.ini/env query strings. Anyone with log-store or config read gets persistent unauthenticated admin (TLS private keys, config edits, chained RCE). |
| GHSA-h246-wpgf-vmq5 | CVE-2026-107813 | 8.8 (high) | Incomplete fix of GHSA-5v7c-xpfp-p65m: the OTP step-up (middleware.RequireSecureSession()) was wrapped around the nginx, cert, dns, backup, site, and stream mutation routers — but not the physically separate api/cluster router, which registers the same class of sensitive ops (node CRUD incl. node-token read/rewrite, cluster-wide nginx reload/restart) on the bare authenticated group. Stolen/persisted JWT without a fresh secure session still mutates the cluster. |
| GHSA-45gv-9wjv-xh7p | CVE-2026-107808 | 8.1 (high) | Enabled2FA() = EnabledOTP() || EnabledPasskey() is what the UI shows as enabled_2fa, but the password-login handler enforces a second factor only when EnabledOTP(). A passkey-only account is logged in on password alone — the registered passkey is never requested. Silent downgrade to single factor while the account reads as 2FA-protected. |
| GHSA-p8v3-89rh-jxc7 | CVE-2026-107810 | high | Backup restore always extracts both inner archives before checking the RestoreNginx/RestoreNginxUI flags; the zip extractor deliberately allows absolute symlinks whose target is under the nginx conf/modules path, and later regular-file entries are opened through the symlinked path → attacker-controlled writes into the live Nginx configuration path even with both restore flags false. Config write = deferred code-execution/RCE class (nginx runs as root, reload applies the config). |
| GHSA-33rr-wq23-g6gg | CVE-2026-107809 | high | The front end writes the JWT into a token cookie and AuthRequired accepts cookie → query-param → header in that order. Management APIs have no universal CSRF token or Origin/Referer check → any logged-in admin visiting an attacker page issues state-changing cross-site requests with ambient credentials (no response read needed). |
| GHSA-662p-52hx-cmh2 | CVE-2026-107812 | high | Self-upgrade downloads binary and its SHA-512 digest from the same origin (github_proxy / default cloud mirror), verifies only digest == DigestSHA512(tar), no signature/public-key check, then swaps the running executable (typically root) and restarts. Whoever controls the download path (compromised mirror, attacker-set http:// proxy) substitutes binary+matching digest = root code execution on next upgrade. |
Why this is durable operator value¶
- Machine-to-machine secrets are authorization state, and "upgrade-to-superuser" middleware is the highest-value gate to hunt. The node secret did not just authenticate a peer — it replaced the identity with
initUserand skipped user auth entirely. On any clustered tool (Consul-style gossip, HA pairs, load-balancer sync, backup agents), enumerate the shared-credential surface and ask what identity a correct credential yields. Two independent legs here reach the same credential: an API field that should have been redacted (json:"token", no per-endpoint redaction) and a transport convenience (header → query fallback). Credential-in-query is a permanent leak — grep any auth middleware forQuery()/url.Valuesfallbacks. - Capability-flag vs enforcement-point drift. The UI, the DB column, and the policy function all said "2FA enabled" (
Enabled2FA), but the login handler branched on a different predicate (EnabledOTP). Rule for every MFA-capable product: enumerate every factor type, then diff what the status endpoint reports against what each login/step-up route actually requires — test a passkey-only and a TOTP-only account against password-only login. Same genealogy as the permission-vs-scope drift families already on the Sept 21 auth-precedence page. - Fix drift across sibling routers — a concrete incomplete-fix differential. The patched middleware was applied per-router; the cluster package was a separate registration site and was missed. After any published advisory + fix, enumerate all router/controller registration sites for the same op class and re-test the old advisory's behavior there (this advisory literally names the fixed sibling vs the missed one — vendor fix commits are attack maps). Nginx UI also ships the MCP sensitive-tool list as part of the fix — tool-name lists and router lists drift independently.
- Extract-before-decide restore + "confined" symlink allow-lists. The restore decided what to apply after already materializing everything; the symlink guard checked only the link target prefix, then later entries wrote through it. Hunt pattern for every import/restore/agent feature: (a) does the extractor write before the flags/plan are evaluated? (b) does the containment check apply to the link itself or the final resolved path? Absolute symlinks under an allow-listed prefix are a classic double-link primitive.
- Same-origin digest ≠ integrity (fourth independent week of update-channel trust). Binary + checksum from one endpoint = the check verifies the fetch, not the publisher. Grep upgraders for
digest/checksumfetches sharing a base URL with the payload, no signature site; notegithub_proxyacceptinghttp://= cleartext supply leg. Pairs with the Sept 16 hardcoded-update-key axis below and the ongoing same-origin-update theme. - Bearer-token-in-cookie is ambient authority. A cookie fallback on an API middleware silently converts a token API into a CSRF-able one for the browser threat model. On any SPA+API panel: check
document.cookiefor JWTs, then drive one cross-site state-changing POST from an attacker page with no token knowledge.
Replayable validation boundaries (lab/authorized scope only)¶
- Node credential ladder (two disposable nodes, lab cluster only): create a low-privilege user →
GET /api/nodes→ check for atokenfield in the JSON (redaction-or-not is the finding, stop before use on any live system). In the lab, replay that value asX-Node-Secretagainst the peer node's API and record the resulting identity. Query-parameter leg: send?node_secret=<value>to a benign endpoint and confirm whether it authenticates (and whether it lands in your own access logs — log exposure is provable on your instance alone). - 2FA enforcement parity matrix: provision passkey-only, TOTP-only, and both-factor lab accounts; attempt password-only
POST /api/loginfor each; evidence = decision table (factor config → login outcome →enabled_2favalue reported). Harmless session; no account takeover claims. - Step-up coverage sweep: for a fixed advisory that added step-up middleware, list every sensitive mutation route (node CRUD, reload/restart, cert issuance, backup restore) and re-test each with a long-lived JWT and no fresh secure session. Route-decision table per router group is the deliverable.
- Restore-symlink probe (own instance only): build a backup archive with an absolute symlink into a scratch directory outside the intended restore root plus a regular marker entry; restore with all restore flags disabled; evidence = whether the marker materialized outside the staging root. Never target live nginx config paths — use a lab nginx-ui instance and scratch targets you own.
- Upgrade-integrity posture (passive + own-instance): read the upgrade code path for signature verification vs digest-equality; confirm whether the proxy/base-URL setting accepts
http://. Proof-of-impact requires an owned mirror serving a synthetic binary — never intercept a live customer upgrade channel. - Cookie-ambient CSRF: from a controlled attacker page, fire one reversible state-changing request (e.g. toggle a benign lab setting) with no explicit token; browser-sent cookie authenticity is the finding.
Cross-references¶
- Fix-drift / incomplete-fix family: Oct 5 VM2 incomplete-fix page; Malcolm sibling-location drift on the Oct 8 Malcolm wave.
- Auth-flag vs enforcement drift and delegated-identity elevation: Sept 21 auth-precedence page.
- Update-channel trust anchors and hardcoded keys: Sept 16 secret-server/OT page (now also the openPDC/openHistorian OT publisher wave).
- Symlink-through-restore genealogy: File Browser symlink canary and archive-separator entries on the June 22 build-context page; Brocade Zip-Slip on the Oct 8 SAN page.
- Management panels in front of the proxy: Nginx UI compromise yields TLS private keys and the config that fronts the estate — treat the panel as a crown-jewel web app in recon prioritization, same posture as the Oct 7 HPE ClearPass NAC-trust wave.
October 9 21:1xZ follow-up: the wave keeps going — restore-overwrites-app.ini is authenticated→RCE, and the bundled proxy's loopback confusion is shipped-topology, not misconfig¶
Three more legs landed 20:45–20:57Z, CVE IDs adjacent to the first seven (CVE-2026-107804/107805/107806), same late-July fix builds. The coordinated dump came out in two batches; same-product same-day re-check rule (the advisory-lists-arrive-out-of-order lesson) caught it.
| GHSA | CVE | Sev | Boundary |
|---|---|---|---|
| GHSA-p393-cf76-4jmr | CVE-2026-107806 | 9.8 (critical) | POST /api/restore accepts a forged encrypted backup as a normal authenticated user and restores app.ini + DB state wholesale — including settings the settings API refuses to write. Set nginx command fields (TestConfigCmd etc.) in the restored ini, then POST /api/nginx/test executes them in the nginx-ui runtime (typically root). A restore endpoint is a config-write endpoint: anything an import/restore applies with operator-level authority bypasses every per-field API guard. Pairs with GHSA-p8v3 above (same endpoint, different primitive — file write-through symlink vs. whole-config overwrite). |
| GHSA-9h23-53f4-q947 | CVE-2026-107804 | medium | The officially documented proxy topology never set Gin's trusted-proxy list, so the backend saw the bundled local proxy as the client: (a) IP allowlists on management API routes were satisfied by any external source routed through the proxy (loopback exception), and (b) all failed logins attributed to one loopback IP, letting any attacker trip the shared temp-ban and lock every user out of password/OTP login. Vendor-shipped architecture, not admin error — the loopback-exception + forwarding-header combo in a documented topology is a design flaw; recon for any panel behind its own bundled proxy: does an allowlisted route accept requests proxied through the panel's own front? |
| GHSA-j3hg-9rp3-5hw9 | CVE-2026-107805 | high | Signed-node-request path staged the attacker body to a temp file and fsync'd before validating digest/signature — unauthenticated disk + I/O exhaustion with syntactically-valid signature metadata. Generalizes the "verify before you store" rule to the write-to-disk step: streaming/hash-on-upload designs still cost an attacker zero if the reject happens after full persistence. Probe: send large bodies with well-formed-but-invalid signature headers and watch temp-partition growth (own instance only). |
Operator additions: (1) restore/import endpoints are priority recon targets on any panel — check what authority the restore applies as, whether the archive's config surface exceeds the API's writable surface (app.ini fields ≠ settings-API fields is the diff that becomes RCE), and whether restore is gated below admin; (2) loopback-exception audit: every "trust if peer is 127.0.0.1" check needs a companion question — what legitimate product feature forwards remote requests to me from loopback? Here it was the vendor's own bundled reverse proxy. Fix (2.6.0) explicitly trusts only the intended proxy address — the standard TrustedProxies fix, but the finding is that the shipped default topology never had it.