Safeguard
Supply Chain Security•

Nginx UI's 2026 Advisories: Why Your Scanner Can't Tell Whether You're Patched

Nginx UI ships 2.x releases from a v1 Go module path, so scanners miss or never clear its 2026 advisories. The data, a fix map, and a version checker.

Safeguard Security Team
Security Research
Updated 12 min read

Nginx UI, the open source web interface for managing nginx servers, published 51 security advisories in its GitHub repository in 2026. On 15 October, GitHub's reviewed database added 11 more of them, three months after the project published them. If you run Nginx UI, your vulnerability scanner probably isn't telling you the truth about any of this. Depending on how it identifies the install, it either sees no Nginx UI version at all, reports advisories that a fully patched release fixed long ago, or misses most of the ones that apply. The causes are a Go module path that doesn't match the project's version numbers, and the way the release binaries are built.

This guide is for teams running Nginx UI, in Docker or from release binaries, and for anyone who scans Go applications rather than Go libraries. It explains why the advisory data doesn't line up with Nginx UI releases, shows what OSV returns for real version numbers, maps GitHub's fix versions to actual releases, and gives you a script that checks a release number against the project's own advisories.

The short answer

Upgrade Nginx UI to 2.8.4, the current release from 7 October, which is newer than every fix listed in the 2026 repository advisories. Don't rely on a container or binary scanner to tell you whether you're exposed. The official release binaries are built in a way that records no Nginx UI version, the Go vulnerability database records most Nginx UI entries as affecting every version with no fix, and GitHub's reviewed entries write fixes as Go pseudo-versions such as 1.9.10-0.20260703131214-23d1934099b0. Check the release number yourself against the repository advisories, which use real release numbers.

Why the numbers don't line up

Nginx UI's releases are numbered 2.x. Its go.mod still declares the module as github.com/0xJacky/Nginx-UI, without the /v2 suffix Go requires for major version 2 and above. So Go's tooling can't use any 2.x tag. Ask the Go module proxy for the module's latest version and it answers v1.9.9, a tag from May 2023. Ask for v2.8.4 and it refuses: the module "contains a go.mod file, so module path must match major version".

That leaves advisory databases with two bad options. GitHub's reviewed database mostly chose Go pseudo-versions, which Go builds from the last valid tag, a timestamp and a commit hash. Of the 41 reviewed GitHub entries for Nginx UI, 28 write the fix that way, 4 use a release number and 9 list no fixed version. Several of those with no fix use a range of <= 1.99, which every Nginx UI release falls into. The Go vulnerability database has 16 Nginx UI entries, and 10 of them record no fixed version at all, so they match every version forever.

Nothing here is wrong in isolation. A pseudo-version is the precise Go answer for "fixed at this commit". It just isn't a number any Nginx UI operator ever sees.

Three ways a scanner gets it wrong

How a scanner identifies Nginx UI decides which mistake it makes.

It reads the binary and finds no version. Container and binary scanners usually read the build information Go embeds in every executable. Nginx UI's release workflow builds with go build -trimpath ... -o dist/$BINARY_NAME -v main.go, naming a file rather than a package. We reproduced that build command on a test module with a v2 tag and a v1 module path. Built as a package, the binary recorded the main module as v1.9.10-0.20261016065828-dbc60133b781. Built from main.go, it recorded the main package as command-line-arguments and no module version at all. We didn't download the official release binaries to confirm. But if they match the workflow, a binary scanner can see Nginx UI's dependencies and nothing about Nginx UI itself. The official Docker image copies in the same binary.

It looks up the release number and gets permanent false positives. We queried OSV for the Go package github.com/0xJacky/Nginx-UI on 16 October:

Version queriedOSV resultsRepository advisories that apply
2.8.4 (current release)11, all Go database entries0
2.3.3 (February 2026)1341
October 2026 pseudo-version170

The current release, which fixes everything listed, gets 11 results that will never clear. February's 2.3.3, exposed to 41 advisories by the project's own count, gets 13.

