Local file inclusion In github.com/xyproto/algernon

Description

Algernon: handler.lua discovery walks parent directories above the server root ### 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(<ancestor>/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/<name> — 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/<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. 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 <<'EOF' -- Runs in the Algernon process; whoami leaks the process owner. local out, _, _

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Package
Affected version
Patched versions