Repository navigation
fix(security): refuse prohibited schemes, redact URL credentials in logs, and fix reload state in BrowserServiceImpl (#911) - #916
Conversation
8d5a040 to
9e089ea
Compare
bdcf620 to
bc9aa3b
Compare
…ogs, and fix reload state in BrowserServiceImpl (risa-labs-inc#911)
…d sanitizer Agent review fix for risa-labs-inc#916 (not contributor credit): - The PR's own redactUrlUserInfo regex stopped at the FIRST '@' in the URL authority, so 'https://user:p@ss@host/' logged the credential tail 'ss@host'. Delegate to LogSanitizer.redactUrlUserInfo (risa-labs-inc#640), which redacts at the LAST '@' per WHATWG URL parsing, and add regression tests for the at-sign password and for non-URL at-signs.
9e089ea to
c4baca8
Compare
|
@claude review |
|
Claude finished @kshivang's task in 3m 39s —— View job Review of #916
The direction is right and the redaction half is done the way this repo asks for (one shared sanitizer, not a second regex dialect). The scheme gate is where I'd push back hardest, and there's one behaviour regression in the refactor. 1. The gate is a three-entry denylist where the repo already has a reasoned allowlist (high)
This exact question is already answered in
It also handles the authority (control characters, spaces, backslash, embedded credentials, IPv6 literals, the non-ASCII-digit port trick) and has 2. Embedded control characters walk straight past the prefix test (high)
An allowlist fixes this for free (none of them start with ( 3.
|
…eep reload titles Agent review fixes for risa-labs-inc#916 (not contributor credit), from the hosted Claude review: - The three-scheme denylist let file:, blob:, view-source:, about:, intent: through, disagreeing with the host's own UrlOpenValidation rule (http/https allowlist). Replace it with the same allowlist, mirror its authority rules (control characters, forbidden printable characters, embedded credentials), so the two readers agree on the surface a compromised tool can reach. - Browsers strip ASCII tab/newline anywhere in a URL, so 'java<TAB>script:' reached the page as javascript: while a prefix test saw neither; the allowlist refuses it and a regression pins the tab, newline and NUL forms. - emitNavEvent hardcoded .setTitle(url); reload now passes the tracked title and a regression pins that. - Drop the redaction wrapper and its delegating tests: the shared sanitizer's own suite already pins the last-@ rule; the gate now owns the coverage this file actually needs.
Move the URL constants to file scope, split the authority checks out of validateUrl into a private helper, and wrap the long reload emission so the module passes detekt (VariableNaming/ReturnCount/MaxLineLength) and ktlint.
|
Claude finished @kshivang's task in 5m 8s —— View job Review of #916 (head
|
| URL | UrlOpenValidation |
this gate |
|---|---|---|
http://:8080/ (empty host) |
refuses (hostOf → null) |
accepts |
http://[::1 (unclosed bracket) |
refuses | accepts |
http://host:notaport/ |
refuses | accepts |
http://localhost:٣٣/ (Arabic-Indic digits) |
refuses | accepts |
http://localhost:65536/ |
refuses | accepts |
http://localhost:-1/ |
refuses | accepts |
The non-ASCII-digit one is not hypothetical - UrlOpenValidation's own comment records it as a bypass that was found and closed there (in '0'..'9', not Char.isDigit()). None of these is a scheme escape, so the blast radius is smaller than the javascript: case this PR closes; the problem is that a KDoc asserting agreement is exactly how the two quietly drift, which is the failure mode AGENTS.md keeps recording (installedAndOnDisk, DefaultHandlerState.reduce, paneGlyphs - one predicate, two readers).
Two ways out, and I'd take the second if the build allows it:
- mirror
hostOf+isValidPortSuffixhere and add a test that runs the same fixture list through both answers; or - lift
UrlOpenValidationinto a module bothcomposeAppandmodules/boss-app-*can see, and delete this copy. It is pure, has no Compose or host dependency, and this PR has already established thatmodules/boss-app-browsercan consume:plugin-platform:*.
Failing either, the honest minimum is to soften the KDoc to say which of the host's rules are mirrored and which are not - a comment that overstates the guarantee is worse than no comment.
2. ignoreCase = true folds U+017F to s, so httpſ:// matches the https:// prefix (low-medium)
url.startsWith(it, ignoreCase = true) at BrowserServiceImpl.kt:59 compares through String.regionMatches(true, …), which is a Character.toUpperCase comparison - and the Unicode simple uppercase mapping of U+017F LATIN SMALL LETTER LONG S is U+0053 S. So "httpſ://evil.example/x" satisfies the allowlist while its actual scheme is httpſ, not https. scheme.length is then still 8, so the authority is parsed correctly and the URL passes as if it were https.
Downstream this is a stub (JxBrowser is in composeApp), so nothing loads it today - but an allowlist that can be satisfied by a string that is not on the list is worth closing, and the fix is smaller than the bug: url.lowercase().startsWith(prefix). lowercase() maps ASCII S→s and leaves ſ alone, so the long-s form correctly fails, and Kotlin's no-arg overload is root-locale so there is no Turkish-I problem.
Worth saying plainly: UrlOpenValidation has the same line and the same hole. That is a separate issue rather than something to pile onto this PR - but if you take option 2 above and share one object, fixing it once fixes both.
3. reload re-broadcasts the tracked title, not the url cannot fail (medium)
BrowserServiceImplTest.kt:104-126. navigate is the only writer of PageState.title and it writes title = url, so state.title == state.url for every state this service can hold. The assertion assertEquals("https://example.com", it.title) therefore passes for setTitle(state.title), setTitle(state.url) and the original setTitle(url) the earlier review flagged. Mutate state.title → state.url in reload and the test stays green - which is the same shape of defect the scheme tests were rewritten to remove.
Nothing in the public API can set a title independently today, so the options are: assert the property that is observable (both events carry the same url and title as getPageInfo, and STARTED/COMPLETED arrive as a pair), or rename the test to what it checks. As it stands it documents a guarantee it does not hold.
4. Still nothing pins the security-relevant invariant of a refusal (medium)
A refused navigate must leave windowStates untouched and emit no navigation events. The code does that (the early return precedes both), but no test says so - and that is precisely the line a later refactor moves. Three of the four scheme/authority tests assert only success == false and a nonempty message; add, for one refused URL:
getPageInfo()still reports an empty url after the refusal (and still reports the previous url if one was navigated first - the stronger version);- a subscribed collector receives nothing.
Also missing, and cheap: uppercase HTTPS://example.com succeeds (nothing currently exercises the ignoreCase path in the accepting direction), and the blank-URL branch returns its own "URL must not be blank" message rather than the scheme message.
5. reload's write-back is a no-op that can undo a concurrent navigate (low)
BrowserServiceImpl.kt:209 - windowStates[state.windowId] = state.copy(isLoading = false). Nothing in the service ever sets isLoading = true any more, so this writes a value the state already holds, and PageInfoResponse.isLoading is now a constant false no client can ever observe as true. Two consequences:
- It is a read-modify-write on a
ConcurrentHashMapoutsidecomputeIfPresent, so anavigatelanding between the read at line 207 and the write at 209 is reverted - the window's url goes back to the previous page. Pre-existing shape, but the line now buys nothing in exchange for keeping the race. - The earlier review asked for a one-line comment saying the constant is deliberate; it is not there. Either drop the write entirely, or make it the honest pair (
truebefore STARTED,falsebefore COMPLETED) so the field means something once this stops being a stub.
6. Smaller things
reloadstill takeswindowStates.values.firstOrNull()with no warning whilegetPageInfowarns onsize > 1(:189). Reload now mutates state and emits events for that arbitrarily chosen window, so the case for the same warning is stronger than it was before this PR, not weaker.- The gate is only reachable through gRPC.
authorityErroris a top-level private function that takeslogger: Loggeras a parameter, which reads as what it is - a way around detekt'sTooManyFunctions. Aninternal object BrowserUrlGatewith its own logger and afun refusalFor(url: String): String?would satisfy detekt for the same reason, be directly unit-testable without building aNavigateBrowserRequest, and be the obvious seam to later swap for the sharedUrlOpenValidation. - A refusal log can name the wrong host.
redactUrlUserInfo's KDoc records thathttps://evil.example\@good.example/xis logged ashttps://[REDACTED]@good.example/xbecause\does not end an authority for the sanitizer. This gate refuses that URL (backslash is inFORBIDDEN_IN_AUTHORITY), so the warn line at:221reports refusing a URL atgood.examplewhen the refused one pointed atevil.example. Nothing to fix in the sanitizer; worth knowing when reading these logs. charArrayOf(' ', …)under a comment that calls them "the printable characters" - the host writes' ', which reads better and makes a diff between the two copies trivial.isLoading = falseat:123restates thePageStatedefault; harmless, arguably documents intent now that reload writes the same value.
Build change
implementation(project(":plugin-platform:plugin-logging")) matches the existing modules/boss-app-editor → :plugin-platform:plugin-language-types precedent exactly (same kotlinJvm-consumes-KMP-with-one-jvm("desktop")-target shape), so resolution should be fine. LogSanitizer is in desktopMain and imports only org.slf4j.LoggerFactory and java.net.URI, so the GraalVM --no-fallback image should need no new reachability metadata. plugin-logging applies the Compose compiler with Compose runtime as compileOnly, so nothing Compose reaches this module's runtime classpath either.
Not verified
Gradle is not in my allowed tools here, so :boss-app-browser:test, ktlintCheck, detekt and nativeCompile are reasoned about rather than executed. The U+017F claim in item 2 rests on the documented Unicode simple uppercase mapping (U+017F → U+0053) and String.regionMatches(ignoreCase = true) semantics - I could not run a JVM snippet in this sandbox to demonstrate it, so confirm it locally before acting on it.
• branch fix/browser-service-scheme-gate-and-url-redaction
|
Thank you for this - the scheme allowlist with the tab/newline bypass analysis, the credential redaction in logs, and the reload While this was in review, #931 landed on dev covering the same #911 ground: the scheme gate (with the WHATWG tab/newline stripping handled the same way you did), logging that never echoes a refused URL, One piece of your PR is not yet on dev: the authority checks - refusing empty/malformed authorities and credentials in the authority, matching what the host deep-link gate does. I filed a follow-up issue for that delta and pointed at your |
…Navigate (#1597) BrowserServiceImpl.navigateRefusal gated only the scheme, so a Navigate over IPC could still carry an authority composeApp's UrlOpenValidation refuses for URLs the OS hands BOSS - https://apple.com@evil.example among them. Navigate now applies the same authority rule after the scheme check: // must follow the scheme; the authority must be non-empty, free of spaces, backslashes, quotes, angle brackets and control characters, and free of @; and it must hold a non-empty host with an optional valid port (bracketed IPv6 aware, ASCII digits only). It judges the URL as an engine reads it, with tabs and newlines stripped, as the scheme check already did, so a tab cannot hide the @. Refusals name the rule, never the URL. Adapted from the authorityError approach in #916 by @KhushiTrivediii. The "response contract is not redacted" test now carries its secret in the query and fragment: userinfo can no longer reach the contract. Fixes #1591
Fixes #911
Changes
BrowserServiceImpl.navigate(http/https allowlist matching the host'sUrlOpenValidation, with authority checks that also block the control-character and embedded-@bypasses)LogSanitizer.redactUrlUserInfoBrowserServiceImplScope history (review 2026-09-22 sweep, codeq)
This branch originally bundled four commits. The review deduplicated the rest, keeping only the #911 work:
fix(security): close the search_path on the passkey SECURITY DEFINER set(fix(security): close the search_path on the passkey SECURITY DEFINER set #773) — already merged to dev; provenance verified, no re-landing needed.fix(security): redact URI credentials, curl user flags, AWS keys, and PEM keys in McpArgumentSanitizer(Security: a credential inside a URI authority reaches the approval dialog and the ledger unmasked #886) — byte-identical (patch-id) to the head of @KhushiTrivediii's other PR fix(security): redact URI credentials, curl user flags, AWS keys, and PEM keys in McpArgumentSanitizer (#886) #898, which already carries this change; credited there.fix(security): implement atomic save and canonical path validation in EditorServiceImpl(Security: the out-of-process EditorService saveFile tears user files on crash and its path gate misses Windows system paths and symlink escapes #885) — superseded by PR fix(editor): atomic saveFile + stricter path gate (fixes #885) #1404 (@choksi2212), which implements the same fix with more refined path validation; fix(editor): atomic saveFile + stricter path gate (fixes #885) #1404 is the surviving implementation.The remaining commit is the #911 gRPC browser-service fix, rebased onto dev. Review follow-ups (allowlist instead of denylist, malformed-authority refusal, reload title retention) are in the subsequent commits.