Repository navigation
fix(security): confine DownloadDataProvider open and reveal to tracked downloads - #1407
Antriksh1984 wants to merge 1 commit into
Conversation
|
Thanks @Antriksh1984 for the focused provider-side hardening and the preserved contributor/test coverage. I reviewed the net two-file patch against the current I’m not marking this ready for merge yet because the provider-side guard reads
Please fix the guard to query the manager’s live map (for example, add a narrowly scoped live lookup/predicate by canonical destination path and use it from both methods), while retaining the blank-path rejection and canonical comparison. Please add focused regressions that (1) add a download and call the provider before the sampled flow emits, and (2) remove/clear a tracked download and assert both operations are refused immediately; keep the existing tracked, CI is green and I found no separate Claude review attached to #1407; the prior Claude feedback on the overlapping #550 was resolved there and specifically called out this sampled-snapshot limitation. Once the live-state fix and focused tests are in, please rerun the provider tests plus |
bdcf620 to
bc9aa3b
Compare
…d downloads, read live DownloadDataProvider.openFile/revealInFolder ran the OS default application on any path, from every installed plugin: no confinement to what the provider actually tracks. The kernel-facing DownloadServiceBridge already applies this rule; it now lives in the in-process provider too, so both callers agree. The confinement check reads DownloadManager.allDownloads(), a new live accessor, rather than the provider's own downloads flow. That flow mirrors DownloadManager.downloads, which is sampled every 150ms for the UI: reading it for a security decision could refuse a download that was just added, and keep authorizing one that was just removed or cleared, for up to a sampling window either way.
e67e15a to
2c6f494
Compare
|
Fixed and force-pushed a clean rebuild on current
Added three tests: a download is trusted the instant
|
08da448 to
4077231
Compare
|
@claude please preliminary triage-review #1407 at exact head 2c6f494. Focus on the authors own commits, not the polluted dev compare. Compare current dev for supersession, flag correctness/test/security blockers, and suggest viable rebase, specific fixes, or closure recommendation. This is triage, not merge approval; full gate follows a clean rebase. |
|
Claude finished @fluck-boss's task in 4m 8s —— View job Triage review of #1407 at
|
| branch base | current dev |
|---|---|
private val _downloadsMap = MutableStateFlow<Map<String, DownloadItem>>(...) + Mutex |
private val downloadsById = mutableMapOf<...>() + private val stateLock = Any() + mutationTick |
fun getDownload(id) = _downloadsMap.value[id] |
fun getDownload(id) = synchronized(stateLock) { downloadsById[id] } |
So fun allDownloads(): List<DownloadItem> = _downloadsMap.value.values.toList() is an unresolved reference after rebase. The rebased form must take the lock, and that is not cosmetic — downloadsById is a plain mutableMapOf, so an unsynchronized values.toList() races every mutator and can throw ConcurrentModificationException or read a torn map. liveDownloads() is not inside a runCatching, so that throw would propagate straight out of openFile into the calling plugin. Correct rebase:
fun allDownloads(): List<DownloadItem> = synchronized(stateLock) { downloadsById.values.toList() }2. The path that is validated is not the path that is launched.
openFile canonicalizes path to compare, then hands the caller's raw string to opener(path). The bridge deliberately does the opposite — trackedDownloadPathOrRefuse returns checkNotNull(requestedCanonical).path. Two consequences:
- A check-then-launch gap: an intermediate directory component of the raw path can be re-pointed (symlink swap) between the canonicalization and the
exec, so the process that launches is not provably the file that was validated. Local-write-access only, so low severity — but the fix costs nothing. - It keeps caller-controlled text on a
cmd /c startcommand line.FileSystemUtils.openFilebuildsarrayOf("cmd", "/c", "start", "", file.absolutePath)(FileSystemUtils.kt:50), andRuntime.execre-joins those into one command string on Windows. The canonical-equality guard does refuse a path carrying&-style payloads today, so this is defence-in-depth rather than an open hole — but launching the tracked item's owndestinationPathremoves the question entirely.
Strongest form: have the predicate return the matched tracked item (or its canonical file) and launch that, not the argument. Also note test the launcher gets the path as given by the tracked entry does not currently test what its name claims — it passes the caller's x/../a b (1).pdf string and then compares canonically, so it passes either way.
3. Two of the new race tests pass silently when their own premise fails.
awaitTracked (and providerTracking) poll for 5s and then fall through with no assertion:
while (provider.downloads.value.none { it.destinationPath == path } && System.currentTimeMillis() < deadline) {
Thread.sleep(20)
}The KDoc on a download is refused the instant it stops being tracked says the wait exists "so the removal below is a real race against a snapshot that still says tracked - not a case where the stale snapshot happened to be empty either way". If the flow never catches up (slow CI, a future change to the sampling), the wait expires, the premise evaporates, and the test goes green having tested nothing. Per this repo's own Test Veracity Rules, that needs an explicit assertTrue(...) / fail() on the deadline in both helpers.
Non-blocking, worth folding into the rebase
- The bridge and the provider still disagree, in the refusing direction.
DownloadServiceBridge.trackedDownloadPathOrRefusekeeps guarding onprovider.downloads.value(sampled). Post-PR the provider is the authority and the bridge can only be stricter, so a kernel caller can still be refused a just-added download. That is fail-closed and safe, but the PR body's "so the two cannot disagree" isn't quite true. The bridge's class KDoc also still says the rule lives there — it should now point at the provider as the authority and describe itself as the outer, snapshot-based check. (Sharing one predicate is awkward: the bridge holds theDownloadDataProviderinterface, which has no live accessor.) - The
List<DownloadItemData>parameter re-exposes the exact seam that caused review round 1. Aninternalpredicate taking a list invites the next caller to pass a stale snapshot again. A memberisTrackedLive(path)(or a predicate taking the manager) makes the stale variant unrepresentable. liveDownloadsallocates a full DTO per download per call just to readdestinationPath, and that mapping is the only reason the twotoData()converters had to be lifted from class members to file-level privates — roughly 45 of the 70 added lines and most of the rebase conflict surface. TakingList<DownloadItem>(orList<String>of destinations) removes the mapping, the move, and the churn.- Test placement:
ai.rever.boss.downloadopenvs the siblingDownloadDataProviderImplTestinai.rever.boss.components.plugin.panels.left_top. Colocate. - Provider instances are never disposed in the new tests — each leaks an eagerly-shared
stateIncollector plus aDownloadManagerscope. Harmless, tidy-up only. - The "nothing legitimate changes" claim is not verifiable in this repo. After grepping, the only in-repo caller of
DownloadDataProviderImpl.openFile/revealInFolderisDownloadServiceBridge; the Downloads panel lives in the browser plugin repo. Someone should check that plugin for an "open/reveal the downloads folder" action that passes a directory — that is now silently refused with only alogger.warn. If such an action exists, this is a behavioural break in an API 33 plugin repos consume, and it needs either an explicit allowance or a release note. - Repo convention: an invariant of this shape ("both the bridge and the in-process provider confine to the live tracked set, and here is why it reads live") normally gets a short
AGENTS.mdparagraph. Worth adding while the reasoning is fresh.
What the author got right
allDownloads()as a live accessor mirroring the existinggetDownload(id)bypass is the correct answer to @kshivang's review, and the two staleness directions (refuse-a-fresh-download, authorize-a-removed-one) are both covered by tests that wait for the throttled flow to genuinely show "tracked" before racing it. That is the harder version of the test and the right one.- The blank-destination fixture was fixed as asked (
item("blank", "")is now actually tracked alongside a real one). - Canonical comparison on both sides, blank rejected before canonicalization,
runCatchingaround bothcanonicalFilecalls,DownloadItemshape unchanged ondevso the fixtures still compile, and the added constructor params are defaulted so the existingDownloadDataProviderImplTestis untouched.
Suggested rebase
The PR content is three files and does not conflict semantically with anything on dev — only DownloadManager.kt conflicts textually. Cleanest path:
git fetch origin dev
git checkout -b fix/download-provider-open-confinement-v2 origin/dev
git cherry-pick 2c6f494 # resolve DownloadManager.kt per blocker 1
# then fold in blockers 2 and 3
./gradlew :composeApp:ktlintFormat
./gradlew :composeApp:desktopTest --tests '*DownloadOpenConfinementTest' --tests '*DownloadDataProviderImplTest' --tests '*DownloadServiceBridgeTest'
./gradlew ktlintCheck detektInclude DownloadServiceBridgeTest in that run — it uses a stub provider so it should be unaffected, but it is the other half of this rule and is cheap to confirm.
I did not build or run tests here: this is a shallow checkout on the pre-rewrite base, so a compile would be testing code that cannot merge anyway. Blocker 1 is from reading origin/dev's DownloadManager.kt directly, not from a build.
Triage only — full gate after a clean rebase, as requested.
· branch fix/download-provider-open-confinement
|
Preliminary triage on head 2c6f494: keep this provider confinement fix; current dev still allows the in-process download provider to open/reveal arbitrary paths. Re-cut the author commit onto current dev, where DownloadManager has a stateLock and downloadsById map rather than _downloadsMap; the live accessor must copy under that lock. Have the predicate return the matched tracked destination and launch that path, not the caller’s raw string. Make the 5-second test waits fail on timeout instead of passing without the race premise, and assert the exact launched tracked path. Claude’s current-head review above details the conflicts and tests. This is triage, not merge approval. Thanks @AntrikshSingh850. |
072a2bd to
2ae2565
Compare
The gap
DownloadDataProvider.openFile(path)handspathtoFileSystemUtils.openFile, which asks the OS to open it with its default application. For an executable, "open" and a double-click are the same action, and nothing on this path asks for consent.DownloadServiceBridgealready documents exactly this hazard and closes it for the kernel-facing RPCs by confining bothopenFileandrevealInFolderto a path the provider is currently tracking as a download.The provider itself was never confined.
PluginContext.downloadDataProvideris available to every installed plugin, andDownloadDataProviderImpl.openFile/revealInFolderaccepted any string. So the confinement existed for a caller that had to be authenticated and did not exist for one that is already inside the process. A plugin (or a compromised one) could callopenFile("C:\\path\\to\\payload.exe")and launch it, with no download involved.Fix
The rule moves into
DownloadDataProviderImpl, where the bridge and the in-process API meet it, so the two cannot disagree:openFileandrevealInFolderact only on a path that is a tracked download'sdestinationPath, compared canonically (a..segment or a link cannot name a tracked file from outside it).Refused openFile: the path is not a tracked download).The Downloads panel only ever passes
destinationPathvalues it read from this same list, so nothing legitimate changes. The provider gets two constructor seams (opener,revealer, defaulting to the realFileSystemUtilscalls) so the rule is testable without launching anything; the public no-argument constructor is unchanged. The comparison lives in an internal top-levelisTrackedDownloadrather than as members, to stay under the class's function ceiling.Tests
DownloadOpenConfinementTest(5). Run against the provider with the seams added and no confinement (the previous behavior), 3 failed: an untracked path was opened and revealed, a..path that resolves to an untracked file was opened, and blank paths were passed through. The two that pin the unchanged path (a tracked download opens and reveals; a tracked name with spaces and parentheses reaches the launcher) passed either way. After the change all 5 pass, and the existingDownloadDataProviderImplTest(11) still passes.ktlintCheckanddetektare clean.One unrelated test in this module,
DesktopFileScannerTest, fails on this Windows machine (A required privilege is not held by the clientcreating a symlink), and it does so without this change.Not covered
ExecutableDownloadConsent) is where that decision is made, and it has not changed.