Skip to content

Commit bfdcb62

Browse files
committed
fix(secrets-store): validate key/value to reject newlines and bad keys
1 parent c3546dc commit bfdcb62

2 files changed

Lines changed: 42 additions & 1 deletion

File tree

autosearch/core/secrets_store.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import logging
2121
import os
22+
import re
2223
import shlex
2324
import tempfile
2425
from pathlib import Path
@@ -29,6 +30,7 @@
2930
_fcntl = None
3031

3132
_FILE_INJECTED_VALUES: dict[str, str] = {}
33+
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
3234
_log = logging.getLogger(__name__)
3335

3436

@@ -73,6 +75,7 @@ def load_secrets(path: Path | None = None) -> dict[str, str]:
7375

7476
def write_secret(key: str, value: str, *, path: Path | None = None) -> None:
7577
"""Atomically write or replace one KEY=value entry in the secrets file."""
78+
_validate_secret_entry(key, value)
7679
target = path or secrets_path()
7780
target.parent.mkdir(parents=True, exist_ok=True)
7881
lock_path = target.with_name(f"{target.name}.lock")
@@ -120,7 +123,7 @@ def write_secret(key: str, value: str, *, path: Path | None = None) -> None:
120123

121124

122125
def _replace_or_append_secret(text: str, key: str, value: str) -> str:
123-
replacement = f"{key}={shlex.quote(value)}"
126+
replacement = _format_secret_line(key, value)
124127
lines = text.splitlines()
125128
replaced = False
126129
next_lines: list[str] = []
@@ -137,6 +140,18 @@ def _replace_or_append_secret(text: str, key: str, value: str) -> str:
137140
return "\n".join(next_lines) + "\n"
138141

139142

143+
def _format_secret_line(key: str, value: str) -> str:
144+
_validate_secret_entry(key, value)
145+
return f"{key}={shlex.quote(value)}"
146+
147+
148+
def _validate_secret_entry(key: str, value: str) -> None:
149+
if not _ENV_KEY_RE.fullmatch(key):
150+
raise ValueError("secret key must match [A-Za-z_][A-Za-z0-9_]*")
151+
if any(char in value for char in ("\n", "\r", "\0")):
152+
raise ValueError("secret value must not contain newline, carriage return, or NUL")
153+
154+
140155
def _secret_line_key(raw: str) -> str | None:
141156
line = raw.strip()
142157
if not line or line.startswith("#") or "=" not in line:

tests/unit/test_secrets_store.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,32 @@ def test_write_secret_basic(tmp_path):
101101
assert load_secrets(f) == {"OPENAI_API_KEY": "sk-basic"}
102102

103103

104+
@pytest.mark.parametrize(
105+
"bad_key",
106+
["", "1OPENAI_API_KEY", "OPENAI-API-KEY", "OPENAI.API.KEY", "OPENAI API KEY"],
107+
)
108+
def test_write_secret_rejects_invalid_key(tmp_path, bad_key):
109+
f = tmp_path / "ai-secrets.env"
110+
111+
with pytest.raises(ValueError, match=r"secret key must match"):
112+
write_secret(bad_key, "sk-basic", path=f)
113+
114+
assert not f.exists()
115+
116+
117+
@pytest.mark.parametrize(
118+
"bad_value",
119+
["sk-line-1\nINJECTED_KEY=bad", "sk-line-1\rsk-line-2", "sk-prefix\0sk-suffix"],
120+
)
121+
def test_write_secret_rejects_newline_and_nul_values(tmp_path, bad_value):
122+
f = tmp_path / "ai-secrets.env"
123+
124+
with pytest.raises(ValueError, match=r"secret value must not contain"):
125+
write_secret("OPENAI_API_KEY", bad_value, path=f)
126+
127+
assert not f.exists()
128+
129+
104130
def test_write_secret_preserves_comments_and_unknown_lines(tmp_path):
105131
f = tmp_path / "ai-secrets.env"
106132
_write(

0 commit comments

Comments
 (0)