It reads a pseudo-version and still can't clear. If you build Nginx UI yourself from a git checkout as a package, the binary does get a pseudo-version, and GitHub's pseudo-version ranges then compare correctly. But the <= 1.99 entries and the Go database entries with no fix still match, which is the 17 in the last row.

What GitHub's fix versions mean in releases

To turn the reviewed entries back into something usable, we took each pseudo-version fix commit from GitHub's database and found the first release containing it with git tag --contains:

Fix commit (date, UTC+8)First release with the fixReviewed entries fixed
7b66578adb47, f61bcec547c0 (14 and 15 Mar)2.3.42
1a9cd29a3082 (16 Mar)2.3.51
23d1934099b0 (3 Jul)2.4.19
f92218e01054 (15 Jul)2.4.31
Five commits on 28 Jul2.5.08
8c9b9a1aff21, e30e331303fc (1 and 4 Sep)2.6.02
Five fixes for 2024 advisories2.0.05

Of the 11 entries GitHub reviewed this week, all published by the project on 17 July, ten were fixed in 2.4.1: nine name that commit as the fix, and one has a range ending at it. The eleventh was fixed in 2.4.3. They cover missing re-authentication on account and two-factor changes, a login CSRF in the Casdoor SSO callback, and three advisories where the MCP configuration tools skipped the directive validation the web interface applies. One of those three, CVE-2026-84318, is rated as authenticated remote code execution through nginx directives. The July reports came from therawdev, jagstack, novice-22, thekogo, aleff-github and Vincent550102.

Check your version

This script takes the release number from nginx-ui --version, or your image tag, and checks it against Nginx UI's repository advisories, which state affected and fixed versions as release numbers. Where an advisory doesn't list a usable fix, it uses the release mapping above. With --binary, it runs go version -m on an executable to show whether a scanner could see the version. It uses only the Python standard library, fetches the advisories from GitHub's API unless you pass a saved copy, and reads files only.

#!/usr/bin/env python3
"""Check an Nginx UI release number against the project's own security advisories.

Scanners that read Go build info often can't see Nginx UI's version, and the
advisory databases mix release numbers with Go pseudo-versions. This script
uses the repository advisories, which are written in release numbers.

  python3 nginx_ui_check.py 2.5.6                 # fetch advisories from GitHub
  python3 nginx_ui_check.py 2.5.6 --advisories adv.json
  python3 nginx_ui_check.py 2.5.6 --binary /usr/local/bin/nginx-ui   # needs Go
"""
import argparse, json, re, shutil, subprocess, sys, urllib.request

API = "https://api.github.com/repos/0xJacky/nginx-ui/security-advisories?per_page=100&state=published"
SEMVER = re.compile(r"v?(\d+)\.(\d+)\.(\d+)")
# First release containing the fix, for advisories whose repository entry has no
# usable patched version. Derived on 16 Oct 2026 from GitHub's reviewed entries
# (Go pseudo-version fix commits) and the fix commit named in GHSA-49r7, with
# `git tag --contains <commit>`.
FIXED_IN = {
    "GHSA-2gc7-j998-5xx6": "2.4.1", "GHSA-5r62-gj48-897x": "2.4.1", "GHSA-5v7c-xpfp-p65m": "2.4.1",
    "GHSA-76pm-mq2q-9gcr": "2.4.1", "GHSA-9jww-cxg5-rj9m": "2.4.1", "GHSA-cf23-7qxj-xmhr": "2.4.1",
    "GHSA-gfrc-g87h-7h5w": "2.4.1", "GHSA-vc32-4gwf-77xp": "2.4.1", "GHSA-wq4w-vwvq-q3x9": "2.4.1",
    "GHSA-44xr-mrhh-qm78": "2.4.1", "GHSA-jrq7-xrjx-pcfw": "2.4.3", "GHSA-49r7-63h8-8p86": "2.5.7",
}
NOT_A_RELEASE = {"GHSA-4rwp-h4w5-hr55": "public demo instance only"}


