GitHub Advisoryadvisory · Go
GHSA-xwcr-wm99-g9jc Algernon: handler.lua discovery walks parent directories above the server root
CRITICAL
Official page ↗
- Aliases
- CVE-2026-45721
- Ecosystem
- Go
- Packages
- github.com/xyproto/algernon
- Severity
- Critical
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
- Weaknesses
- CWE-20, CWE-426, CWE-552
- Reviewed
- 2026-05-19
Affected packages
• Go github.com/xyproto/algernon: < 1.17.7 → fixed in 1.17.7
Description
Summary
When 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.
This 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.
Details
Root cause — unbounded upward search in `DirPage`
```go
// engine/dirhandler.go:170-183
// Serve handler.lua, if found in parent directories
var ancestor string
ancestor = filepath.Dir(dirname)
for range 100 { // a maximum of 100 directories deep
filename = filepath.Join(ancestor, "handler.lua")
if ac.fs.Exists(filename) {
ac.FilePage(w, req, filename, luaDataFilename)
return
}
if ancestor == "." {
break
}
ancestor = filepath.Dir(ancestor)
}
```
`dirname` is the absolute path of the requested directory on disk, e.g. `/srv/algernon/` when running with `--prod` (see engine/config.go:207). `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(/handler.lua)`.
For the canonical `--prod` invocation the candidate set is:
```
/srv/handler.lua
/handler.lua
```
For `algernon /var/www/example.com`:
```
/var/www/handler.lua
/var/handler.lua
/handler.lua
```
For `algernon ~/site` started by user `alice`:
```
/home/alice/handler.lua
/home/handler.lua
/handler.lua
```
The 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.
Why the request is reachable unauthenticated
The permission middleware in `RegisterHandlers` runs before `DirPage` but only rejects requests whose `req.URL.Path` matches an admin/user prefix:
```go
// engine/handlers.go:510-525
allRequests := func(w http.ResponseWriter, req *http.Request) {
if ac.perm != nil {
if ac.perm.Rejected(w, req) {
sc := sheepcounter.New(w)
ac.perm.DenyFunction()(sc, req)
ac.LogAccess(req, http.StatusForbidden, sc.Counter())
return
}
}
...
```
`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.
Three exploit-amenable scenarios:
1. 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/` — anything writable at `~/sites/` (or `~/`) escalates into every instance.
2. CI runners, container images, dev workstations. A repository or container that contains *any* `handler.lua` at root, in `/srv`, in `/var`, or in `/home/` — 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.
3. 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.
Source-level evidence
```text
$ rg -n 'handler\.lua' engine/
engine/dirhandler.go:170: // Serve handler.lua, if found in parent directories
engine/dirhandler.go:174: filename = filepath.Join(ancestor, "handler.lua")
$ rg -n 'run3|os\.execute|exec\.Command' engine/basic.go lua/run3/
engine/basic.go:142: command := L.ToString(1)
engine/basic.go:144: return run3.ShellHelper(L, command, workingDir)
lua/run3/run3.go:23: cmd := exec.Command("sh", "-c", command)
$ rg -n 'lua\.NewState|skip(?:_)?open_libs|OpenLibs' lua/pool/ engine/
lua/pool/pool.go:34: L := lua.NewState()
No skip-libs flag is set — gopher-lua loads os, io, debug, package by default.
```
The Lua state pool issues states with stock library loading (no `SkipOpenLibs` option in 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.
PoC
Variant A — confused-deputy via shared parent
```bash
Operator runs Algernon serving a directory under /srv:
sudo mkdir -p /srv/site && echo 'hi' > /srv/site/index.html
algernon --prod /srv/site & # binds :3000
Attacker (any account with write to /srv) drops handler.lua one level up:
cat > /srv/handler.lua &1 | head -3")
print(out)
EOF
Trigger from anywhere on the network — any directory URL that lacks an
index.* file inside /srv/site fires the parent walk. The cleanest trigger
is to request a non-existent subdir:
curl -i http://server:3000/nope/
=> Algernon executes /srv/handler.lua. Response body is the captured stdout
of `id` and the first lines of /etc/shadow (if Algernon runs as root,
or the targeted file is readable b…
Related
Alias · CVE (1)
- CVE-2026-45721 Algernon is a small self-contained pure-Go web server. vulnerability