Safeguard
Vulnerability Analysis•

Hono CVE-2026-111757: serveStatic's second decode and the 4.13.11 fix

CVE-2026-111757: hono's serveStatic decoded paths twice, so requests could skip middleware. Fixed in 4.13.11. The Node.js adapter's GHSA-rmxm-3fg6-px4f has no CVE.

Safeguard Security Team
39 min read

CVE-2026-111757 lets a crafted request reach a static file in a Hono app without running the middleware mounted on that file's path, such as an authentication check. It affects hono 4.12.4 up to 4.13.11; update to hono 4.13.11 or later. The same flaw in the Node.js adapter, @hono/node-server 1.19.10 up to 2.1.3, is a separate advisory, GHSA-rmxm-3fg6-px4f, which has no CVE assigned; update the adapter to 2.1.3 or later.

The affected range starts exactly at the March fix for CVE-2026-29045, which CVE.org's record titles "Hono: Arbitrary file access via serveStatic vulnerability". That fix made serveStatic use the same decoder as the router, but it kept serveStatic decoding the path a second time, after Hono had already decoded it once for routing. The September fix is the first one that stops the second decode from mattering for security. It does so by refusing, by default, any path that still contains a percent sign after Hono's own decode.

Below: how the two decodes work, what 4.13.11 refuses to serve, the separate Node.js adapter advisory, a project checker, a stopgap for teams that cannot upgrade today, and what changed since 20 October.

The short answer

If you serve static files with Hono and protect part of that directory with path-based middleware, update. hono 4.12.4 to 4.13.10 and @hono/node-server 1.19.10 to 2.1.2 decode the request path a second time inside serveStatic. A request with a malformed percent-encoded sequence can be matched by the router as one path and served from disk as another, so middleware on the protected prefix never runs. The file is still read from inside the configured static root. The fix is in hono 4.13.11 and @hono/node-server 2.1.3, both published on 29 September 2026. There is no fixed @hono/node-server 1.x release. CVE-2026-111757 covers hono; the adapter's advisory is GHSA-rmxm-3fg6-px4f, which has no CVE assigned.

The fix changes behavior in one narrow way. serveStatic now refuses any request path that still contains a % after Hono's own decode. Encoded spaces and Unicode names are already decoded by then and keep working. What stops working by default is a file name with a literal percent sign, such as 100%.png, or with a character Hono's first decode leaves encoded, such as ? or #. Setting allowPercentInPath: true restores the old behavior, and the old risk, for that serveStatic instance.

What happened

Times are UTC where the source gives a time. Commit times are converted from the author's +0900 offset.

Date and time (UTC)EventSource
3 Mar 2026, 10:01Commit 6a0607a switches serveStatic from decodeURIComponent to tryDecodeURI, still applied to c.req.pathhono commit
3 Mar 2026, 10:23hono 4.12.4 published to npm with that changenpm registry
3 Mar 2026, 10:30@hono/node-server 1.19.10 published with the equivalent changenpm registry
3 Mar 2026GHSA-q5qw-h33p-qvwr (CVE-2026-29045) and, for the Node.js adapter, GHSA-wc8c-qw6v-h7f6 (CVE-2026-29087) published by the maintainersGitHub advisories
4 Mar 2026, 22:09CVE.org publishes CVE-2026-29045, "Hono: Arbitrary file access via serveStatic vulnerability"CVE.org
7 Apr 2026, 04:13hono 4.12.12 rejects single-dot segments and repeated slashes in serveStatic (CVE-2026-39407)npm, GitHub advisory
9 Jun 2026, 03:28hono 4.12.25 also rejects backslashes, for the Windows %5C issue (CVE-2026-54286)npm, GitHub advisory
28 Sep 2026, 08:24hono 4.13.10, the last affected releasenpm registry
29 Sep 2026, 06:01Fix commit 8b05c77: "Reject percent signs in routed request paths before additional decoding"hono commit
29 Sep 2026, 06:10@hono/node-server 2.1.3 published with the same fixnpm registry
29 Sep 2026, 06:15hono 4.13.11 publishednpm registry
29 Sep 2026GHSA-5r4p-p66f-jhc7 (hono) and GHSA-rmxm-3fg6-px4f (@hono/node-server) published by the maintainer, with GitHub releases noting each other's fixGitHub advisories and releases
13 Oct 2026, 13:27A second CVE Numbering Authority publishes CVE-2026-109955 for the same hono flawCVE.org
19 Oct 2026, 17:31CVE-2026-111757 (reserved 16 Oct) published on CVE.org; NVD lists it the same dayCVE.org, NVD
20 Oct 2026, 14:36GitHub reviews GHSA-5r4p-p66f-jhc7 into the GitHub Advisory Database, which is when npm audit and Dependabot start flagging itOSV, GitHub
20 Oct 2026, 15:18CISA's ADP adds an SSVC assessment: exploitation none, automatable yes, technical impact partialCVE.org

The fix shipped on 29 September, but the advisory database entry that drives most automated alerts arrived three weeks later. And the Node.js adapter's advisory has no CVE ID at all, so tools that only follow CVE feeds may never flag it.

Who is affected

Affected and fixed versions