def ver(s):
    m = SEMVER.search(s or "")
    return tuple(map(int, m.groups())) if m else None


def affected(adv, v):
    """True, False, or None when the advisory's ranges can't be read."""
    if adv["ghsa_id"] in NOT_A_RELEASE:
        return False
    if adv["ghsa_id"] in FIXED_IN:
        return v < ver(FIXED_IN[adv["ghsa_id"]])
    results = []
    for vuln in adv.get("vulnerabilities") or []:
        rng = vuln.get("vulnerable_version_range") or ""
        fixed = ver(vuln.get("patched_versions"))
        lower = re.search(r">=?\s*(v?\d+\.\d+\.\d+)", rng)
        upper = re.search(r"<(=?)\s*=?\s*(v?\d+\.\d+\.\d+)", rng)
        if lower and v < ver(lower.group(1)):
            results.append(False)
        elif fixed:
            results.append(v < fixed)
        elif upper:
            limit = ver(upper.group(2))
            results.append(v <= limit if upper.group(1) else v < limit)
        elif ver(rng) and not re.search(r"[<>]", rng):
            results.append(v == ver(rng))
        else:
            results.append(None)
    if True in results:
        return True
    return None if None in results else False


def check_binary(path):
    go = shutil.which("go")
    if not go:
        print(f"{path}: Go not installed, skipping build-info check")
        return
    out = subprocess.run([go, "version", "-m", path], capture_output=True, text=True).stdout
    main = re.search(r"^\tpath\t(\S+)", out, re.M)
    mod = re.search(r"^\tmod\t(\S+)\t(\S+)", out, re.M)
    print(f"{path}: build info main package {main.group(1) if main else '?'}, "
          f"module version {mod.group(2) if mod else 'NOT RECORDED'}")
    if not mod or mod.group(2) == "(devel)":
        print("  Binary scanners have no Nginx UI version to match. Check the release number instead.")


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("version", help="release number, e.g. 2.5.6 (nginx-ui --version)")
    ap.add_argument("--advisories", help="saved JSON from the repository advisories API")
    ap.add_argument("--binary", help="nginx-ui binary to inspect with `go version -m`")
    a = ap.parse_args()
    v = ver(a.version)
    if not v:
        sys.exit(f"not a release number: {a.version}")
    if a.advisories:
        advs = json.load(open(a.advisories))
    else:
        with urllib.request.urlopen(API, timeout=30) as r:
            advs = json.load(r)
    hits, unread = [], []
    for adv in advs:
        state = affected(adv, v)
        if state:
            hits.append(adv)
        elif state is None:
            unread.append(adv)
    order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
    hits.sort(key=lambda x: (order.get(x["severity"], 9), x["published_at"]))
    print(f"Nginx UI {a.version}: {len(hits)} of {len(advs)} repository advisories apply")
    for adv in hits:
        ident = adv["cve_id"] or adv["ghsa_id"]
        print(f"  {adv['severity']:<8} {ident:<20} {adv['summary'][:70]}")
    for adv in unread:
        rng = adv["vulnerabilities"][0]["vulnerable_version_range"] if adv["vulnerabilities"] else ""
        print(f"  review   {adv['ghsa_id']:<20} range not machine-readable: {rng!r}")
    fixes = [ver(FIXED_IN.get(x["ghsa_id"]) or x["vulnerabilities"][0].get("patched_versions"))
             for x in hits if x["vulnerabilities"]]
    fixes = [f for f in fixes if f]
    if hits:
        print(f"  Highest listed fix among these: {'.'.join(map(str, max(fixes))) if fixes else 'none listed'}. "
              "Upgrade to the latest release.")
    if a.binary:
        check_binary(a.binary)
    sys.exit(1 if hits else 0)


if __name__ == "__main__":
    main()

