GHSA-fwqx-8365-9983 Algernon: Single-file mode unconditionally enables debug mode
SummaryWhen 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:`debugMode` activates the `PrettyError` renderer, which on any Lua or template error response dumps:The **absolute path** of the file that errored (`Filename` field of the error template).The **complete byte contents** of that file, HTML-escaped, with the offending line wrapped in `<font style='color: red !important'>…</font>`.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.DetailsRoot cause 1 — single-file detection forces `debugMode = 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 directionThis 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:On paper step 3 wins. In practice the operator-controlled execution path through `MustServe` for `.lua` files is: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 sourceThe HTML template at the top of the file embeds those fields directly: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 inputsAnd in `PongoHandler` ([engine/handlers.go:81-92](../engine/handlers.go)):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.PoCVariant 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`:`data.lua` (sibling, picked up automatically by `PongoHandler` at [engine/handlers.go:64-93](../engine/handlers.go)):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` extensionsThe 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.**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: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):****Run (no `--debug`, no `--server`, no extra hardening):****Response body (excerpt — entire file is the PrettyError page):**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:**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.
CVSS v3.1 score: 7.5