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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
**Vulnerability:** User-provided string fields (like project and connection names) lacked strict validation against control characters, only relying on length constraints.
**Learning:** This could potentially lead to Log Injection (CRLF injection), Null Byte Injection, or terminal escape injection if these strings are subsequently logged or rendered directly.
**Prevention:** Use explicit regex validation `pattern=r'^[^\x00-\x1F\x7F]+$'` on Pydantic string fields to strictly reject control characters.

## 2025-02-14 - Fix incomplete DSN secret redaction and over-redaction
**Vulnerability:** URL-encoded secrets in driver error messages were only partially checked (using `urllib.parse.urlsplit().password` which returns percent-encoded values like `%20` or `+`), leaving un-encoded variants exposed. Additionally, short secrets could be improperly bounded, causing over-redaction (e.g. corrupting 'password' when secret was 'pass', or failing to redact non-alphanumeric bounded short secrets).
**Learning:** Raw DSN parsing requires explicit decoding (`unquote_plus`) and systematic re-encoding (`quote`, `quote_plus`) to handle all variations of DB driver output formats. Regular expression boundaries (`\b` or similar) do not work universally when secrets begin or end with non-alphanumeric characters.
**Prevention:** Explicitly apply `.isalnum()` checks to the first and last characters of a short secret before conditionally appending negative lookbehinds/lookaheads for word boundaries. Always decode fully before adding variations to redaction candidate lists.
17 changes: 14 additions & 3 deletions backend/app/dsn_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,20 @@ def _password_candidates_from_dsn(dsn: str) -> set[str]:

if password:
candidates.add(password)
candidates.add(quote(password, safe=""))
decoded = unquote_plus(password)
candidates.add(decoded)
candidates.add(quote(decoded, safe=""))
candidates.add(quote_plus(decoded, safe=""))

if "@" in netloc:
userinfo = netloc.rsplit("@", 1)[0]
if ":" in userinfo:
raw_password = userinfo.split(":", 1)[1]
candidates.add(raw_password)
candidates.add(unquote(raw_password))
decoded_raw = unquote_plus(raw_password)
candidates.add(decoded_raw)
candidates.add(quote(decoded_raw, safe=""))
candidates.add(quote_plus(decoded_raw, safe=""))

for part in query.split("&"):
key, sep, raw_value = part.partition("=")
Expand All @@ -83,10 +89,15 @@ def _password_candidates_from_dsn(dsn: str) -> set[str]:


def _redact_secret_occurrences(message: str, secret: str) -> str:
if not secret:
return message

if len(secret) > 4:
return message.replace(secret, "***")

pattern = re.compile(rf"(?<![A-Za-z0-9]){re.escape(secret)}(?![A-Za-z0-9])")
start_boundary = r"(?<![A-Za-z0-9])" if secret[0].isalnum() else ""
end_boundary = r"(?![A-Za-z0-9])" if secret[-1].isalnum() else ""
pattern = re.compile(rf"{start_boundary}{re.escape(secret)}{end_boundary}")
return pattern.sub("***", message)


Expand Down
53 changes: 53 additions & 0 deletions backend/tests/test_dsn_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,56 @@ def test_malformed_dsn_still_redacts_embedded_secrets() -> None:
assert "s3cr3t" not in redacted
assert "q/secret" not in redacted
assert "password=***" in redacted


def test_url_encoded_short_passwords_and_boundaries() -> None:
dsn = "postgresql://user:a%2Bb@db.example.com/app"
error = "driver failed for a+b (a%2Bb) with =a+ and b+="

redacted = redact_dsn_error_message(error, dsn)

assert "a+b" not in redacted
assert "a%2Bb" not in redacted
assert "=a+" in redacted
assert "b+=" in redacted


def test_userinfo_literal_plus_is_not_decoded_as_space() -> None:
dsn = "postgresql://user:a+b@db.example.com/app"
error = "driver exposed a+b, but the unrelated phrase a b must remain"

redacted = redact_dsn_error_message(error, dsn)

assert "a+b" not in redacted
assert "unrelated phrase a b must remain" in redacted


def test_query_plus_uses_form_decoding_semantics() -> None:
dsn = "postgresql://user@db.example.com/app?access_token=a+b"
error = "access_token=a+b was decoded by the driver as access_token=a b"

redacted = redact_dsn_error_message(error, dsn)

assert "access_token=a+b" not in redacted
assert "access_token=a b" not in redacted
assert redacted.count("access_token=***") == 2


def test_short_unicode_secret_uses_unicode_word_boundaries() -> None:
dsn = "postgresql://user@db.example.com/app?token=키"
error = "token=키 must be hidden while 비밀키값 remains readable"

redacted = redact_dsn_error_message(error, dsn)

assert "token=***" in redacted
assert "비밀키값 remains readable" in redacted


def test_short_punctuation_secret_is_not_redacted_inside_larger_text() -> None:
dsn = "postgresql://user:%2Ba%2B@db.example.com/app"
error = "isolated +a+ must be hidden while x+a+y remains readable"

redacted = redact_dsn_error_message(error, dsn)

assert "isolated *** must be hidden" in redacted
assert "x+a+y remains readable" in redacted
Loading