CVE-2026-45728: Algernon Single-file Mode Unconditionally Enables Debug Vulnerability

HIGH
Advisory Identifier: GHSA-fwqx-8365-9983
CVSS 7.5
CWE-489 CWE-540 CWE-209 CWE-1188
Published
Advisory Summary

CVE-2026-45728 is a HIGH (CVSS 7.5) single-file mode unconditionally enables debug vulnerability (CWE-489: Active Debug Code) affecting Algernon libraries in the Go ecosystem. The issue involves single-file mode unconditionally enables debug mode. Remediation is available by upgrading affected packages to version 1.17.7.

CVE-2026-45728 at a Glance

CVE Identifier CVE-2026-45728
GHSA Identifier GHSA-fwqx-8365-9983
Severity HIGH
CVSS v3.1 Base Score 7.5
CWE Classification CWE-489: Active Debug Code
Affected Product Algernon
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 7.5

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity None (I:N)
Availability None (A:N)

How to Fix CVE-2026-45728

To remediate this vulnerability, upgrade all affected Algernon dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/xyproto/algernon fixed: 1.17.7
Upgrade github.com/xyproto/algernon to 1.17.7
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/xyproto/algernon

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-45728, inspect your project dependencies for Algernon:

inspect dependencies for github.com/xyproto/algernon

Analyze the terminal output to locate any version of Algernon in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.17.7), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-489: Active Debug Code, where the product is released with debugging code still enabled or active. In Algernon, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### 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 `<font style='color: red !important'>…</font>`. 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("&lt;")) 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}}: <div> <pre><code>{{.Code}}</code></pre> </div> Error message: <div> <pre id="wrap"><code style="color: #A00000;">{{.ErrorMessage}}</code></pre> </div> ``` 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 #} <h1>Hello {{ user.name }}</h1> <p>Internal token: {{ admin_token }}</p> ``` `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 <title>Lua Error</title> ... <div style="font-size: 3em; font-weight: bold;">Lua Error</div> Contents of poc-test\poc4c\data.lua: <div> <pre><code>local SECRET = "sk-LEAKCANARY-DATALUA-PRIVATE" <font style='color: red !important'>this is intentionally bad lua</font> </code></pre> </div> Error message: <div> <pre id="wrap"><code style="color: #A00000;">&lt;string&gt; line:2(column:7) near 'is': parse error</code></pre> </div> ``` 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-45728 Affected Algernon Versions

1 component(s)
Fixed Versions:
1.17.7
Product / Package Ecosystem Affected Versions Fixed Version
github.com/xyproto/algernon
Go
< 1.17.7
1.17.7

References

Frequently Asked Questions

What is CVE-2026-45728?
CVE-2026-45728 is a security vulnerability in Algernon involving Single-file mode unconditionally enables debug mode under CWE-489: Active Debug Code.
Which Algernon versions are affected?
Affected versions include releases prior to 1.17.7 across affected packages: github.com/xyproto/algernon.
What version fixes CVE-2026-45728?
Upgrading to 1.17.7 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/xyproto/algernon affected?
Yes, github.com/xyproto/algernon is affected in releases prior to 1.17.7. Upgrade your dependency to 1.17.7 or later.
What is the CVSS score of CVE-2026-45728?
CVE-2026-45728 has a CVSS v3.1 base score of 7.5 (HIGH) with vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N.
What CWE is associated with CVE-2026-45728?
CVE-2026-45728 is classified under CWE-489: Active Debug Code (The product is released with debugging code still enabled or active.).
How can I check Go dependencies for CVE-2026-45728?
Run the dependency tree command: 'inspect dependencies for github.com/xyproto/algernon'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-fwqx-8365-9983",
  "modified" : "2026-05-19T14:35:51Z",
  "published" : "2026-05-19T14:35:51Z",
  "aliases" : [ "CVE-2026-45728" ],
  "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 `<font style='color: red !important'>…</font>`.\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       -> productionMode=true, debugMode=false\n2. single-file detect -> debugMode = true     (overrides production)\n3. finalConfiguration -> 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                                            -> productionMode=true, debugMode=false\n2. single-file detect (line 493 default branch)            -> singleFileMode = true\n3. if singleFileMode { debugMode = true } (line 499)       -> debugMode = true\n4. if singleFileMode && ext==\".lua\" { singleFileMode = false; serverDir = Dir(...) }\n5. ac.RunConfiguration(luaServerFilename, mux, true)       -> Lua server-conf script runs, may register handlers\n6. ac.finalConfiguration(host)                              -> 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(\"<\"), []byte(\"&lt;\"))\n    bytelines := bytes.Split(filebytes, []byte(\"\\n\"))\n    if (linenr >= 0) && (linenr < 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<div>\n  <pre><code>{{.Code}}</code></pre>\n</div>\nError message:\n<div>\n  <pre id=\"wrap\"><code style=\"color: #A00000;\">{{.ErrorMessage}}</code></pre>\n</div>\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<h1>Hello {{ user.name }}</h1>\n<p>Internal token: {{ admin_token }}</p>\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# => \"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# => 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    <-- 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 </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<title>Lua Error</title>\n...\n<div style=\"font-size: 3em; font-weight: bold;\">Lua Error</div>\nContents of poc-test\\poc4c\\data.lua:\n<div>\n  <pre><code>local SECRET = \"sk-LEAKCANARY-DATALUA-PRIVATE\"\n<font style='color: red !important'>this is intentionally bad lua</font>\n</code></pre>\n</div>\nError message:\n<div>\n  <pre id=\"wrap\"><code style=\"color: #A00000;\">&lt;string&gt; line:2(column:7) near 'is':   parse error</code></pre>\n</div>\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 </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.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/xyproto/algernon"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.17.7"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 1.17.6"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/xyproto/algernon/security/advisories/GHSA-fwqx-8365-9983"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/xyproto/algernon"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-1188", "CWE-209", "CWE-489", "CWE-540" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-19T14:35:51Z",
    "nvd_published_at" : null
  }
}