PackageAffectedFixedAdvisoryCVE
hono (core serveStatic and the Bun, Deno and Cloudflare Workers adapters built on it)>= 4.12.4, < 4.13.114.13.11 (latest 4.13.13; 5.0.0-rc.1 also fixed)GHSA-5r4p-p66f-jhc7CVE-2026-111757, and CVE-2026-109955 for the same flaw
@hono/node-server (@hono/node-server/serve-static)>= 1.19.10, < 2.1.32.1.3 (latest 2.1.5)GHSA-rmxm-3fg6-px4fnone listed
@hono/node-server 1.x line1.19.10 to 1.19.17no 1.x fix; move to 2.1.3 or laterGHSA-rmxm-3fg6-px4fnone listed
hono below 4.12.4Not affected by this bugAffected by CVE-2026-29045 and later serveStatic CVEs insteadGHSA-q5qw-h33p-qvwr and othersCVE-2026-29045

The hono 4.13.11 release notes say the issue affects hono/serve-static "and the adapters built on it", naming hono/bun, hono/deno and hono/cloudflare-workers along with the separately published @hono/bun, @hono/deno and @hono/cloudflare-workers packages. If you use one of those, check which hono version it resolves to.

The conditions for exposure

All three of these must be true:

  1. An affected version is installed. That is hono 4.12.4 to 4.13.10 for Bun, Deno or Workers apps, or @hono/node-server 1.19.10 to 2.1.2 for Node.js apps using the adapter's own serveStatic.
  2. serveStatic serves a directory that contains files meant to be protected. A typical case is a single public root that holds both open assets and a reports or exports folder.
  3. The protection is middleware mounted on a path prefix inside that directory. For example, app.use('/reports/*', bearerAuth(...)) with app.use('/*', serveStatic({ root: './public' })).

The advisory states the impact as unauthorized disclosure of a static file that upstream middleware was meant to protect. An unauthenticated attacker can reach such a file without the middleware on its prefix running.

Who is not affected

  • Apps that serve only public files. The advisory says so directly.
  • Apps that serve protected files from their own route handlers, which check authorization and then read a file.
  • Files outside the static root. The advisory says the file "is still resolved inside the configured root"; this is not a traversal.
  • serveStatic with the path option, which serves one fixed file and never decodes the request path.

How many installs are on affected versions

npm's per-version counts for the most recent week show 48,983,610 hono downloads, 51.3% of them for affected versions and 43.2% for fixed ones. For @hono/node-server, 65.3% of 46,124,070 downloads were for affected versions, and 42.1% were for the 1.x releases that have no fix. Downloads are not deployments, and most apps do not mix protected and public files in one root, but those shares make the version check worth doing.

How it works, in plain language

Two decodes, by design

Hono decodes a request path before routing. Its getPath function scans the URL, and if it finds a percent sign it runs a tolerant version of the standard decodeURI over the path. Two details matter.

First, decodeURI never decodes reserved characters. An encoded slash (%2F), question mark (%3F), hash (%23) and a few others stay encoded after this step, so the router never mistakes an encoded slash for a real one.

Second, before decoding, getPath rewrites every %25, the encoding of a literal percent sign, to %2525, which the decode turns back into %25. So Hono deliberately keeps a literal percent sign encoded in c.req.path, and a string like %2541 cannot become a new escape sequence during routing.

The result is that c.req.path is "decoded once, except for literal percent signs, reserved characters and anything malformed". Both the router and serveStatic work from it.

serveStatic still needs a real file name. For a file called 100%.png, c.req.path holds 100%25.png, and only a second decode produces the name on disk. That is why serveStatic decoded again: it was how literal percent signs in file names got served.

The March bug and the March fix

