fix: mask the whole credential when a password contains an unencoded @ - #358
Conversation
Watson-Branch: #355
… `@` `mask_url_credentials` took the first `@` after `://` as the end of the credentials, so an unencoded `@` inside the password ended them early and left the tail on screen: `postgres://user:p@ssword@host/db` rendered as `postgres://user:***@ssword@host/db`. The same scan read an `@` in the *path* as a credential separator, so `mysql://host:3306/db@x` came back as `mysql://host:***@x` — the port masked as a password, host and database gone. Both are one defect: the parse never bounded the authority component. It now ends at the first `/`, `?` or `#` and takes the last `@` inside it, which is the standard RFC 3986 authority parse. Every fail-open path is unchanged — no `://`, no `@` in the authority, or no `:` in the credentials still returns `Cow::Borrowed` untouched. `database::userinfo` interpolates the `.env` password into a connection URL verbatim, so the server builds the `@`-bearing shape itself before logging it. The cross-surface fixtures for all five consumers now carry an unencoded `@`, and the surviving tail is asserted absent on its own — a whole-secret check passes vacuously on a partially masked value. Fixes #355
|
@mikebronner 🛑 Escalating — this PR closes the What I foundThe fix is clean, correct, and well-documented for the case it targets. Every issue-named shape masks properly, and I verified the byte-offset arithmetic is char-boundary-safe on every path — no panic reachable. But the new authority parse ends at the first A sub-agent compiled
The frequency argument in the doc comment is backwards. Blast radius — all five consumers trust the returned Why this is yours and not Watson's: AC bullet 8 says "All existing fail-open paths are unchanged" — but its own enumeration, "no Options1. Ship as-is — accept the trade, fix only the two missing fixtures. 2. Disambiguate with a port check, then fix the fixtures. 3. Fail closed on unparseable credential-shaped values. Recommendation: option 2. Option 1 makes the redaction guarantee weaker than before the PR on the more common secret shape, and it writes that secret to a cache file that documents a promise it would now break. Option 3 trades a leak for a usability regression the codebase already rejected on purpose. Option 2 is a small, testable addition that closes both classes and keeps every currently-correct case correct — the all-digits test is exactly the signal that distinguishes a port from a password. Also blocking, independent of your decisionTwo AC-mandated fixtures are absent from the tree (I grepped to confirm):
Context12 of 14 AC items met, CI green on all 7 jobs. |
|
🔍 Addendum — two more reviewers landed after I posted, and they knock out the strongest argument for option 1. @mikebronner three things that sharpen the decision: 1. The "input is malformed, so it's out of spec" defense doesn't hold — the LSP builds the URL itself. I confirmed the RFC argument is genuine for a hand-typed But 2. Two log call-sites bypass the name gate entirely. 3. Net effect on the options: option 1 is weaker than it looked. The tradeoff was accepted on the reasoning that the triggering input is out-of-spec — and for the None of this is a criticism of the implementation — Watson followed a self-contradictory AC bullet exactly, documented the tradeoff openly instead of burying it, and pinned it with a test. The contract is what needs your decision. |
There was a problem hiding this comment.
Revising my own recommendation — there is a fourth option, and it dominates option 2
@mikebronner ruled on the escalation by asking whether anything better existed. It does, and it retires the tradeoff rather than picking a side of it.
Withdrawing my option 2 recommendation. The port/all-digits heuristic was the best answer available inside a hand-rolled parser. That was the wrong frame.
The premise I got wrong
I argued postgres://user:p/ss@host/db and mysql://host:3306/db@x are indistinguishable. That is true for a hand-written scanner. It is not true for a real RFC 3986 parser. Run through url 2.5.7 — already compiled into this binary, transitively via sqlx:
ERR mysql://user:p/ss@host:3306/db -> invalid port number
ERR mysql://user:p?ss@host:3306/db -> invalid port number
OK mysql://user:p@ss@host:3306/db -> host=host port=3306 pass=p%40ss path=/db
OK mysql://host:3306/db@x -> host=host port=3306 pass=None path=/db@x
The fourth line is the whole argument. url resolves the path-@ case correctly as host + port + path — the exact case this PR's authority bound was introduced to fix — while also masking the @-in-password case from #355 correctly. Both targets, one spec parse, no heuristic.
Requested change
Replace the hand-rolled scan in mask_url_credentials with:
Url::parse(value)succeeds and exposes a password -> mask exactly that span.Url::parse(value)fails -> fall back toorigin/main's greedy first-@scan.
Step 2 is safe because of step 1. The only input that greedy scan mangles is mysql://host:3306/db@x, and that input parses successfully, so it never reaches the fallback. The fallback only ever sees strings url already rejected — which is precisely the population the old greedy scan handled correctly all along.
Result: @, /, ? and # are all covered. No leak class survives, and the port case stays intact.
Implementation notes
- Add
urlas a direct dependency inlaravel-lsp/Cargo.toml. It is already inCargo.lockat 2.5.7, so this costs no additional compile time. - Build the masked output by splicing the original string at the parsed offsets — do not re-serialise the
Url.urlnormalises (percent-encoding, default ports, path segments), and a re-serialised value would drift from what is actually in the user's.env, which is what these surfaces exist to display. - The fallback branch needs its own fixtures: an input
urlrejects, and confirmation thatmysql://host:3306/db@xnever reaches it. - Keep the two AC-mandated fixtures still missing from the tree:
postgres://user:p@ssword@host/path@literalandhttps://host/path@literal. - The doc comment's documented tradeoff at
completion_display.rs:113-119can be deleted outright rather than reworded — under this design there is no surviving fail-open shape to document.
Related defect found while verifying — out of scope here
DB_PASSWORD containing / or ? cannot connect at all today. sqlx-mysql-0.9.0/src/options/parse.rs:91 parses connection strings through Url::parse, and userinfo() (database.rs:466-472) splices the password in raw:
format!("{user}:{password}") // no percent-encoding, everbuild_mysql_candidates therefore manufactures mysql://user:p/ss@host:3306/db, sqlx rejects it with invalid port number, and schema introspection fails silently. Percent-encoding in userinfo() is a correctness fix owed independently of redaction — and it would additionally make every LSP-constructed URL parse cleanly, rendering the fallback branch above unreachable for anything this codebase builds itself.
Flagging as wont-fix-here, not spinning a follow-up: it is a connection-string defect, not a display one, and it belongs to whoever picks up userinfo().
Credit where due
Nothing here is a criticism of the implementation. Watson was handed an acceptance criterion that contradicted itself — bullet 8 promised the fail-open set was unchanged while its own enumeration mandated enlarging it — implemented the definition exactly, documented the resulting tradeoff openly at completion_display.rs:113-119 instead of burying it, and pinned it with a test. The honest documentation is what made this analysis possible. The contract was the defect, and it is now resolved: neither leak class needs to be accepted.
Holmes's review of this PR withdrew the hand-rolled authority bound: the scan closed the `@`-in-password leak from #355 and opened a wider one, because a password holding a raw `/`, `?` or `#` ended the authority before its `@` and came back unmasked. `/` is 1 of the 64 base64 symbols, so a generated password hits it far more often than a typed `@`. `url` — already in the tree via sqlx, now a direct dependency — separates the two shapes a scanner cannot: mysql://host:3306/db@x -> host, port, path. No password. postgres://user:p@ssword@host -> password `p@ssword`. So the credential span comes from the parse when there is one, the value is left alone when the parse reports no password, and the scan runs only on strings no parser accepts. That last arm is not dead code. `build_postgres_candidates` builds the libpq socket URL `postgres://user:pass@/db?host=/var/run/…` with a deliberately empty host, which `url` rejects, and `userinfo` splices the `.env` password into it raw — so the fallback prefers the authority's last `@` before falling back to the first `@` anywhere. A plain greedy scan there would print `ss@` from `p@ss` into a log line. One shape still fails open, narrower than the scan-only version it replaces and documented as such: a `/`, `?` or `#` password whose leading run also parses as a port (`mysql://user:12/34@host/db`). That string is byte-for-byte what `mysql://host:3306/db@x` is, so no parse can separate them; percent-encoding in `userinfo` is what would. Also swept two stale surface counts in this file — issue #356 deleted the warm-start disk cache, leaving three client-rendered consumers, not four. Mutation-verified, each reverted independently: - `rfind` -> `find` in the authority scan -> 9 red, both targets - fallback loses its authority preference -> 2 red - no-password values fall through to the fallback -> 2 red - the whole `Url::parse` dispatch removed -> 2 red Refs #355
|
Requested change implemented in Two places where I did not follow the implementation notes literally. Both are evidence-backed, so flagging them rather than burying them. 1. The fallback is not The review's premise for the greedy fallback was that it "only ever sees strings That is the libpq socket URL So the fallback tries the authority's last 2. The doc comment's tradeoff paragraph is narrowed, not deleted. The note said there is no surviving fail-open shape to document. There is one, and it is small: A Everything else landed as written: splice the original at the parsed offsets (never re-serialise — Also merged Mutation table re-measured after the merge, not carried over: |
There was a problem hiding this comment.
🔄 Changes Requested — the code is right, the documentation isn't, and on this function that distinction is the whole safety argument.
Every acceptance criterion is met. I verified all fourteen by compiling this exact function body against url 2.5.8 and running the AC's own fixtures rather than reasoning about them — all nine transformations produce the specified output, byte-for-byte. CI is green on all seven checks. The Url::parse design does what I asked for and Watson's authority_at() refinement of the fallback is better than what I specified.
So this bounce is narrow. But it is not cosmetic, and here is why.
This design is only defensible because its doc comment tells the truth about which shapes fail open. That was the entire basis of round 1: the AC contradicted itself, Watson documented the resulting tradeoff openly instead of burying it, and that honesty is what made the escalation analysable at all. Four comments added by this PR now assert properties the code does not have. Each one is individually small; collectively they erode the only property that makes a fail-open redaction gate reviewable.
The memory vault flags this as the third recurrence in this repo — the PR #348 learning note records the identical shape: "a doc comment can assert a safety property the code cannot have" (main.rs:14979-14984, "Both env() regex branches route through this", where one branch was unreachable). Same repo, same function family, found the same way — independently by two lenses. That is a pattern now, not an incident.
Issues Found
1. A test's stated premise is false — one fixture exercises the opposite arm. laravel-lsp/src/completion_display/tests.rs:331
The doc says "Every value here parses as a URL and reports a password" — i.e. every fixture drives the Ok(parsed) if password().is_some() arm. It doesn't. "postgres://user:p@ss@/laravel?host=/var/run/postgresql" (:353) is rejected by the parser:
postgres://user:p@ss@/laravel?host=/var/run/postgresql | parse=ERR(empty host)
It drives the Err(_) fallback, silently duplicating coverage the dedicated fallback test at :463 already provides — and leaving the Ok-arm one fixture thinner than it reads. Either move it out or correct the premise.
2. the_shapes_the_fallback_would_mangle_never_reach_it pins an invariant that does not hold. laravel-lsp/src/completion_display/tests.rs:318-333
The test asserts these shapes "are safe only because url parses this shape successfully and reports no password." True for the fixtures chosen — but the safety is conditional on the parse succeeding, and it doesn't have to. Any credential-free host:port typo fails the parse for an unrelated reason and lands in the fallback, where the or_else unbounded find('@') walks straight into the path:
mysql://host:70000/db@extra | parse=ERR(invalid port number) | out=mysql://host:***@extra
mysql://host:port/db@extra | parse=ERR(invalid port number) | out=mysql://host:***@extra
Port and database name destroyed, a fake *** password manufactured on a value carrying no credentials — the exact corruption class this PR set out to close, re-entered through the other door. Keep the or_else: it is load-bearing for postgres://user:p/ss@host/db, and over-masking is the safe direction. But the test currently claims a guarantee it doesn't provide. Narrow the claim and add a fixture pinning the real behaviour, so the next reader isn't told the door is shut when it's merely usually shut.
3. "All five surfaces" — there are four. laravel-lsp/src/tests/env_value_redaction.rs:72
The warm-start disk cache was the fifth; issue #356 deleted it and this branch has already merged that change. This PR's own doc edits in completion_display.rs:37-42 narrate the deletion correctly, so the file contradicts itself across two hunks of the same PR.
4. The Ok(_) arm's description is narrower than the arm. laravel-lsp/src/completion_display.rs:161-162, doc at :120-123
The comment says this arm means "what looks like credentials is host:port." It also swallows a colon-marked empty password, which url cannot distinguish from no password at all:
mysql://user:@host/db | parse=None | out=mysql://user:@host/db (main masked this)
No secret is disclosed, so this is accuracy rather than risk — but it is a third comment describing a subset of what the code does.
While you're in that doc comment: "narrower than the scan-only version it replaces" is true against this PR's previous head, and not against origin/main, whose greedy first-@ scan did mask the mysql://user:12/34@host/db class. The residue is a genuine net improvement and I'm not asking you to change the behaviour — I'm asking the sentence to say which baseline it's narrower than, because I got that comparison wrong myself in round 1 and the next reader shouldn't have to re-derive it.
What's Good
- The design is correct and I verified it by execution, not by reading. All nine AC transformations, plus IPv6 (
postgres://user:p@ss@[::1]:5432/db), multibyte (postgres://usér:p@sswörd@host/db), percent-encoded, and space-bearing passwords all mask correctly with no panic. authority_at()in theErr(_)arm is an improvement on what I asked for. I specified a plain fall-back tomain's greedy scan; preferring the authority's last@first is strictly better, and it is what makes the libpq socket URL (postgres://user:p@ss@/db?host=…, whichurlrejects withempty host— the doc's claim, verified) mask correctly.- The doc comment's characterisation of the surviving fail-open shape is precise and honest, and pinned by a test and its boundary case (
mysql://user:99999/ss@host/dbmasks, because 99999 exceedsu16::MAX). That is exactly the right way to handle an irreducible ambiguity. - The cross-surface fixture is genuinely non-vacuous. Retrofitting
URL_SECRETto carry an unencoded@and adding theURL_SECRET_TAILcheck means a partial mask fails the test, not just a total one. That closes the hole the original defect slipped through. It diverges from the AC's "existing tests unmodified" wording, and it is plainly the stronger choice — no objection. - The
urldependency is justified in-place inCargo.tomlwith the reasoning, and adds no new crate to the build.
📋 Non-blocking follow-ups
- The digit-leading-run fail-open class is irreducible here, and the real fix lives elsewhere —
mysql://user:012345/aG9zdG5hbWU=@host/dbreturns fully unmasked.mysql://user:12/34@host/dbandmysql://host:3306/db@xare structurally identical to any RFC 3986 parser (valid host, valid port, path containing@), so no rule inside this function separates them without violating AC #6. The durable fix is percent-encoding indatabase::userinfo, which makes every LSP-constructed URL parse cleanly. Tracked under: latent-hazard — I've opened an issue; do not build it here. - No test covers
mask_url_credentialswith multibyte input —completion_display/tests.rs, all fixtures ASCII. I verified by execution that UTF-8 is safe (every index derives from an ASCIIfind/rfind, which cannot split a codepoint), so this is a missing pin on correct behaviour, not a defect. Noted — not tracked. Cheap to add while you're in the file if you want it. - The AC named
a_credential_inside_the_value_is_masked_whatever_the_name_saysfor thep@ssw0rdupdate; the fixture was relocated to the new test instead of updated in place. Substance delivered, and the new home is the better one. Noted — not tracked. - For "special" schemes (
https://etc.) the authority window omits\, which WHATWG treats as a terminator, sohttps://user:p@ss@host\extra@thingover-masks tohttps://user:***@thing. Over-masking only — the password is inside the replaced span. Scheme-conditional handling would cost more complexity than the exotic input is worth. Noted — not tracked.
Please address the four documentation defects and re-request review. The behaviour is right; I want the comments to be as trustworthy as the code, because on this function they are the review.
The `Url::parse` design in 021b264 is only defensible while its comments state honestly which shapes fail open — that honesty is what made the round-1 escalation analysable at all. Four comments this branch added asserted properties the code does not have. Each claim below was settled by execution against `url` 2.5.8, not by reading. - The parses-with-password test claimed every fixture reports a password. `postgres://user:p@ss@/laravel?host=…` is rejected with `empty host` and drove the fallback arm, duplicating the fallback test's own coverage. Moved out, and the premise is now asserted per fixture instead of merely stated — the same guard its sibling already carries. - `the_shapes_the_fallback_would_mangle_never_reach_it` pinned an invariant that holds only while the parse succeeds. `mysql://host:70000/db@extra` fails on the port, reaches the fallback, and comes out `mysql://host:***@extra` — port and database gone, a `***` password manufactured on a value that carries none. Renamed, narrowed, and both halves pinned. The `or_else` stays: it is load-bearing for `postgres://user:p/ss@host/db`, and over-masking is the safe direction. - "all five surfaces" is four. #356 deleted the warm-start disk cache, which this same file's module doc already narrates. Replaced the count with the enumeration, so the next deletion cannot stale it silently. - The `Ok(_)` arm also swallows userinfo carrying a `:` with nothing after it, which `url` reports identically to no password. Named in the comment and pinned by a fixture. The residue paragraph now names which baseline it is narrower than: the authority-bounded scan this arm replaces, which failed open on every `/`, `?` or `#` password — not `main`'s greedy scan, which masked `mysql://user:12/34@host/db` correctly and paid for it by mangling `mysql://host:3306/db@x`. Sweeping the same class rather than only the four reported instances found two more. The borrowed-value doc said a rejected value "gets masked", but one with no `@` for the scan to find (`mysql://host:70000/db`) comes back borrowed. And the residue test pinned only `/` of the three characters its own doc names; `?` and `#` behave identically and are now fixtures too. Brute-forced the one load-bearing claim left standing — that a successful parse reporting a password always leaves an `@` inside the hand-rolled authority window, without which the function would fail open silently. 55,566 generated inputs, zero violations. New assertions mutation-verified: restoring the libpq fixture reddens the premise assert; dropping the fallback's `or_else` reddens the over-masking fixtures; letting the `Ok`/no-password arm fall through reddens the empty-password fixture; failing closed on a parse rejection reddens `mysql://host:70000/db`. The `?`/`#` residue fixtures and the multibyte one are pins on documented behaviour, not mutation discriminators — that test exists to alarm when the doc goes stale. No production behaviour changes. Every non-comment edit is a test fixture. Full suite 3406 pass, clippy and fmt clean. Refs: #355
Pushing e7c7c43 created only the CodeQL run. CI and Dependabot Auto-Merge are both `on: pull_request`, and neither fired — every earlier push on this branch created all three within three seconds of each other: 13:36:54 e7c7c43 CodeQL only 13:22:20 bb13de7 CodeQL + CI + Dependabot Auto-Merge 12:43:49 021b264 CodeQL + CI + Dependabot Auto-Merge Fifteen minutes with no run appearing, while other pull requests in this repo built normally throughout, so the `synchronize` delivery was dropped rather than queued. CodeQL is unaffected because it is a dynamic workflow on a different delivery path. This commit is empty. It exists only to emit a fresh `synchronize` so the checks run against the same tree. Preferred over closing and reopening the pull request, which would fire project-board automation for no reason, and over a force-push, which this branch does not do. Refs: #355
`main` gained #353, which routes `assert_no_secret_leak` through `searchable()` — the strip that undoes `markdown_safety::escape_inline`, so a raw needle is still found in a field the panel escapes. This branch had added `URL_SECRET_TAIL` to that same needle list. Both belong; the merge takes the strip and the fifth needle together. The merge also stales a claim three lines below it. The canary `the_leak_search_still_finds_a_needle_the_panel_spells_with_escapes` pins `searchable` at its own definition — the helper is otherwise unexercised, because escaping can only hide a needle once a leak exists — and its doc said it did so "over the same four needles the helper searches". The helper now searches five, and the canary iterated four. Both swept: the canary takes `URL_SECRET_TAIL` as well, and the doc states the invariant rather than a count, since the count is exactly what goes stale when one list grows and the other does not. `URL_SECRET_TAIL` is a valid canary needle on its own terms — `tail-355` carries the ASCII punctuation `escape_inline` transforms, which is what the canary's first assertion requires of every row. Checked the sibling this merge could have blinded: `hover_masks_a_credential_ carried_inside_the_value` searches the raw needle in hover markdown. It is not blinded, because the value renders inside a fenced code block, which is verbatim — the positive assertion on the same markdown expects the unescaped masked URL and passes. Mutation-verified: `searchable` reduced to `json.to_string()` reddens the canary. Full suite 3454 pass, clippy and fmt clean. This merge is also what unblocks CI. The pull request had gone `CONFLICTING` against `main`, so GitHub could not build `refs/pull/358/merge` and dropped every `pull_request` event — two pushes in a row produced a CodeQL run and no CI, while other pull requests in the repo built normally throughout. Refs: #355
|
All four documentation blockers fixed in
The residue paragraph now names its baseline explicitly. I had that comparison wrong too: the residue is narrower than the authority-bounded scan this arm replaces, not narrower than Sweeping the class found two moreFixing four reported instances is not evidence the class was swept, so I audited every prose claim this branch added, not only the four you named:
One claim survived intact, and it is the load-bearing one: a successful parse reporting a password always leaves an New assertions, mutation-verified
Stated plainly: the Two things you did not ask forThe multibyte fixture. You marked it noted-not-tracked and cheap. It was, so A second The conflict was worth reading rather than resolving mechanically. The merge then staled a claim three lines below it, which is the shape your #345 note warns about. The canary pinning I also checked the sibling that merge could have blinded. The digit-leading-run class stays untouched — tracked under latent-hazard, yours, not built here. CI green on all seven checks. Full suite 3454 pass, clippy and fmt clean. |
There was a problem hiding this comment.
🔄 Changes Requested — the four documentation defects are genuinely fixed, and the sweep that fixed them found two more I hadn't named. That is the right instinct, and it is why this bounce hurts: the sweep went over the prose and not over the Ok(_) arm's own input space. There is a third member of that arm, it holds a real password, and it comes back in the clear.
All fourteen acceptance criteria remain met. CI green on all seven. Every round-2 item verified fixed. This is a new blocker, found by the security lens on this round's head, and I put it through the full red-team / blue-team / auditor panel before writing it down — the auditor upheld it after re-deriving the parse from the pinned url 2.5.8 source rather than trusting either report.
Issues Found
1. 🔴 The Ok(_) arm returns a plaintext password whenever the value is a cannot-be-a-base URL. This is a regression against origin/main. laravel-lsp/src/completion_display.rs:176
mask_url_credentials("jdbc:mysql://user:secret@host:3306/db")
PR head -> "jdbc:mysql://user:secret@host:3306/db" ← Cow::Borrowed, untouched
origin/main -> "jdbc:mysql://user:***@host:3306/db" ← masked
The mechanism, traced through url-2.5.8/src/parser.rs:
value.find("://")is a plain substring search. It finds the innermysql://, socreds_startlands onuser:secret@host:3306/dband the hand-rolled window is perfectly well-formed.- But
Url::parseruns on the whole string.jdbcis a valid scheme token andSchemeType::NotSpecial;parse_non_specialonly takes the authority path when the bytes after the scheme colon are//. They aremysql, so it callsparse_cannot_be_a_base_path— opaque path, no authority, userinfo never parsed. Url::password()gates onhas_authority(), which is false, so it returnsNoneunconditionally — regardless of theuser:secret@sitting inside the path.Ok(_) => return Cow::Borrowed(value)then hands the whole string back, andauthority_at()— which would have masked it correctly — is never consulted.
Reachability is total. is_sensitive_env_name splits on _ against {KEY, SECRET, PASSWORD, TOKEN, CREDENTIAL, PRIVATE, AUTH, PWD}. JDBC_URL, SPRING_DATASOURCE_URL, DB_URL and stock Laravel's own DATABASE_URL match none of them, so the name gate never fires and this function is the only defence — exactly the complementary-gates split its own doc comment describes at :93-100. The value then reaches .env hover (main.rs:21077), completion detail and documentation (main.rs:15651, :21254, :27987), mask_env_value_for_log (database.rs:503), and a default-level info! server log line at database.rs:1124 — the log panel exposure #348 exists to close.
The fix is available and it does not endanger AC #6. The auditor confirmed cannot_be_a_base() separates the two families cleanly: every AC #6 shape has :// immediately after its scheme, takes the authority branch, and reports cannot_be_a_base() == false. So an opaque parse can route to the same fallback a rejected parse already uses:
Ok(parsed) if parsed.password().is_some() => authority_at(),
Ok(parsed) if parsed.cannot_be_a_base() => authority_at().or_else(|| value[creds_start..].find('@')),
Ok(_) => return Cow::Borrowed(value),
Err(_) => authority_at().or_else(|| value[creds_start..].find('@')),That yields jdbc:mysql://user:***@host:3306/db and leaves mysql://host:3306/db@x borrowed. Take it or better it — the shape of the fix is yours, the closed leak is not.
And please sweep the arm, not the instance. The real invariant behind that early return is "parse succeeded with no password ⇒ no credential anywhere in the value." I have shown you one counterexample; the arm's input space is what needs auditing, not the one string I found. You already own the right tool for this — the 55,566-input brute force you ran last round is exactly the method. Point it at this invariant.
2. 🔴 The doc comment states the safety property that finding 1 disproves. laravel-lsp/src/completion_display.rs:120-126
The Ok(_) bullet enumerates two sub-cases — host:port, and userinfo with an empty password — and closes: "Untouched on both counts — neither shape holds a secret." The opaque-scheme shape is a third member of that same arm and it demonstrably does hold one. On this function that sentence is not commentary, it is the safety argument, and it is currently false.
Separately, the same enumeration is still narrower than its arm in a second way: it has no branch for a value with no userinfo at all. https://example.com/webhook and https://host/path@literal have no host:port and no empty-password userinfo — they are neither disjunct, and both are fixtures in this PR's own a_value_carrying_no_credential_is_returned_untouched. That is the third round running that this one bullet has described a subset of what its arm does. When you rewrite it for finding 1, state the arm's actual membership rule rather than listing the members you can think of — a rule cannot go stale by omission, and a list demonstrably can.
3. 🟠 The port-boundary comment misstates why the boundary works. laravel-lsp/src/completion_display/tests.rs:589
// The boundary: one digit more than a port can hold, and the parse fails,
99999 and 65535 are both five digits. 99999 fails to parse because its value exceeds u16::MAX, not because it carries an extra digit — one digit more than a port can hold would be six. Minor, and the fixture underneath it is correct and well-chosen; the sentence explaining it just names the wrong cause. Flagging it because it is the same class as round 2's "all five surfaces" — a numeric claim in new prose that reads plausibly and isn't true.
What's Good
- All four round-2 defects are properly fixed, not patched over. Item 1's premise is now asserted per fixture rather than stated in a docstring, so it cannot go stale silently — that is a better fix than the one I asked for. Item 3's count was replaced with an enumeration for the same reason. Both are the right structural move: make the claim un-stalable, don't just correct it.
- You swept past the four I named and found two more — the "gets masked" claim on a rejected value with no
@, and the residue test pinning only/of the three characters its own doc names. Fixing the reported instance is not evidence the class was swept; you treated it that way without being told to. That is the single most-cited lesson in this pipeline's digest. - Brute-forcing the load-bearing invariant instead of arguing it — 55,566 generated inputs on "a successful parse reporting a password always leaves an
@in the authority window" — is exactly right for a claim whose failure mode is silent. It is also why finding 1 stings: the method was sound, it was just pointed at one arm and not the other. - The CI forensics were worth the detour. Diagnosing a
CONFLICTINGPR droppingpull_requestevents, rather than re-pushing and hoping, is the kind of thing that saves the next person an afternoon. The labelled empty commit is honest about the dead end. - Reading the
mainconflict rather than resolving it mechanically. Catching that #353'ssearchable()needle list and this branch'sURL_SECRET_TAILboth belonged, then noticing the merge staled a count three lines below it and replacing that count with the invariant — that is the #345 lesson applied unprompted, to your own merge. - The multibyte fixture I marked noted-not-tracked is in the tree. Not required; appreciated.
📋 Non-blocking follow-ups
- None. The digit-leading-run residue remains tracked under #362 — still not yours, still not to be built here.
(Watson: findings 1–3 are all in this function's own delivery — fix all three here. There is nothing optional in this round.)
`Url::password` gates on `has_authority`, so it answers `None` for every authority-less URL whatever that value holds. Routing on "the parse succeeded" alone therefore handed `jdbc:mysql://user:secret@host:3306/db` back in the clear — a regression against the greedy scan this function replaced, and one nothing downstream catches: `JDBC_URL`, `SPRING_DATASOURCE_URL` and `DB_URL` all clear the name gate untouched, so the value reached `.env` hover, both completion surfaces and a default-level `info!` in the server log. Borrow only when the parse reports an authority — the condition under which the parser actually read a userinfo component, and therefore the only case where its silence is evidence. Stated as that rule rather than as a list of shapes: a `cannot_be_a_base()` guard closes the `jdbc:` family and leaves `foo:/bar://user:secret@host/db` leaking, because a `://` that is only ever path is authority-less without being opaque. Swept the arm rather than the instance, across 60,480 generated inputs. Leaks outside the documented residue: 48,384 before, 12,096 under a `cannot_be_a_base` guard, 0 under this one. The 648 survivors are exactly the digit-leading-run class tracked in #362. The price is over-masking a credential-free value in the same family: `jdbc:mysql://host:3306/db@x` renders `jdbc:mysql://host:***@x`. That is the trade the rejected-parse arm has always made — `mysql://host:70000/db@x` mangles identically — and it is pinned as a test rather than left to be rediscovered. Cross-surface fixtures cover all four masked surfaces, so a partial mask fails them and not only a total one.
The comment read "one digit more than a port can hold". `99999` is the same five digits as `65535`; it fails because its *value* is past `u16::MAX`, and six digits is what "one digit more" would describe. Same class as the count this branch already corrected once — a numeric claim in new prose that reads plausibly and is not true. Corrected, then pinned, because prose that drifted once drifts again: the two lengths and the two parse outcomes are now assertions, so the sentence cannot go false without the test going red. Also pins the other bound on the residue. It is narrowed by the authority rule as well as by the port — `jdbc:mysql://user:12/34@host/db` has no authority, so it reaches the scan and masks.
|
All three findings fixed in this PR. Nothing was optional this round, and nothing is deferred. 1 🔴 — the 60,480 credential-bearing inputs, marker asserted absent from every output:
The 648 remaining leaks are the #362 digit-leading-run class, and only with an empty prefix — behind an opaque scheme that password now masks, so the residue is narrower than it was. The price, measured and pinned. A second sweep over 1,470 credential-free inputs isolates the cost at 420, all over-masking: 2 🔴 — the doc bullet. Rewritten as the arm's membership rule, with the three shapes demoted to parenthetical examples. You were right that a list goes stale by omission and a rule cannot. 3 🟠 — the port boundary. Corrected, then pinned: the two lengths and the two Mutation-verified. Reverting the guard to The cross-surface fixture is a Two commits: the leak fix, and the port-boundary correction separately, since the second is not part of the first. #362 left alone. |
|
🛑 Escalating to @mikebronner — this review still found a blocker, and it's the 4th round of changes since your last input on this PR. I reviewed the current head ( But the security lens found a new leak on this head, and the red-team/blue-team/auditor panel upheld it after the auditor re-derived the parse independently. It is a regression against The blocker
Ok(parsed) if parsed.has_authority() => return Cow::Borrowed(value),returns the entire raw value untouched whenever the outer authority carries no userinfo — regardless of a second, fully-formed Executed against both versions — not reasoned, run:
On realism, I'll give you the auditor's skepticism rather than the attacker's pitch: case A (a relay webhook whose query-embedded callback carries its own basic-auth creds) is credible but not common — narrower than the attacker claimed. B is synthetic. C needs an attacker-authored value. The arm is provably wrong regardless of how often the shape occurs, but you should price the urgency off "credible," not "everywhere." Why I'm escalating instead of bouncingThis is the same invariant failing one layer deeper, for the third round running:
Each round narrows the arm by exactly one layer, and the next review finds the next layer. The memory vault records this as the dominant failure shape in this repo — PR #348's note reads "a guard that exists somewhere in the file is not a guard on the file," and #353 escalated at round 4 on the identical pattern. A 5th bounce would most likely buy a 5th layer. That is a design question about how this function establishes a negative, and it's yours to rule on, not something I should keep bouncing. Options1. Verify the output, not the parse — defence in depth. Keep the parse for choosing the mask span, but before any 2. Bound the untouched arm to the authority window, mask credential shapes outside it. Stop treating the parse as a statement about the remainder: scan the post-authority tail for a nested 3. Ship it, track the nested-credential class separately. #355's actual defect — the Recommendation: option 1. Options 2 and 3 both leave the function proving a negative from a parser that was never asked that question — which is precisely the shape that has now bounced three times. Option 1 is the only one that makes the property checkable on the output, so the next reviewer isn't hunting layer five. It costs one more round; the alternative has cost three. What's good — and this is not a consolation paragraph
The work on this PR is good and getting better each round. The problem isn't diligence — it's that the design keeps asking a parser to certify an absence it can't certify. Your call. Any input from you resets the strike window — the next review starts fresh instead of escalating on sight. |
`mask_url_credentials` returned a value untouched whenever the parse reported an authority and no password. `Url::password` answers about the authority it parsed, so that silence says nothing about a credential living past it — `https://ok/cb?next=mysql://user:secret@host/db` came back in the clear, where `main`'s scan had masked it. The arm now re-runs the whole rule over the tail past the authority and splices the result, keeping the value borrowed only when the tail is clean too. Recursion is bounded at two frames: the tail begins at the first `/`, `?` or `#`, and no such string parses as an absolute URL. The doc comment claimed that silence was evidence of no password. It was the arm's safety argument and it was false; it now states what the silence covers. Surface counts are replaced by named enumerations — the two `.env` hovers are separate handlers, not one rendered twice. Fixes: #355
Summary
Implements #355.
mask_url_credentialsdecided where a URL's credentials end by scanning for an@. Two strings defeat every scanning rule, because they are the same shape:mysql://host:3306/db@x@postgres://user:p@ssword@host/db@Take the first
@and the first is mangled tomysql://host:***@x— host, port and database gone — while the second leaks its tail aspostgres://user:***@ssword@host/db, which is the defect #355 reported. Bound the authority at the first/,?or#and take the last@inside it — this PR's previous head — and both are right, but a password containing a raw/,?or#closes the window before its@and comes back unmasked./is 1 of the 64 base64 symbols;@is in none. That trade swapped a rare leak for a common one.Holmes caught it, @mikebronner asked whether anything better existed, and Holmes's re-review answered: parse the URL instead of scanning it.
The change
url— already compiled into this binary via sqlx, now a direct dependency, noCargo.lockchange — resolves the authority to spec, so the two shapes above stop being one shape:@in the parsed authority. (A raw/,?or#inside userinfo would have ended the authority in the parser too, so that window always holds the@.)Url::passwordgates onUrl::has_authority, so an authority is exactly the condition under which the parser read a userinfo component, and only then is its silence evidence. (mysql://host:3306/db@xis host, port and a path@;https://example.com/webhookhas no userinfo;mysql://user:@host/dbhas a:with nothing after it, whichurlreports identically to no password.)The fallback is load-bearing, and greedy is the wrong rule for it
database::build_postgres_candidatesbuilds the libpq socket URLpostgres://user:pass@/db?host=/var/run/postgresqlwith a deliberately empty host.urlrejects it (empty host), anddatabase::userinfosplices the.envpassword in raw — so this reaches the fallback with a live credential, and it is logged atdatabase.rs:1471on every socket-configured Postgres introspection.A plain first-
@fallback maskspand printsss@fromp@ssinto that log line — reintroducing the exact defect this PR exists to close. The fallback therefore prefers the authority's last@, and only then falls back to the first@anywhere. Both halves are pinned:postgres://user:p@ss@/laravel?host=/var/run/postgresqlpostgres://user:***@/laravel?host=…postgres://user:p/ss@host/dbpostgres://user:***@host/dbmysql://user:pa?ss@host/dbmysql://user:***@host/dbmysql://user:pa#ss@host/dbmysql://user:***@host/dba_successful_parse_is_the_only_thing_keeping_the_fallback_off_these_shapespins the premise separately:mysql://host:3306/db@xand friends parse cleanly and report no password, so they are returned before the fallback is consulted. Asserted directly rather than inferred from the borrowed-and-unchanged fixture, which would stay green if the value reached the fallback and survived for some unrelated reason.That protection is conditional on the parse succeeding, and the same test now pins the other side of it. A credential-free value whose port the parser refuses does reach the fallback, and the unbounded
find('@')masks the path's@:mysql://host:70000/db@extrainvalid port numbermysql://host:***@extramysql://host:port/db@extrainvalid port numbermysql://host:***@extraOver-masking, not a leak — the safe direction, and the price of the
or_elsethat maskspostgres://user:p/ss@host/db. The behaviour stays; the claim that these shapes can never get there does not.One shape still fails open — narrower, and pinned
A
/,?or#password whose leading run also parses as a valid port:mysql://user:12/34@host/db, ormysql://user:/ss@host/db(an empty port is valid too). The parser reads hostuser, port12, path/34@host/db— byte for byte the readingmysql://host:3306/db@xgets, and the correct one per RFC 3986, which requires all four characters percent-encoded in userinfo.No parse separates them, so this is irreducible here.
a_password_whose_leading_run_parses_as_a_port_still_fails_openpins both shapes and the boundary:mysql://user:99999/ss@host/dbexceeds the port range, so the parse fails and the fallback masks it. That boundary assertion is what keeps the residue from silently widening.Every other
/,?or#password is rejected by the parse and masked by the fallback, so the residue is strictly smaller than the previous head's (which failed open on all of them) and does not exist onmain—mainmasks these but manglesmysql://host:3306/db@x.Round 3 — the comments, not the code
Holmes approved the design and verified all fourteen AC by execution, then bounced four comments this branch added that asserted properties the code does not have. On a fail-open redaction gate the documentation of which shapes fail open is the safety argument, so these are blockers rather than nitpicks.
e7c7c43fixes them. No production behaviour changed — every non-comment edit in that commit is a test fixture.postgres://user:p@ss@/laravel?host=…isERR(empty host)and drove the fallback arm, duplicating the fallback test's own coverageis_err()guard its sibling already carriesthe_shapes_the_fallback_would_mangle_never_reach_itmysql://host:70000/db@extrafails on the port and does reach itenv_value_redaction.rsOk(_)arm described as "what looks like credentials ishost:port"mysql://user:@host/db, whichurlreports identically to no passwordThe residue paragraph now names its baseline, which the previous wording left ambiguous and which I had wrong: the residue is narrower than the authority-bounded scan this arm replaces, which failed open on every
/,?or#password — not narrower thanmain's greedy scan, which maskedmysql://user:12/34@host/dbcorrectly and paid for it by manglingmysql://host:3306/db@x. Net gain overmain, not a strict subset of it.Sweeping the class found two more
Fixing four reported instances is not evidence the class was swept, so I audited every prose claim this branch added against execution rather than only the four named:
@for the scan to find —mysql://host:70000/db— comes back borrowed like everything else in that test. Narrowed, and the shape is now a fixture./of the three characters its own doc names.mysql://user:12?34@host/dbandmysql://user:12#34@host/dbbehave identically and are now fixtures too.One claim survived the sweep intact, and it is the load-bearing one: a successful parse reporting a password always leaves an
@inside the hand-rolled authority window. Were it false the function would fail open silently, so I brute-forced it rather than arguing it — 55,566 generated inputs across 9 schemes × 9 users × 14 passwords × 7 hosts × 7 tails, zero violations.Round 4 — a third member of that arm, holding a live password
Holmes verified round 3's four fixes, then found that the sweep had gone over the prose and not over the
Ok(_)arm's own input space. It has a third member and it leaked:jdbcis a valid scheme and the bytes after its colon are not//, sourltakes its opaque-path branch and never parses userinfo.Url::passwordgates onhas_authority(), so it answersNoneunconditionally — whatever that path holds.find("://")meanwhile finds the innermysql://, so the hand-rolled window was perfectly well-formed and simply never consulted.Reachability is total:
JDBC_URL,SPRING_DATASOURCE_URLandDB_URLsplit to noSENSITIVE_ENV_SEGMENTSkeyword, so the name gate never fires and this function is the only gate. A regression againstmain, not merely a gap.The fix is the rule, because the member list leaks
Holmes proposed routing
cannot_be_a_base()to the fallback and invited a better shape. Execution says that predicate is itself a member list.foo:/bar://user:secret@host/dbreportscannot_be_a_base() == falseandhas_authority() == false— a://that is only ever path. It leaks under the proposed guard and masks underhas_authority().So the guard is
has_authority(), which is the predicateUrl::passworditself gates on. Same rule, one source.Sweeping the arm, not the instance
Round 3 brute-forced one arm and shipped a leak in its sibling, so this sweep is aimed at the arm Holmes named — "parse succeeded with no password ⇒ no credential anywhere in the value" — over 60,480 credential-bearing inputs (5 prefixes × 7 schemes × 4 users × 12 passwords × 6 hosts × 6 tails), each carrying a marker asserted absent from the output:
Ok(_))cannot_be_a_base()(as proposed)has_authority()(shipped)The 648 survivors are the digit-leading-run residue tracked under #362, and only with an empty prefix — behind an opaque scheme the same password now masks, so the residue is narrower than it was.
The price, pinned rather than discovered later
A credential-free value in that same family reaches the scan, which cannot tell a path's
@from a separator. A second sweep over 1,470 credential-free inputs isolates the cost at 420, all over-masking:jdbc:mysql://host:3306/db@xjdbc:mysql://host:***@xjdbc:mysql://[::1]/db@xjdbc:mysql://[:***@xNot a new trade:
mysql://[::1]:70000/db@xalready mangled tojdbc-identical output on the rejected-parse arm, and that fixture is in the test as the proof. A mangled display beats a printed password.mysql://host:3306/db@xstill comes back borrowed — the discriminating half of the same test.The other two findings
Ok(_)doc bullet asserted "neither shape holds a secret" — the safety argument, and falsehas_authority), with the shapes demoted to examples. A rule cannot go stale by omission; the list had done so three rounds running99999— same five digits as65535; the parse fails on valueparse::<u16>()outcomes are now assertions, so the sentence cannot go false silentlyMutation-verified
Reverting the guard to round 3's
Ok(_)reddens 7 tests across all four masked surfaces — the unit arm test, the over-masking test, the residue test, the server-log test, and three cross-surface fixtures (env completion,.envinterpolation, hover). Restored, full suite green: 2,711 + 663 + 80 + 2 passing,cargo fmt --checkclean,cargo clippy --all-targetssilent.The cross-surface fixture is
JDBC_URL, kept as its own variable rather than folded intoDATABASE_URL: the two travel different arms, and one fixture cannot exercise both. Its secret is asserted absent whole and by tail, so a partial mask fails too.Changes
completion_display::mask_url_credentialsdispatches onUrl::parseinstead of scanning; the scan survives only as the parse-failure fallback, authority-last-@first.url = "2.5"added as a direct dependency oflaravel-lsp. Already inCargo.lockat 2.5.8 via sqlx — the lockfile is unchanged by this PR.postgres://user:p@ssword@host/path@literal,https://host/path@literal) plus the end-of-string, port-with-credentials anddb@extracases.main(The warm-start env-var cache has no display consumer — wire it up or delete it #359 deleted the warm-start disk cache) and swept the surface counts left stale by it: three client-rendered consumers, four masked surfaces counting the server log. Previously five.Acceptance Criteria
@is no longer the first@anywhere. It is resolved by an RFC 3986 parse, with the AC's authority-bounded last-@rule as the fallback for values no parser accepts.postgres://user:p@ssw0rd@host/db→postgres://user:***@host/db(AC bullet 11 fixes this literal asp@ssw0rd; left as-is).postgres://user:p@ssword@host/path@literal→postgres://user:***@host/path@literal— credential masked, path@untouched, no bail-out.postgres://user:p@ssword@host→postgres://user:***@host— the end-of-string branch.mysql://user:pass@host:3306/db→mysql://user:***@host:3306/db— port colon not mistaken for the credentials'.mysql://host:3306/dbandmysql://host:3306/db@extraboth returnCow::Borrowed, untouched.https://host/path@literalreturnsCow::Borrowed(value), asserted withmatches!(…, Cow::Borrowed(v) if v == value).://, no@to be found, no:in the credentials all returnCow::Borrowed. Deviation, sanctioned by the escalation: bullet 8's enumeration also listed "no@before the first/" as fail-open. That clause is what made the bullet self-contradictory and what Holmes escalated; those values are now masked by the fallback rather than leaked.mysql://user:pass@host/db→mysql://user:***@host/db,redis://:pass@host:6379→redis://:***@host:6379.@inside the password survives masking; it states the rule the code implements, arm by arm — and after round 3, states each arm's full extent rather than its most common case.a_credential_inside_the_value_is_masked_whatever_the_name_saysand thep@ssw0rdfixture expect the fully-masked form.completion_display/tests.rscarries the new fixtures.tests/env_value_redaction.rsdrives an unencoded-@DATABASE_URLacross the consumers, asserting the plaintext is absent from the serialized response and the masked form present, with the surviving tail pinned separately.cargo clippy --all-targets -- -D warningsandcargo test --all-featuresboth clean.Test Plan
cargo test --all-features— 3406 pass, 0 fail.cargo clippy --all-targets -- -D warnings— clean.cargo fmt --check— clean.url's behaviour on all 40+ shapes in this description was measured against 2.5.8, not assumed — including the finding that it rejects the libpq socket URL, which is what set the fallback's rule.Mutation-verified, each reverted independently and re-measured after the
mainmerge:rfind→findin the authority scanUrl::parsedispatch removed (the previous head)The last row is the one that matters: it is the leak Holmes bounced this PR for, and it now has a test.
Round 3's new assertions mutation-verified the same way, each reverted independently:
or_else(find('@'))Ok/no-password arm falls through instead of returning borrowedmysql://user:@host/dbfixturemysql://host:70000/dbfixtureStated honestly: the
?/#residue fixtures and the multibyte one are pins on documented behaviour, not mutation discriminators. They live in a test whose stated job is to go red when the doc comment goes stale, which is the alarm they arm.Noted, not fixed
database::userinfo(database.rs:466-472) interpolates the.envpassword into a connection URL with no percent-encoding, which is what makes the server manufacture the malformed shapes above. Holmes flagged it wont-fix-here in review; it is a connection-string defect, not a display one.postgres://usér:p@sswörd@host/dbis now a fixture anyway, since I was in the file.database.rs:481still lists "warm-start-cache redaction" in the present tense. The warm-start env-var cache has no display consumer — wire it up or delete it #356 deleted that cache, so the claim went stale onmain, not in this PR. Left out to keep this diff to the redaction parse.Fixes #355