{"id":"GHSA-fwqx-8365-9983","summary":"Algernon: Single-file mode unconditionally enables debug mode","details":"### Summary\n\nWhen 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:\n\n```go\n// engine/config.go:498-502\n// Make a few changes to the defaults if we are serving a single file\nif ac.singleFileMode {\n    ac.debugMode = true\n    ac.serveJustHTTP = true\n}\n```\n\n`debugMode` activates the `PrettyError` renderer, which on any Lua or template error response dumps:\n\n1. The **absolute path** of the file that errored (`Filename` field of the error template).\n2. The **complete byte contents** of that file, HTML-escaped, with the offending line wrapped in `\u003cfont style='color: red !important'\u003e…\u003c/font\u003e`.\n3. 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).\n\nThis 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.\n\nThis 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.\n\n### Details\n\n#### Root cause 1 — single-file detection forces `debugMode = true`\n\n```go\n// engine/config.go:441-502  (inside MustServe — abridged)\nswitch strings.ToLower(filepath.Ext(serverFile)) {\ncase \".md\", \".markdown\":\n    ...\ncase \".zip\", \".alg\":\n    ...\ndefault:\n    ac.singleFileMode = true\n}\n// ...\n// Make a few changes to the defaults if we are serving a single file\nif ac.singleFileMode {\n    ac.debugMode = true\n    ac.serveJustHTTP = true\n}\n```\n\nAny 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.\n\nThe `.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.\n\n#### Root cause 2 — `--prod`'s clamp runs *after* the forced enable, so it is the wrong direction\n\n```go\n// engine/config.go:393-397  (finalConfiguration, called from MustServe)\n// Turn off debug mode if production mode is enabled\nif ac.productionMode {\n    // Turn off debug mode\n    ac.debugMode = false\n}\n```\n\nThis 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:\n\n```\n1. flag parsing       -\u003e productionMode=true, debugMode=false\n2. single-file detect -\u003e debugMode = true     (overrides production)\n3. finalConfiguration -\u003e if productionMode { debugMode = false }\n```\n\nOn paper step 3 wins. In practice the operator-controlled execution path through `MustServe` for `.lua` files is:\n\n```\n1. flag parsing                                            -\u003e productionMode=true, debugMode=false\n2. single-file detect (line 493 default branch)            -\u003e singleFileMode = true\n3. if singleFileMode { debugMode = true } (line 499)       -\u003e debugMode = true\n4. if singleFileMode && ext==\".lua\" { singleFileMode = false; serverDir = Dir(...) }\n5. ac.RunConfiguration(luaServerFilename, mux, true)       -\u003e Lua server-conf script runs, may register handlers\n6. ac.finalConfiguration(host)                              -\u003e if productionMode { debugMode = false }   ← clamp restored\n```\n\nStep 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.\n\nEmpirically: `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.\n\n#### Root cause 3 — `PrettyError` discloses absolute path + full source\n\n```go\n// engine/prettyerror.go:82-147  (abridged)\nfunc (ac *Config) PrettyError(w http.ResponseWriter, req *http.Request, filename string, filebytes []byte, errormessage, lang string) {\n    w.WriteHeader(http.StatusOK)\n    w.Header().Add(contentType, htmlUTF8)\n    // ... linenr parsing elided ...\n    filebytes = bytes.ReplaceAll(filebytes, []byte(\"\u003c\"), []byte(\"&lt;\"))\n    bytelines := bytes.Split(filebytes, []byte(\"\\n\"))\n    if (linenr \u003e= 0) && (linenr \u003c len(bytelines)) {\n        bytelines[linenr] = []byte(preHighlight + string(bytelines[linenr]) + postHighlight)\n    }\n    code = string(bytes.Join(bytelines, []byte(\"\\n\")))\n    title := errorPageTitle(lang)\n    data := struct {\n        Title         string\n        Filename      string\n        Code          string\n        ErrorMessage  string\n        VersionString string\n    }{\n        Title:         title,\n        Filename:      filename,        // absolute path on disk\n        Code:          code,            // entire file\n        ErrorMessage:  strings.TrimSpace(errormessage),\n        VersionString: ac.versionString,\n    }\n    ...\n}\n```\n\nThe HTML template at the top of the file embeds those fields directly:\n\n```html\nContents of {{.Filename}}:\n\u003cdiv\u003e\n  \u003cpre\u003e\u003ccode\u003e{{.Code}}\u003c/code\u003e\u003c/pre\u003e\n\u003c/div\u003e\nError message:\n\u003cdiv\u003e\n  \u003cpre id=\"wrap\"\u003e\u003ccode style=\"color: #A00000;\"\u003e{{.ErrorMessage}}\u003c/code\u003e\u003c/pre\u003e\n\u003c/div\u003e\n```\n\nEvery 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.\n\n#### Root cause 4 — call sites that reach `PrettyError` are exercised by ordinary, attacker-influenceable inputs\n\n```go\n// engine/handlers.go (Lua handler with debugMode):\nif ac.debugMode {\n    ...\n    if err := ac.RunLua(recorder, req, filename, flushFunc, httpStatus); err != nil {\n        errortext := err.Error()\n        fileblock, err := ac.cache.Read(filename, ac.shouldCache(ext))\n        if err != nil {\n            fileblock = datablock.NewDataBlock([]byte(err.Error()), true)\n        }\n        ac.PrettyError(w, req, filename, fileblock.Bytes(), errortext, \"lua\")\n    }\n}\n```\n\nAnd in `PongoHandler` ([engine/handlers.go:81-92](../engine/handlers.go)):\n\n```go\nif err != nil {\n    if ac.debugMode {\n        luablock, luablockErr := ac.cache.Read(luafilename, ac.shouldCache(ext))\n        if luablockErr != nil {\n            luablock = datablock.EmptyDataBlock\n        }\n        ac.PrettyError(w, req, luafilename, luablock.Bytes(), err.Error(), \"lua\")\n    }\n    ...\n}\n```\n\nThe 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:\n\n- Send a `GET` to an endpoint the script handles only on `POST` — most `handle()` implementations index `req` fields that crash on the wrong method.\n- 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`.\n- 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.\n- 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`).\n\nThese are not exotic conditions; they are first-five-minutes-of-fuzzing behaviour.\n\n### PoC\n\n#### Variant A — `.lua` single-file invocation **does not reach `PrettyError`**\n\nImportant 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.\n\n#### Variant B — `.po2` single-file invocation, template-side trigger\n\n`page.po2`:\n\n```html\n{# Demonstrate template error disclosure under singleFileMode #}\n\u003ch1\u003eHello {{ user.name }}\u003c/h1\u003e\n\u003cp\u003eInternal token: {{ admin_token }}\u003c/p\u003e\n```\n\n`data.lua` (sibling, picked up automatically by `PongoHandler` at [engine/handlers.go:64-93](../engine/handlers.go)):\n\n```lua\nadmin_token = \"AKIA-FAKE-DEMO-AAAAAAAAAA/SECRET=demoSecretBYTES\"\nuser = nil   -- forces {{ user.name }} to raise\n```\n\n```bash\nalgernon page.po2 &\ncurl -s 'http://localhost:3000/'\n# =\u003e \"Lua Error\" page citing /home/op/data.lua, source inlined,\n#    `admin_token = \"...\"` visible to the unauthenticated requester.\n```\n\nNote 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.\n\n#### Variant C — `--prod` does not block this for non-`.lua` extensions\n\n```bash\nalgernon --prod page.po2 &\ncurl -s 'http://localhost:3000/'\n# =\u003e Same disclosure. --prod sets productionMode=true and\n#    finalConfiguration would normally clamp debugMode back to false,\n#    but for .po2 the singleFileMode → debugMode=true write happens at\n#    line 499 of engine/config.go, and singleFileMode stays true (no\n#    follow-up reset), so the engine treats this as a debug-on\n#    single-file deployment regardless of --prod.\n```\n\nThe 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.\n\n### Impact\n\n- **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.\n- **Integrity:** none directly.\n- **Availability:** none directly.\n\n**Affected population:**\n\n- Every developer running `algernon foo.lua` / `algernon page.po2` for a demo, evaluation, or local dev — the documented quickstart workflow.\n- 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\").\n- Every CI test job that exercises Algernon in single-file mode against attacker-controlled HTTP input (fuzz harnesses, integration tests with adversarial payloads).\n\n### Suggestions to fix\n\n**Primary fix — flip the default. `singleFileMode` should *not* force `debugMode` on; it should default it on only when `--debug`/`-d` was passed explicitly.**\n\n```go\n// engine/config.go:498-502  -- replace\nif ac.singleFileMode {\n    // Single-file mode is a convenience for quick demos. It should\n    // imply the relaxed serving model (no HTTPS, etc) but it must NOT\n    // override the operator's debug/production stance.\n    ac.serveJustHTTP = true\n    // (do not touch ac.debugMode)\n}\n```\n\nIf 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.\n\n**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:\n\n```go\n// engine/config.go -- early in MustServe, before single-file detection runs\nif ac.productionMode {\n    ac.debugMode = false\n}\n// ... single-file detection still runs but its debugMode assignment is now gated:\nif ac.singleFileMode && !ac.productionMode {\n    ac.debugMode = true\n}\n```\n\nA `--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.\n\n**Defence in depth — narrow what `PrettyError` discloses even when debugMode is on.**\n\n- 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.\n- 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.\n- Set `Cache-Control: no-store` on the response so intermediate caches and browser back-buttons do not retain it.\n- 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`.\n\n**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.\n\n### Live verification (2026-05-11, Algernon 1.17.6)\n\nReproduced against a fresh `go build` of `xyproto/algernon@main` on Windows 10.\n\n**Setup (Variant B — `.po2` single-file):**\n\n```\npoc4c/\n  page.po2        # contains {{ user.name }} and {{ admin_token }}\n  data.lua        # contains: local SECRET = \"sk-LEAKCANARY-DATALUA-PRIVATE\"\n                  #           this is intentionally bad lua    \u003c-- parse error\n```\n\n**Run (no `--debug`, no `--server`, no extra hardening):**\n\n```\n$ ./algernon.exe --nodb --httponly --addr 127.0.0.1:18777 --quiet poc4c/page.po2 \u003c/dev/null &\n$ curl -s -o po2b.html -w \"HTTP %{http_code}  bytes %{size_download}\\n\" http://127.0.0.1:18777/\nHTTP 200  bytes 1013\n```\n\n**Response body (excerpt — entire file is the PrettyError page):**\n\n```html\n\u003ctitle\u003eLua Error\u003c/title\u003e\n...\n\u003cdiv style=\"font-size: 3em; font-weight: bold;\"\u003eLua Error\u003c/div\u003e\nContents of poc-test\\poc4c\\data.lua:\n\u003cdiv\u003e\n  \u003cpre\u003e\u003ccode\u003elocal SECRET = \"sk-LEAKCANARY-DATALUA-PRIVATE\"\n\u003cfont style='color: red !important'\u003ethis is intentionally bad lua\u003c/font\u003e\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/div\u003e\nError message:\n\u003cdiv\u003e\n  \u003cpre id=\"wrap\"\u003e\u003ccode style=\"color: #A00000;\"\u003e&lt;string&gt; line:2(column:7) near 'is':   parse error\u003c/code\u003e\u003c/pre\u003e\n\u003c/div\u003e\n```\n\nThe `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`.\n\n**Baseline comparison — same files served in directory mode:**\n\n```\npoc4c-dir/\n  page.po2\n  data.lua        # same broken file\n\n$ ./algernon.exe --nodb --httponly --server --addr 127.0.0.1:18778 --quiet poc4c-dir \u003c/dev/null &\n$ curl -s -o po2c.html -w \"dir-mode: HTTP %{http_code}  bytes %{size_download}\\n\" http://127.0.0.1:18778/page.po2\ndir-mode: HTTP 200  bytes 0\n```\n\nEmpty 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`.\n\n**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.\n\n**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.","aliases":["CVE-2026-45728","GO-2026-5385"],"modified":"2026-06-25T19:56:19.118560956Z","published":"2026-05-19T14:35:51Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-05-19T14:35:51Z","nvd_published_at":null,"cwe_ids":["CWE-1188","CWE-209","CWE-489","CWE-540"],"severity":"HIGH"},"references":[{"type":"WEB","url":"https://github.com/xyproto/algernon/security/advisories/GHSA-fwqx-8365-9983"},{"type":"PACKAGE","url":"https://github.com/xyproto/algernon"}],"affected":[{"package":{"name":"github.com/xyproto/algernon","ecosystem":"Go","purl":"pkg:golang/github.com/xyproto/algernon"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.17.7"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 1.17.6","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-fwqx-8365-9983/GHSA-fwqx-8365-9983.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"}]}