Before 4.12.4, serveStatic used decodeURIComponent for its second decode. Unlike decodeURI, that function does decode %2F into a slash. So a request for /admin%2Fsecret.html was seen by the router as one segment that did not match /admin/*, while serveStatic decoded it to admin/secret.html and served it. The GitHub advisory for CVE-2026-29045 describes exactly this. CVE.org's record titles it "Arbitrary file access", and the same advisory makes clear that access stays inside the static root and "is not a path traversal vulnerability". The practical harm was the same as now: protected static files served without their middleware.

The March fix replaced decodeURIComponent with the same tolerant decodeURI the router uses. That closed the encoded-slash route, because %2F now stays %2F in both places. But serveStatic still applied a second decode to a value that had already been decoded once.

The September bug

Decoding twice is safe only if the first pass leaves nothing that the second pass would turn into something new. The advisory explains how that breaks: "With a malformed percent-encoded sequence, the first decode leaves part of the path encoded, the router matches the encoded form, and the second decode resolves the real directory."

The tolerant decoder is the reason. When decodeURI throws on malformed input, Hono's tryDecode falls back to decoding only the well-formed runs of percent escapes and leaving the rest alone. A carefully malformed path can come out of that first pass containing a valid percent escape that was not visible as a whole in the original request. The router compares that partly encoded string with /reports/*, sees no match, and skips the middleware. serveStatic decodes again, the escape becomes an ordinary letter, and the result is a path inside the protected folder.

Both CVE records assign CWE-174, "Double Decoding of the Same Data". We are not publishing a request that triggers the bypass; the mechanism above is enough to understand the fix and to look for probes in logs.

What the September fix does differently

The March fix tried to make two decoders agree. The September fix stops relying on agreement. Before the second decode, serveStatic now checks whether c.req.path contains a percent sign at all. If it does, and allowPercentInPath is not set, it treats the request as not found. Since c.req.path only contains a percent sign when the first decode left something encoded, a path that would need a second decode is never decoded a second time.

Technical details

Identifiers and scores

ItemValueSource
GitHub advisory (hono)GHSA-5r4p-p66f-jhc7, ModerateGitHub Advisory Database
GitHub advisory (@hono/node-server)GHSA-rmxm-3fg6-px4f, Moderate, "No known CVE"honojs/node-server advisory
CVE assigned by GitHubCVE-2026-111757, published 19 Oct 2026 17:31 UTCCVE.org
CVE assigned by a second CNACVE-2026-109955, published 13 Oct 2026 13:27 UTC, date public 29 Sep 2026CVE.org
OSV aliases for the GHSACVE-2026-109955 and CVE-2026-111757OSV
CWECWE-174, Double Decoding of the Same Databoth CVE records
CVSS 3.15.3 Medium, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:Nboth CVE records, GitHub, NVD (as secondary)
CVSS 4.06.9 Medium, CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:NCVE-2026-109955 record only
EPSS0.223%, 12th percentile, as shown on 25 OctGitHub Advisory Database page
CISA SSVCExploitation none, Automatable yes, Technical Impact partialCISA ADP on the CVE-2026-111757 record
NVD statusDeferredNVD
CISA KEVNot listed (catalog 2026.10.23)CISA KEV JSON feed
Reporter creditmanop55555GitHub advisory, CVE-2026-109955 record
Fix commit8b05c774ef1555c70f5ba5e2991b7e77cc3635cc (hono)CVE.org reference, GitHub
Release tagshono v4.13.11 at 37ce069; @hono/node-server v2.1.3 at 720ac78GitHub releases

Both CVE records describe the same versions, weakness and reporter. If your scanner shows one ID and your ticket system the other, they are the same issue; GitHub's advisory and NVD use CVE-2026-111757.

The CVSS 3.1 vector rates confidentiality impact Low, where the March record (7.5) rated it High. For your own app, the real impact depends on what sits behind the bypassed middleware: a protected folder of signed contracts matters more than one of draft images.

The fix, line by line

The functional change in src/middleware/serve-static/index.ts is three lines plus a new option. In the published 4.13.11 build it reads:

if (!options.allowPercentInPath && c.req.path.includes("%")) throw new Error();
filename = tryDecodeURI(c.req.path);
if (/(?:^|[\/\\])\.{1,2}(?:$|[\/\\])|[\/\\]{2,}|\\/.test(filename)) throw new Error();

The thrown error lands in an existing catch block that calls onNotFound with c.req.path and then next(). So serveStatic does not answer a refused request itself; it falls through to the next handler, usually the default not-found response.

The type definition adds allowPercentInPath?: boolean, documented as "Defaults to false." The @hono/node-server 2.1.3 build carries the same three lines. The commit also changes the March test: /admin%2Fsecret.txt, previously served as a file literally named admin%2Fsecret.txt, now gets a 404.

What 4.13.11 serves and refuses

We ran the published hono 4.13.10 and 4.13.11 packages from npm against a stub file reader, with serveStatic mounted on /static/* and a catch-all after it. No real files were involved.

Request path4.13.104.13.11 default4.13.11 with allowPercentInPath: true
/static/hello%20world.txtserved as hello world.txtserved as hello world.txtserved
/static/%E7%82%8E.txt (a kanji file name)servedservedserved
/static/plain.txtservedservedserved
/static/100%25.png (literal % in the name)served as 100%.pngrefused, falls throughserved as 100%.png
/static/a%3Fb.txt (encoded ?)served as a%3Fb.txtrefused, falls throughserved
/static/a%2Fb.txt (encoded /)served as a%2Fb.txtrefused, falls throughserved

The middle column is the one to remember. Encoded spaces and Unicode are decoded before serveStatic looks at the path, so they never contain a % by the time the new check runs. The fix only affects paths where a percent sign survives Hono's own decode: literal percent signs in file names, reserved characters that decodeURI keeps encoded, and malformed sequences.

Why the earlier path checks are not new in 4.13.11

Our 20 October version said 4.13.11 also began rejecting single-dot segments, repeated slashes and backslashes. That was wrong. The published npm packages show the regular expression above unchanged between 4.13.10 and 4.13.11. Its parts arrived earlier:

hono versionDatePath check after decodingAdvisory
4.12.3 and earlierbefore 3 Mar 2026decodeURIComponent, rejects .. segments(CVE-2026-29045 applies)
4.12.4 to 4.12.11from 3 Mar 2026tryDecodeURI, rejects .. segmentsCVE-2026-29045 fixed
4.12.12 to 4.12.24from 7 Apr 2026also rejects . segments and repeated slashesCVE-2026-39407
4.12.25 to 4.13.10from 9 Jun 2026also rejects backslashesCVE-2026-54286
4.13.11 and laterfrom 29 Sep 2026refuses any % left after Hono's decode before decoding againCVE-2026-111757

The @hono/node-server adapter followed the same path: decodeURIComponent up to 1.19.9, tryDecodeURI from 1.19.10, repeated-slash rejection from 1.19.13 (CVE-2026-39406), the Windows backslash fix in 1.19.15 and 2.0.5 (GHSA-frvp-7c67-39w9, no CVE), and the percent check only in 2.1.3.

Package integrity values

If you pin by integrity hash or verify tarballs in a private registry, these are the values npm reports for the fixed and current releases.

Package and versionnpm integrity (SHA-512)
hono 4.13.11sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg==
hono 4.13.13sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==
@hono/node-server 2.1.3sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==
@hono/node-server 2.1.5sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==

What to look for in logs

There are no published indicators of compromise for this issue, and CISA's SSVC entry records no exploitation. If you want to check whether anyone probed your protected static paths before you upgraded, the shape to look for is a request to a static prefix whose path contains a percent sign that is not followed by two hex digits, or contains %25. Normal browsers rarely send either. On a combined-format access log:

# Requests to static prefixes with a malformed or double-encoded percent sign
grep -E '"(GET|HEAD) /(static|public|assets|reports|downloads)/[^ "]*%([^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|25)' access.log \
  | awk '{print $1, $4, $7, $9}' | sort | uniq -c | sort -rn | head -50

Adjust the prefixes to the ones your app serves. A hit with a 200 status on a path under a protected folder deserves a closer look. A hit is not proof of exploitation, because scanners and broken clients also send odd encodings.

The run of serveStatic fixes this year

This is the fourth serveStatic fix in hono core in 2026 where a request reached a protected static file without its middleware, all with the same root cause: the router and the file lookup disagreed about what a path meant.

PublishedAdvisoryCVEWhat the router saw versus what serveStatic servedFixed in
3 Mar 2026GHSA-q5qw-h33p-qvwrCVE-2026-29045 (7.5)Encoded slash kept by router, decoded by serveStatic4.12.4
7 Apr 2026GHSA-wmmm-f939-6g9cCVE-2026-39407 (5.3)Repeated slashes not matched by router, normalized by file lookup4.12.12
9 Jun 2026GHSA-wwfh-h76j-fc44CVE-2026-54286 (5.9)Encoded backslash is one segment to the router, a separator to Windows4.12.25
29 Sep 2026GHSA-5r4p-p66f-jhc7CVE-2026-111757 (5.3)Malformed encoding matched in partly encoded form, decoded again by serveStatic4.13.11

The Hono team fixed each one quickly, with advisories that state the limits honestly. Still, each fix closed one way for the two layers to disagree, and the next report found another. The September fix is different in kind: it refuses input that needs a second interpretation instead of trying to make the second interpretation match. (In January, CVE-2026-24473 fixed a separate key-read issue in the Workers adapter's serveStatic.)

The design lesson: prefix middleware in front of a static file server is a fragile way to protect files. Each of these bugs needed only that combination. We covered the March bug in our post on CVE-2026-29045, and the same class of bug in another framework in our Next.js middleware bypass deep dive.

The @hono/node-server 1.x problem

Node.js apps usually serve static files with @hono/node-server/serve-static, which is a separate implementation from the one in hono core. Updating hono alone does not fix it.

The adapter's advisory lists 1.19.10 up to 2.1.3 as affected, and 2.1.3 as the only patched version. The newest 1.x release, 1.19.17 from 27 July, still decodes twice without the percent check. Versions 1.19.10 to 1.19.17 drew about 19.4 million downloads in the most recent week.

Moving to 2.x has one hard requirement in the package metadata: 1.19.17 declares node >= 18.14.1, while 2.1.3 declares node >= 20. Both lines accept hono 4 as a peer dependency. If you cannot move this week, wrap the adapter's serveStatic with the stopgap below; it works on 1.x too.

How to check whether you are exposed

Step 1: find the installed versions

# npm
npm ls hono @hono/node-server --all

# pnpm (all workspace packages)
pnpm -r why hono
pnpm -r why @hono/node-server

# Yarn (v1 and Berry)
yarn why hono
yarn why @hono/node-server

# Bun
bun pm ls --all | grep -E 'hono@|@hono/node-server@'

# Deno: look for npm:hono or jsr:@hono/hono in deno.lock
grep -E '"(npm:)?hono@|@hono/hono@|@hono/node-server@' deno.lock

For a container image, check the lockfile that went into it, or read the installed versions directly (images without a shell need the lockfile route):

docker run --rm --entrypoint sh my-api:latest -c \
  'find / \( -path "*/node_modules/hono/package.json" -o -path "*/node_modules/@hono/node-server/package.json" \) 2>/dev/null | xargs grep -H "\"version\""'

