Debugging enabled in production In github.com/xyproto/algernon
Description
Algernon: Single-file mode unconditionally enables debug mode ### 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 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}}: <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
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
go | 1.17.7 |
Aliases
References