We ran it on 16 October with the advisories saved that morning. For 2.3.3 it lists 41 advisories; here are 2.5.6, the last July release, and 2.8.4:

$ python3 nginx_ui_check.py 2.5.6 --advisories adv.json
Nginx UI 2.5.6: 9 of 59 repository advisories apply
  high     CVE-2026-107805      Unauthenticated signed-request body staging can exhaust temporary stor
  high     GHSA-gp79-j356-rr7m  WebSocket short tokens can access management APIs and renew after logo
  high     GHSA-rg7q-q2cr-372q  Write-scoped service tokens could mutate administrator accounts
  high     GHSA-x2x3-p8j2-63mx  MCP parser differential allows read-scoped tokens to invoke write tool
  medium   GHSA-49r7-63h8-8p86  External notification test requests lacked interactive secure-session 
  medium   CVE-2026-107804      Bundled reverse proxy can bypass IP allowlists and enable shared login
  medium   GHSA-94h5-jw58-qqq8  Demo-mode stored health checks could reach attacker-selected network t
  medium   GHSA-pw4f-pq6v-x57r  Read-scoped service tokens could disclose stored infrastructure creden
  medium   GHSA-xrj8-9g8p-mc7v  Passkey session identifiers could collide with shared cache entries
  review   GHSA-h6c2-x2m2-mwhf  range not machine-readable: 'all versions'
  Highest listed fix among these: 2.7.0. Upgrade to the latest release.

$ python3 nginx_ui_check.py 2.8.4 --advisories adv.json
Nginx UI 2.8.4: 0 of 59 repository advisories apply
  review   GHSA-h6c2-x2m2-mwhf  range not machine-readable: 'all versions'

The one advisory left for review is CVE-2026-33032, the unauthenticated MCP endpoint we covered in our MCPwn teardown. Its repository advisory says "all versions" and its CVE record lists versions up to 2.3.5 as affected, so any current release is past it. And the build-information check, run on our two test builds:

$ python3 nginx_ui_check.py 2.8.4 --advisories adv.json --binary nginx-ui-file-build
nginx-ui-file-build: build info main package command-line-arguments, module version NOT RECORDED
  Binary scanners have no Nginx UI version to match. Check the release number instead.

$ python3 nginx_ui_check.py 2.8.4 --advisories adv.json --binary nginx-ui-package-build
nginx-ui-package-build: build info main package example.com/acme/Panel, module version v1.9.10-0.20261016065828-dbc60133b781

What to do

  1. Upgrade to 2.8.4 or later. Releases since 2.7.0 include every fix the 2026 repository advisories list.
  2. Check by release number, with the script or by reading the repository advisories, not by trusting a scanner's silence or its findings.
  3. Record the false positives. If your scanner flags the Go database entries on a current release, suppress them with a written reason, or publish a VEX statement if you ship Nginx UI to others.
  4. Keep it off the internet. Most of this year's advisories need network access to the interface. Put it behind a VPN or an authenticating proxy, set an explicit IP allowlist for the MCP endpoint, and turn on two-factor authentication.
  5. Rotate secrets after upgrading from anything before 2.5.0. Several advisories exposed the node secret, DNS API tokens or ACME keys.

The fair view

Nginx UI's maintainers publish detailed advisories, credit reporters, and have shipped fixes steadily through a hard year. The module path isn't a security bug, and for an application nobody imports as a library, it rarely matters to anyone building it. GitHub's reviewers and the Go team are also doing the right thing by their own rules: pseudo-versions are how Go identifies an untagged commit, and "affects every version" is the safe default when nobody can say which release fixed something.

But the combination is a scanner that can't see the application, databases that describe fixes in versions nobody runs, and false positives that outlast the fix. That's the same outcome as having no data at all, and it applies to any Go application that ships v2 releases from a v1 module path, or builds its releases from a file. If your team relies on scan results for Go applications, test what your scanner actually reports for one you know, before you need it.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.