Step 2: run the project checker

This script reads package-lock.json, pnpm-lock.yaml, yarn.lock and bun.lock for both packages, then lists every serveStatic call, every allowPercentInPath: true, every middleware mounted on a path prefix, and every file with a % in its name. It does not decide whether a middleware prefix overlaps a static root; you do that by reading the lines it prints. Standard library only.

#!/usr/bin/env python3
"""Check a project for CVE-2026-111757 (hono) and GHSA-rmxm-3fg6-px4f
(@hono/node-server): the second decode in serveStatic.

Reads package-lock.json, pnpm-lock.yaml, yarn.lock and bun.lock, then lists
serveStatic calls, allowPercentInPath opt-ins, prefix-mounted middleware and
file names that 4.13.11 / 2.1.3 will stop serving by default.
Standard library only. Usage: python3 hono_static_check.py PROJECT_DIR
"""
import json, pathlib, re, sys

RANGES = {  # package: (first affected, first fixed, note)
    "hono": ((4, 12, 4), (4, 13, 11), "update to 4.13.11 or later"),
    "@hono/node-server": ((1, 19, 10), (2, 1, 3),
                          "update to 2.1.3 or later (no fixed 1.x; needs Node.js 20+)"),
}
SRC = (".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx")
SKIP = {"node_modules", ".git", "dist", "build", ".next", ".wrangler"}


