fix(knowledge): require a direct local request for the native folder picker - #9358
fix(knowledge): require a direct local request for the native folder picker#9358leonlaiyc wants to merge 1 commit into
Conversation
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of The change is verified against the base: Design-Verdict: PASS Closes a counted, real remote-reaches-host-dialog gap with the exact fail-closed predicate its sibling already uses — right layer, minimal surface, tested red-before. [DESIGN-REVIEWED] cdc2a79 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All checks complete. The fix itself is verified against the base: the weak gate exists at First-Principles-Verdict: CONCERNS The class this PR names stays open by one member: What this change shipsInventory (3 items)Intent: stop a proxied/tunneled remote token-holder from popping a native folder dialog on the gateway operator's screen — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] cdc2a79 |
…picker `_folder_picker_available` gates the host-side macOS folder dialog on `sys.platform == "darwin" and request.app["local_only"]`. `local_only` describes how the GATEWAY was started, not where the request came from -- and the gateway binds loopback precisely because remote access is delivered by a same-host tunnel or reverse proxy. A remote user's request therefore arrives from a loopback peer with `local_only` still True, passes the gate, and `POST /api/knowledge/pick-folder` opens a native modal on the gateway operator's screen, where it blocks for up to `_FOLDER_DIALOG_TIMEOUT` (180s) driven by someone else entirely. Whatever the operator then picks is returned to the remote caller as an absolute host path. The fix is the repository's own per-request predicate, `is_direct_local_request` (loopback peer AND no forwarding headers), added as a third conjunct. This is the unfixed sibling the review of kirodotdev#9233 counted when that PR applied the same predicate to the project picker in `handlers/files.py`; the comment here points back at it so the pair stays visible. `local_only` is kept rather than replaced: it is still the right switch for "this deployment offers host-side dialogs at all", and dropping it would widen the gate on a gateway deliberately started without it. Behaviour is unchanged for a genuine local browser, which is loopback with no forwarding headers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
74f4b3c to
cdc2a79
Compare
Problem / Motivation
POST /api/knowledge/pick-folderopens a native macOS folder dialog on thegateway host. Its gate is:
local_onlydescribes how the gateway was started, not where this requestcame from — and those come apart in exactly the deployment this project ships.
dashboard/origin.py's own helper states why:So on a
local_onlygateway published throughtailscale serve, an AEA tunnel,nginx or Caddy, a remote user's request satisfies this gate. The consequences
are all on the operator's machine, not the requester's:
by someone else;
_FOLDER_DIALOG_TIMEOUT(180 s), and the handlerholds an executor thread for the duration;
returned to the remote caller as an absolute host path, which is a small
filesystem-layout disclosure even though
add_sourcere-validates the pathbefore using it.
The same gate also drives
GET /api/knowledge/config'sfolder_pickerflag, soa remote dashboard currently advertises the button as available.
Why it matters
This is the unfixed sibling the review of #9233 counted. That PR added
exactly this predicate to the project picker in
handlers/files.py, and itsreview recorded:
Two host-side native dialogs, one hardened and one not, is the state that helper
exists to prevent. Fixing only the first left the same capability reachable one
route over.
The scope is deliberately not overstated: reaching the endpoint still requires a
valid dashboard token. This closes the gap between "a token holder on the far
end of the tunnel" and "someone sitting at the machine", which is the same
boundary the
is_direct_local_requestdocstring says it exists to hold — it isnot, and does not claim to be, protection against a host-level actor.
What changed (motivation → approach → change)
Root cause: a per-deployment flag was standing in for a per-request property.
_folder_picker_availablegainsis_direct_local_request(request)as athird conjunct — loopback peer and no forwarding headers. One import, one
early return, one call.
local_onlyis kept, not replaced. It is still the right switch for"this deployment offers host-side dialogs at all", and dropping it would
widen the gate on a gateway deliberately started without it. The check order
also preserves the existing fail-closed behaviour when
local_onlyis unset.touches either next.
Behaviour is unchanged for a genuine local browser, which is loopback with no
forwarding headers — pinned by a test that flips only that one header.
Deliberately not widened. No other
local_onlyreader is touched. Most arecorrect as they stand (they gate deployment capability, not a host-side side
effect), and auditing all of them is a different change from fixing the sibling a
reviewer already counted.
Tests
Extended
TestFolderPickerAvailableintest/test_knowledge_add_source.py.The existing
_fake_requesthelper carried onlyapp, which is no longer enough— the real predicate reads
request.remoteandrequest.headers. Rather thanpatch
is_direct_local_requestout (which would assert the gate against a mockof itself), the fixture now supplies a genuine loopback peer and real headers,
so the production predicate actually runs and the new cases flip it by changing
one field.
Red-before (production file reverted to
origin/main, tests kept):assert True is Falseis the defect stated plainly: the gate reports the hostdialog as available to a proxied remote request.
test_unavailable_when_a_proxy_forwarded_the_request— the reported case: aloopback peer carrying
X-Forwarded-For.test_unavailable_when_the_peer_is_not_loopback— a directly-bound remotepeer, which
local_onlyalone also failed to exclude.test_the_forwarding_header_is_what_flips_it— guard the guard: asserts theallowed and denied requests differ only by that one header, so the denial
cannot be passing because the fixture is malformed.
All four pre-existing tests in the class are unchanged and still pass, including
test_fail_closed_when_local_only_unset, which passes a bareSimpleNamespacewith no peer at all — the check order keeps that reaching
Falsebefore the newpredicate is consulted.
Green-after: 417 passed / 2 skipped across
test_knowledge_add_source.py,test_dashboard_origin.py,test_knowledge.pyand
test_knowledge_budget.py.Gates:
flake8,isort --check-only,mypy --platform linuxandscripts/check_black_formatting.pyall pass. The one black finding intest_knowledge_add_source.pyis a pre-existing baseline entry in a region thisPR does not touch, so the file was not reformatted.
Manual verification
N/A — unit coverage sufficient: the gate is a pure predicate over
request.remoteandrequest.headers, and the tests drive the real predicatewith the exact peer/header combinations a tunnel and a direct browser produce.
Reproducing it by hand would need a macOS host behind a reverse proxy, and would
assert nothing these do not.
Related Issues
None filed. Found by mining merged-PR review threads for counted-but-unfixed
siblings; this one is named explicitly in the review of #9233.
Contention checked immediately before opening:
handlers/knowledge.pyis touchedby 5 open PRs (#2937, #8345, #8985, #9222, #9333), and none of them has a hunk
in the
_folder_picker_available/pick_folderregion — the nearest are atlines ~519–661 and ~919+.
Pattern harvest
Rule candidate:
review-promptPattern: a deployment flag is not a request property.
local_onlyanswers"how was this gateway started", and it was being used to answer "is the person
making this request at the keyboard". Those diverge the moment a tunnel or
reverse proxy is in front — which is not an edge case here, it is the shipped
remote-access design, and it is why
is_direct_local_requestexists. Any gateprotecting a host-side side effect (a native dialog, a local file write, a
desktop notification, a shell) needs the per-request check; the deployment flag
can only decide whether the capability is offered at all.
Second lesson: when a shared predicate is introduced to fix one call site,
the other call sites of the old weaker check are the change's real scope.
#9233's review counted this sibling correctly, and it stayed open because
counting is not fixing. A helper added to close a class leaves the class open
until every member is migrated — which argues for landing the sibling in the
same cycle, or filing it, rather than only naming it in a review thread.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code