Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@
**Vulnerability:** Database driver exceptions can echo DSN fragments, query parameters, or assignment-style secrets after connection failures, leaking plaintext passwords through snapshot error messages and queue logs.
**Learning:** Redacting only the literal DSN is not enough. Error messages may contain decoded, percent-encoded, query-string, or `password=`/`api_key=` style forms of the same secret.
**Prevention:** Sanitize snapshot job errors before persisting or re-raising them, and raise sanitized exceptions with `from None` so Python exception chaining does not reattach the original secret-bearing exception.
## 2026-06-27 - DSN Password Redaction Leakage via Encoding Variations
**Vulnerability:** The DSN redactor (`redact_dsn_error_message`) previously failed to catch and replace variations of URL-encoded passwords (like `+` vs `%20` or double-encoded forms) from driver error messages.
**Learning:** `urllib.parse.urlsplit().password` returns a URL-decoded string. If the original DSN contains an encoded password (e.g. `p+ass` or `p%20ass`), and the database driver logs it in its raw or re-encoded form in the error message, naive string replacement using only the standard `quote()` variation will miss it. Furthermore, any attempt to avoid over-redaction by skipping passwords shorter than a certain length (e.g., `< 4`) creates a critical leakage vulnerability for short passwords.
**Prevention:** To reliably redact passwords from error messages, you must first decode the raw string fully to a base representation (`unquote_plus`), and then generate all possible logging variations (the decoded string, `quote()`, and `quote_plus()`) for the redaction candidates set. Never skip redacting short passwords; over-redaction is always preferable to a credential leak.
28 changes: 18 additions & 10 deletions backend/app/dsn_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,44 @@ def _password_candidates_from_dsn(dsn: str) -> set[str]:
candidates: set[str] = set()
parsed = urlsplit(dsn)

def add_variations(pw: str) -> None:
if not pw:
return

decoded = unquote_plus(pw)
candidates.add(pw)
candidates.add(decoded)
candidates.add(quote(decoded, safe=""))
candidates.add(quote_plus(decoded, safe=""))

if parsed.password:
candidates.add(parsed.password)
candidates.add(quote(parsed.password, safe=""))
add_variations(parsed.password)

if "@" in parsed.netloc:
userinfo = parsed.netloc.rsplit("@", 1)[0]
if ":" in userinfo:
raw_password = userinfo.split(":", 1)[1]
candidates.add(raw_password)
candidates.add(unquote(raw_password))
add_variations(raw_password)

for part in parsed.query.split("&"):
key, sep, raw_value = part.partition("=")
if not sep:
continue
if not _SECRET_KEY_PATTERN.search(unquote_plus(key)):
continue
decoded_value = unquote_plus(raw_value)
candidates.add(raw_value)
candidates.add(decoded_value)
candidates.add(quote(decoded_value, safe=""))
candidates.add(quote_plus(decoded_value, safe=""))
add_variations(raw_value)

return {candidate for candidate in candidates if candidate}


def redact_dsn_error_message(error_message: str, dsn: str) -> str:
"""Redact DSN-derived secrets from a driver error message."""

redacted = error_message

# Apply naive replacements for all candidates.
# While this may cause over-redaction for very short passwords, it is
# the safest approach to ensure no secrets leak in error messages.
for secret in sorted(_password_candidates_from_dsn(dsn), key=len, reverse=True):
redacted = redacted.replace(secret, "***")

return _SECRET_ASSIGNMENT_PATTERN.sub(r"\g<prefix>***", redacted)