def vt(v):
    m = re.match(r"(\d+)\.(\d+)\.(\d+)", v)
    return tuple(map(int, m.groups())) if m else None


def versions(root):
    found = set()
    lock = root / "package-lock.json"
    if lock.exists():
        for key, meta in json.loads(lock.read_text()).get("packages", {}).items():
            for name in RANGES:
                if key.endswith("node_modules/" + name) and "version" in meta:
                    found.add((name, meta["version"], lock.name))
    pats = {
        "pnpm-lock.yaml": r"^\s+'?/?(@hono/node-server|hono)@(\d+\.\d+\.\d+)",
        "bun.lock": r"\"(@hono/node-server|hono)@(\d+\.\d+\.\d+)\"",
    }
    for fname, pat in pats.items():
        f = root / fname
        if f.exists():
            for name, ver in re.findall(pat, f.read_text(), re.M):
                found.add((name, ver, fname))
    y = root / "yarn.lock"
    if y.exists():
        for block in y.read_text().split("\n\n"):
            head = re.match(r'^"?(@hono/node-server|hono)@', block.strip())
            ver = re.search(r'^\s+version:?\s+"?(\d+\.\d+\.\d+)', block, re.M)
            if head and ver:
                found.add((head.group(1), ver.group(1), "yarn.lock"))
    return sorted(found)


def main(root):
    root = pathlib.Path(root)
    for name, ver, src in versions(root):
        lo, fixed, note = RANGES[name]
        t = vt(ver)
        if t is None:
            continue
        verdict = (f"AFFECTED, {note}" if lo <= t < fixed else
                   "fixed" if t >= fixed else
                   "older line: check the earlier serveStatic advisories, then update")
        print(f"{name} {ver} ({src}): {verdict}")
    statics, opt_in, prefixed, odd_names = [], [], [], []
    for p in sorted(root.rglob("*")):
        if SKIP & set(p.relative_to(root).parts):
            continue
        if p.is_file() and "%" in p.name:
            odd_names.append(str(p.relative_to(root)))
        if p.suffix not in SRC or not p.is_file():
            continue
        for n, line in enumerate(p.read_text(errors="replace").splitlines(), 1):
            loc = f"{p.relative_to(root)}:{n}"
            if "serveStatic(" in line:
                statics.append(loc)
            if re.search(r"allowPercentInPath\s*:\s*true", line):
                opt_in.append(loc)
            m = re.search(r"\.(?:use|all|get|on)\(\s*['\"`](/[^'\"`]+)['\"`]\s*,", line)
            if m and m.group(1) not in ("/*", "*", "/") and "serveStatic(" not in line:
                prefixed.append(f"{loc}  {m.group(1)}")
    def show(title, items, hint=""):
        print(f"{title}: {len(items)}" + "".join(f"\n  {i}{hint}" for i in items))
    show("serveStatic calls", statics)
    show("allowPercentInPath: true", opt_in, "  (old decoding is back on; review)")
    show("prefix-mounted middleware", prefixed)
    show("files with % in the name (404 by default after the fix)", odd_names)


if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else ".")

