fix(dotenv): save values as UTF-8 and escape the key before matching - #1791
Open
LHMQ878 wants to merge 1 commit into
Open
fix(dotenv): save values as UTF-8 and escape the key before matching#1791LHMQ878 wants to merge 1 commit into
LHMQ878 wants to merge 1 commit into
Conversation
`save_dotenv_value` persists `usr/.env` -- auth password, root password,
provider API keys and the CSRF allowed-origins list all go through it. It
had three defects.
**Encoding.** Neither `open()` passed `encoding=`, so the file was read and
written with the platform's preferred encoding, a legacy codepage on a
stock Windows install. Two consequences:
* a non-ASCII value raised UnicodeEncodeError instead of being stored, so
a user with an accented or CJK password could not save it at all;
* a non-ASCII byte *already in the file* made `readlines()` raise, so
every later save failed too -- even of a purely ASCII value.
The second case is reachable without the user ever typing a non-ASCII
character: `helpers/secrets.py` reads and writes this same file through
`files.read_file`/`files.write_file`, which default to UTF-8. The two
paths disagreed about the encoding of one file.
**Unescaped key in a regex.** The key was interpolated into
`rf"^\s*{key}\s*="` raw, so a key containing a regex metacharacter matched
a *different* line and rewrote its name, discarding the value stored
there: saving `API_KEY_OPEN.I` turned `API_KEY_OPENAI=sk-real` into
`API_KEY_OPEN.I=<new>`, losing the real OpenAI key. Keys are built as
`API_KEY_{provider.upper()}` from request payloads with no whitelist
(`plugins/_model_config/api/api_keys.py`, `helpers/settings.py`), so the
value is caller-controlled. `re.escape` is already used for this purpose
in `helpers/files.py`, `helpers/tailscale_tunnel.py` and two plugins.
**CRLF.** Text mode translated the LF endings the function writes into
CRLF on Windows. `.env` is also sourced by shells and read by Docker,
which keep the CR, so a shell doing `. .env` bound the value to
"secret\r". `newline=""` writes the endings as-is.
The read and write are now separate `open()` calls rather than one `r+`
with `seek(0)`/`truncate()`; the in-place truncation window is unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
helpers/dotenv.py::save_dotenv_valuepersistsusr/.env. Every credential the UI writes goes through it —AUTH_PASSWORD,ROOT_PASSWORD,RFC_PASSWORD, everyAPI_KEY_*, and the CSRFALLOWED_ORIGINSlist. It had three defects; two lose user data.1. Encoding — a non-ASCII password cannot be saved, and one non-ASCII byte in the file breaks every later save
Neither
open()passedencoding=, so the file was read and written with the platform's preferred encoding — a legacy codepage on a stock Windows install (cp936 on a Chinese system, cp1252 on a Western one), not UTF-8.Write side. Settings accepts any string as
auth_password/root_password, so an accented or CJK password raised instead of being stored:Read side, and this is the worse one. A non-ASCII byte already in the file made
readlines()raise, so every later save failed too — including saves of purely ASCII values:The user never has to type a non-ASCII character for this to happen:
helpers/secrets.pyreads and writes this same file throughfiles.read_file/files.write_file, which default toencoding="utf-8"(helpers/files.py:212,:465). So one file had two writers that disagreed about its encoding — content written by the secrets path could make the dotenv path fail.2. The key is interpolated into a regex unescaped — saving one key can discard another's value
A key holding a regex metacharacter matches a different line, and because the replacement rewrites the whole line including its name, the entry that was there is gone:
This is caller-controlled, not hypothetical. Keys are built as
API_KEY_{provider.upper()}from request payloads with no whitelist —plugins/_model_config/api/api_keys.py::_set_keysiterates the submittedkeysdict directly, andhelpers/settings.py::_write_sensitive_settingsdoes the same oversettings["api_keys"].re.escapeis already the established pattern for this in the repo:helpers/files.py:439,helpers/tailscale_tunnel.py:43,plugins/_browser/helpers/extension_manager.py:568,plugins/_office/hooks.py:380.3. CRLF endings
Text mode translated the
\nthis function writes into\r\non Windows..envis also sourced by shells and read by Docker, both of which keep the CR, so a shell doing. .envboundAUTH_PASSWORDto"secret\r"and an auth comparison against it fails. (python-dotenv strips the CR, which is why this only bites the non-Python consumers.)newline=""writes the endings as-is.Changes
helpers/dotenv.py—encoding="utf-8"on all threeopen()calls,newline=""on the write, andre.escape(key)in the pattern. The read and write are now separate calls instead of oner+withseek(0)/truncate(); the in-place truncation window is unchanged (the original also truncated in place, and neither version takes a lock).Formatting of the untouched functions in this file is left alone —
ruff formatalready wants to reformat them onmain, and that is out of scope here.Validation
New
tests/test_dotenv_save_value.py, 6 tests, isolated via the existing pattern (monkeypatch.setattr(dotenv, "get_dotenv_file_path", lambda: str(env_file)), as intests/test_model_config_api_keys.py:58) and withload_dotenvstubbed so the real process env is untouched — pertests/AGENTS.md, nousr/runtime state.test_non_ascii_value_is_saved_under_a_legacy_localetest_existing_non_ascii_content_does_not_break_an_ascii_savetest_ascii_only_save_still_works_under_a_legacy_localetest_key_with_regex_metacharacters_does_not_rewrite_another_entryAPI_KEY_OPENAI=sk-real-openaiis still presenttest_existing_key_is_updated_in_placetest_saved_file_uses_lf_endingsReproducing a locale bug on a UTF-8 CI runner needs care. Monkeypatching
locale.getpreferredencodingdoes not work — CPython reads the locale encoding at the C level, soopen()ignores the patched function. Alegacy_default_encodingcontext manager instead replacesopenwith a shim that supplies a codec in exactly the position CPython would supply the locale's — only when the caller passed noencoding:Code that names its encoding is untouched by the shim, so these tests fail on a machine of any locale when the
encoding=is missing and pass on a machine of any locale when it is present.Control experiment —
helpers/dotenv.pyreverted, tests kept:The two that stay green are the controls. With the fix: 6 passed.
No regressions.
mainIdentical — the failing-test sets are the same, with an empty symmetric difference in both directions. I ran this on Windows, where a portion of the suite cannot run at all: 12 modules fail to collect on
main(fcntl,resource,fastmcp.server.providers— POSIX-only or unavailable) and were excluded from both runs, and the 102 shared failures are environment-dependent (POSIX paths, symlinks, tunnel binaries). Since both runs are on the same machine with the same exclusions, the comparison is still valid for "this change adds no failures", but a maintainer run on Linux would be a better check of the absolute numbers.Worth flagging:
tests/test_timezone_regressions.pyis the one existing module that exercisessave_dotenv_value, and it cannot be collected on Windows (fcntl). Reading it, it monkeypatchessave_dotenv_valuewholesale and asserts only on the arguments it receives, so this change is outside what it covers — but I could not execute it, and say so rather than imply I did.ruff checkon both changed files → All checks passed.I searched open and closed upstream PRs and issues before opening this (
save_dotenv_value,dotenvin title,dotenv,UnicodeDecodeError,re.escape) — no existing or overlapping work. Targetingmain, which is what 23 of the 25 currently-open PRs use.