On a small Node.js fixture with a token-protected /reports/* prefix, a site-wide serveStatic from the adapter, and a downloads directory that opts back into percent signs:

$ python3 hono_static_check.py app
@hono/node-server 1.19.14 (package-lock.json): AFFECTED, update to 2.1.3 or later (no fixed 1.x; needs Node.js 20+)
hono 4.13.4 (package-lock.json): AFFECTED, update to 4.13.11 or later
serveStatic calls: 2
  src/index.ts:7
  src/index.ts:8
allowPercentInPath: true: 1
  src/index.ts:8  (old decoding is back on; review)
prefix-mounted middleware: 1
  src/index.ts:6  /reports/*
files with % in the name (404 by default after the fix): 1
  public/downloads/100%-cotton-spec.pdf

Read it this way. The /reports/* line plus a site-wide serveStatic on the same root is the exposed pattern: on these versions a crafted request could reach public/reports/q3.pdf without the bearer token. Note that the Node.js app is exposed through the adapter's serveStatic, so updating hono alone would not have fixed it. The allowPercentInPath line is fine only if nothing under /downloads is protected. The file name with a % tells you what will break after the upgrade unless you rename it or opt in.

Step 3: map prefixes to static roots

For each serveStatic call, note its mount pattern and root; for each prefix middleware, its pattern. A middleware pattern inside a serveStatic mount, guarding files under that root, is the exposed combination.

Step 4: confirm on staging after the upgrade

After upgrading, check three things on a staging host:

# 1. The protected prefix still demands credentials
curl -s -o /dev/null -w '%{http_code}\n' https://staging.example.com/reports/q3.pdf

# 2. Ordinary encoded names still work
curl -s -o /dev/null -w '%{http_code}\n' 'https://staging.example.com/docs/Q3%20summary.pdf'

# 3. Names with a literal percent sign now fall through (expect 404 unless you opted in)
curl -s -o /dev/null -w '%{http_code}\n' 'https://staging.example.com/downloads/100%25-cotton-spec.pdf'

Expect 401 or 403 for the first, 200 for the second, and 404 for the third unless you deliberately set allowPercentInPath: true for that mount.

What to do now

  1. Update hono to 4.13.11 or later. The current release is 4.13.13.

    npm install hono@^4.13.13
    pnpm up -r hono@^4.13.13
    yarn up hono@^4.13.13      # Yarn Berry; use `yarn upgrade` on Yarn 1
    bun add hono@^4.13.13
    

    For Deno, update the npm:hono or jsr:@hono/hono specifier in deno.json and run deno install to refresh deno.lock.

  2. Update @hono/node-server to 2.1.3 or later if you serve static files on Node.js. The current release is 2.1.5. On the 1.x line there is no fix, so this is a major version move, and it needs Node.js 20 or newer.

    npm install @hono/node-server@^2.1.5
    
  3. Rebuild and redeploy. A lockfile change does nothing until the image, bundle or Worker that runs in production is rebuilt from it.

  4. Review every allowPercentInPath: true. It turns the old decoding back on for that serveStatic instance, bypass included. Keep it only on mounts where no path-based middleware protects anything below.

  5. Rename files with a literal %, ? or # in their names, or serve them from a dedicated mount that opts in and protects nothing. Renaming is usually simpler and avoids the question entirely.

  6. Move protected files out of the public root. The most durable fix is not to have protected files in a directory that serveStatic exposes at all. Serve them from a route handler that checks authorization itself (see the pattern below).

  7. Search your logs for the request shape above on protected prefixes, covering the period your app ran an affected version. For many teams that is since March.

A stopgap if you cannot upgrade today

This wrapper does what 4.13.11 does, in your own code: a path that still contains % skips static serving and falls through to the next handler, so API routes with encoded parameters keep working. It works on any affected hono version and around the @hono/node-server 1.x serveStatic.

import type { MiddlewareHandler } from 'hono'
import { serveStatic } from '@hono/node-server/serve-static' // or 'hono/bun', 'hono/deno'

// Mirrors the hono 4.13.11 / @hono/node-server 2.1.3 check.
const guardedStatic = (handler: MiddlewareHandler): MiddlewareHandler =>
  async (c, next) => (c.req.path.includes('%') ? next() : handler(c, next))

// Mount it exactly where serveStatic was mounted.
app.use('/*', guardedStatic(serveStatic({ root: './public' })))

The guard has to sit on the serveStatic mount, not on the protected prefix. A crafted request does not match the protected prefix; that is the whole bug. Like the official fix, this stops serving files whose names contain a literal percent sign.

A sturdier pattern for protected files

Rather than relying on prefix middleware in front of a static directory, keep protected files outside any serveStatic root and serve them from a handler that checks authorization and validates the file name itself:

import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { bearerAuth } from 'hono/bearer-auth'

const REPORTS_DIR = '/srv/app/private/reports'   // not under ./public
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/

app.get('/reports/:name', bearerAuth({ token: process.env.REPORTS_TOKEN! }), async (c) => {
  const name = c.req.param('name')
  if (!SAFE_NAME.test(name)) return c.notFound()
  try {
    const body = await readFile(join(REPORTS_DIR, name))
    return c.body(body, 200, { 'Content-Type': 'application/pdf' })
  } catch {
    return c.notFound()
  }
})

Here the authorization check and the file lookup sit in one handler, working from one decoded parameter. There is no second layer that can read the path differently, and the name check rules out slashes, dots at the start and anything encoded.

Common mistakes

  • Updating hono and assuming Node.js apps are fixed. @hono/node-server/serve-static is its own implementation with its own advisory. On Node.js, the adapter version is the one that matters for this bug.
  • Staying on @hono/node-server 1.x and waiting for 1.19.18. No fixed 1.x release exists. Plan the move to 2.x, which needs Node.js 20 or later, and use the stopgap until then.
  • Thinking 4.12.4 is safe because it fixed CVE-2026-29045. The affected range of CVE-2026-111757 starts at exactly that release. Every version from the March fix up to 4.13.10 is affected.
  • Reading the fix as "Hono no longer decodes static paths". It still decodes them once, before routing, and serves encoded spaces and Unicode names as before. It refuses only paths that still contain a percent sign after that decode.
  • Turning on allowPercentInPath: true globally to fix one broken download link. That restores the bypass for every file under that mount. Rename the file, or scope the opt-in to a mount that protects nothing.
  • Counting on a 404 from serveStatic. A refused request falls through to the next handler, so an SPA fallback that returns index.html will answer instead. Harmless, but it can confuse tests.
  • Treating CVE-2026-109955 and CVE-2026-111757 as two issues, or dismissing one as a false positive. They are the same flaw.

What changed since 20 October

  • A second CVE ID. CVE-2026-109955, published on 13 October by a second CVE Numbering Authority, covers the same flaw. OSV lists both IDs as aliases of GHSA-5r4p-p66f-jhc7. Our first version missed it.
  • The Node.js adapter advisory. GHSA-rmxm-3fg6-px4f covers @hono/node-server 1.19.10 up to 2.1.3, with no CVE ID and no fixed 1.x release. For Node.js apps it is the advisory that matters.
  • A correction. The single-dot, repeated-slash and backslash checks arrived in 4.12.12 and 4.12.25, not 4.13.11. The only functional change in 4.13.11 is the percent check and allowPercentInPath.
  • March framing aligned with the records. CVE.org titles CVE-2026-29045 "Hono: Arbitrary file access via serveStatic vulnerability"; its GitHub advisory adds that access stays inside the static root.
  • Record status. CISA's SSVC assessment was added on 20 October. NVD shows the entry as Deferred, so the only score is GitHub's 5.3.
  • Newer releases. hono 4.13.13 is latest and 5.0.0-rc.1 is on next, both fixed. @hono/node-server is at 2.1.5.
  • Still no reported exploitation, and no KEV listing as of the 23 October catalog.

Open questions

  • Will the duplicate CVE be rejected? Two published records for one flaw usually end with one marked as a duplicate. Until then, expect some tools to show one ID and some the other.
  • Will @hono/node-server get a CVE ID or a 1.x backport? Neither existed on 25 October. A CVE ID would make the advisory visible to more scanners. A backport would spare Node.js 18 users a runtime upgrade.
  • Will NVD score it? The entry is Deferred, so NVD-driven tooling sees only GitHub's secondary score, if anything.

What would have helped

Keep protected files out of public static roots. Every one of this year's serveStatic advisories needed protected and public files in the same directory, guarded only by prefix middleware. Apps that served protected files from handlers were never exposed to any of them. Our guide to broken access control in Node.js apps covers handler-level authorization patterns that carry over to Hono.

Know where each framework and adapter version runs. The fastest response to this advisory is a list of services with their hono and @hono/node-server versions, runtime and owner. Building that list from SBOMs before you need it is covered in our post on finding affected products fast with SBOMs.

Take patch releases on a schedule, not on alerts. The fix shipped on 29 September. The GitHub Advisory Database entry that drives most automated alerts arrived on 20 October. Projects that took routine patch updates in early October were fixed three weeks before any alert fired.

Track advisory IDs, not just CVE IDs. The Node.js adapter's advisory has no CVE. Processes that key on GHSA or OSV identifiers catch it; CVE-only processes do not.

Pin through lockfiles and stay on supported runtimes. A committed lockfile makes the check and the fix a reviewable diff (see our npm lockfile practices for 2026). Teams on Node.js 20 or later could move the adapter to 2.x in one step.

Where Safeguard fits

Safeguard's scanner builds SBOMs and dependency inventories for repositories, container images and SBOM files, for example with safeguard scan --dir ., safeguard scan --image <name> or safeguard scan --sbom <file>, and writes results as JSON under ./output. The platform then shows which projects contain a given component and version, such as hono 4.13.4 or @hono/node-server 1.19.14, and matches components against vulnerability databases as those databases carry these advisories. Reachability analysis helps rank which vulnerable code paths your application actually calls.

Policies and gates evaluated in the platform can stop a build that still resolves an affected version. When a fixed version exists, autonomous remediation can open fix pull requests that move hono and @hono/node-server to safe versions; it ships off by default.

The Safeguard CLI's assistant can do the hands-on work with your approval: run npm ls across a workspace, update the packages, add the stopgap guard in front of serveStatic, rename files with a literal %, rebuild and run your tests, asking before any command or file edit that changes something, under the /permissions mode you choose. In CI, safeguard exec runs the same kind of task non-interactively.

Where Safeguard does not help: it does not see your live traffic and cannot block a crafted request at your edge. Dependency data tells you an affected version is present; it does not tell you whether a prefix middleware overlaps a static root in your code, which is why the checker and the manual mapping step above still matter. Safeguard would not have prevented this bug.

Zero-Day Discovery and Gold

Zero-Day Discovery looks for previously unknown flaws before a CVE exists, in an agentic mode and a deterministic scanner mode, and issues findings as SGZ advisories. We make no claim that it played any part in this bug or that an SGZ advisory existed for it; the GitHub advisory credits the reporter manop55555, and the Hono maintainers wrote and shipped the fix. We also make no claim that Gold holds a record for these CVEs or a patched @hono/node-server 1.x build. Check gold.safeguard.sh yourself rather than relying on this post. The Gold section below explains what it is for.

FAQ

Which versions fix CVE-2026-111757?

hono 4.13.11 and later, including 4.13.13 and 5.0.0-rc.1. CVE-2026-111757 covers hono only. Node.js apps using the adapter's static middleware need the fix for GHSA-rmxm-3fg6-px4f, which has no CVE assigned: @hono/node-server 2.1.3 and later, including 2.1.5. There is no fixed @hono/node-server 1.x release.

We updated to 4.12.4 in March for CVE-2026-29045. Are we safe?

No. CVE-2026-111757 affects every hono release from 4.12.4 up to 4.13.10. The March fix is where the affected range begins. Update to 4.13.11 or later.

Why do some tools show CVE-2026-109955 instead?

Two CVE Numbering Authorities published records for the same flaw. CVE-2026-109955 appeared on 13 October and CVE-2026-111757, assigned by GitHub, on 19 October. They describe the same versions, weakness and reporter. Treat them as one issue.

After updating, a file called 100%.png returns not found, but "Q3 summary.pdf" still works. Why?

Hono decodes the path once before routing, so the %20 in the second name is already a space when serveStatic looks at it. A literal percent sign is deliberately kept encoded as %25 by that first decode, so the first name still contains a %, and 4.13.11 refuses it unless allowPercentInPath is true. Rename the file, or opt in only on a mount that no path-based middleware protects.

Does the fix break encoded spaces or non-English file names?

No. Both are decoded before the new check runs. The fix commit added tests confirming that hello%20world.txt and a kanji file name are still served. Only names with a literal % or a reserved character that stays encoded, such as ? or #, are affected.

Is this a path traversal?

No. The advisory says the file is still resolved inside the configured static root. The harm is reading files inside the root that your middleware was meant to protect. Apps that serve only public files are not affected.

Is CVE-2026-111757 being exploited?

We found no report of exploitation. CISA's SSVC assessment on the CVE record says exploitation "none", and none of these CVEs is on CISA's KEV list as of 23 October. The fix commit's tests and the advisory describe the mechanism, so assume it is easy to reproduce for anyone who looks.

We run Hono on Cloudflare Workers. Are we affected?

The 4.13.11 release notes list hono/cloudflare-workers among the adapters built on the affected serveStatic. If your Worker uses that adapter's serveStatic with prefix middleware over the same assets, update hono and redeploy. Many Workers apps serve static assets through the platform's own asset handling instead, which does not use Hono's serveStatic.

Fix it without waiting for upstream

For hono and @hono/node-server 2.x the fix already exists, and the job is getting it into every build that serves static files. For @hono/node-server 1.x there is no fixed release, which is the second gap described below.

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.

3. Gold Registry: start from components that are already fixed

registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would have helped

None of these demos shows this Hono CVE. Each shows a real flow on another example, and the notes explain how the same flow applies here.

The CLI demo fixes a lodash CVE: the assistant reads the finding and the advisory, asks before any command that changes something, edits package.json, runs npm install and npm test with your approval, prints a summary and pushes nothing. For this CVE you would run the same kind of session in your Hono project and ask it to move hono to 4.13.13 and @hono/node-server to 2.1.5, add the percent guard in front of any serveStatic you cannot upgrade yet, and run your tests, approving each step.

Click through it at your own pace, or open it full screen.

The desktop app demo sends work to a computer running the CLI: you pick the folder, then Approve or Deny each command and file edit, and it ends with an upgrade done and tests passing. Here that could be the @hono/node-server 1.x to 2.x move, where you would want to review the package.json change, the Node.js version bump in your Dockerfile and the test run one at a time.

Click through it at your own pace, or open it full screen.

The Claude demo connects Claude to the Safeguard MCP server with OAuth and lets you choose which tools Claude can use. With that in place you could ask Claude which of your projects contain hono between 4.12.4 and 4.13.10, or @hono/node-server on the 1.x line, and get the list from your own Safeguard data.

Click through it at your own pace, or open it full screen.

To try it on your own code, download Safeguard and follow the CLI docs.

Quick check

Your Node.js app uses hono 4.13.13 and @hono/node-server 1.19.17, with serveStatic imported from @hono/node-server/serve-static. Is it fixed?

No. On Node.js the adapter's own serveStatic is the one that runs, and @hono/node-server 1.19.10 to 2.1.2 is affected under GHSA-rmxm-3fg6-px4f. There is no fixed 1.x release. Move to 2.1.3 or later, which needs Node.js 20, and use the percent guard until you can.

After updating to hono 4.13.11, which of these still gets served by default: "Q3 summary.pdf", "100%.png" or "a?b.txt"?

Only "Q3 summary.pdf". Its encoded space is decoded before serveStatic checks the path. The literal % stays encoded as %25 after Hono's own decode, and ? stays encoded as %3F, so both still contain a percent sign and are refused unless allowPercentInPath is true.

You add a guard that rejects '%' in paths, mounted on '/reports/*' where your auth middleware sits. Does it stop the bypass?

No. The crafted request does not match /reports/* in the first place; that is why the auth middleware is skipped. Apply the guard where serveStatic is mounted, for example by wrapping serveStatic as shown in the stopgap above, so it runs for every static request.

Sources

Last reviewed: 25 October 2026. This is a developing story; check the linked advisories for the latest status.

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.