Handoff: W3Wallet capability-enforcement hardening (url:// origin spoofing) + JVM-demo false-success fix
Thread start date: 2026-06-30 (this is the doc's dated event). State last re-verified: 2026-07-05.
Re-verify everything before acting — gh pr view <n> --json state,mergeStateStatus,statusCheckRollup and gh issue view <n> are authoritative, never this doc or memory. PRs merge, CI runs finish, and infra gets fixed on their own.
Mission summary
The thread began as a question about whether NetLab supports the "browser + W3Wallet extension + daemon behind a NAT ↔ public web-host, driven by Playwright" e2e (it does; already built as W3WalletTests/browser-nat-e2e, both JS and JVM variants). It then became: assess whether the W3Wallet e2e/integration tests are comprehensive, find real bugs, and fix them TDD-style (failing test first, then fix).
That produced one merged security fix and a set of follow-ups. The user then directed: "ensure [the fix PR] gets merged and then proceed with all the follow-ups." All follow-ups are now either done, merged, tracked as issues (by user direction / infeasibility), or code-complete-but-CI-blocked. The only thing still moving is FU3's PR, which is blocked by a CI infrastructure regression that is not a code problem (details below).
The core bug that started it (DONE — merged)
W3WalletDaemon url:// trusted-extension-origin escalation. The daemon keys all capability access on the request origin. In WalletRpcHandler, a trusted browser-extension origin (chrome-extension:// / moz-extension://) is granted cross-domain admin (list_all_capabilities; read/update/delete on any domain incl. disabled caps). That trust is authenticated only on the local WebSocket transport (per-session client secret + getOrigin() origin-pinning). The url:// P2P + relay transports passed metadata["origin"] / the X-Origin header verbatim into the same gate. Since the real extension never uses url:// (only localhost WS), any remote peer that reaches url://w3wallet.daemon.<peerId>/ could impersonate the extension and seize full cross-domain control of the wallet.
- Fix + deterministic test (runs in CI): W3WalletDaemon PR #114 — MERGED 2026-06-30 (squash
34edb231cb56ecb7c2d7c120245b320ee25cdfe2). Adds RemoteOriginGuard.kt (isTrustedExtensionOrigin shared by the WS gate and the url:// guard; remoteTrustedOriginRejection), rejects self-asserted trusted-scheme origins at both WalletServiceHandler.handleRequest and handleRelayedRequest, and adds UrlProtocolOriginSpoofingTest (drives the url:// WalletServiceHandler directly against a real daemon + SQLite — no libp2p, deterministic, red→green proven; 4 escalation cases + 2 https no-regression controls). Corrected the existing UrlProtocolServiceTest case that had codified the hole as correct. Full suite 340 tests, 0 failures.
- Superseded the pre-existing weaker PR #110 (based on a merged branch, missed the relay path, tests
skipInCi()) — now CLOSED.
Follow-ups (FU1–FU4) — current status
| FU | What | Status |
|---|---|---|
| FU1 | Deeper url:// owner-shortcut bypass: a remote caller asserting origin=https://<domain> still gets the owner shortcut (skips the permission check) → can invoke/CRUD that domain's caps with no grant. Reachable via the guessable w3wallet-daemon / w3wallet.daemon.temp aliases. More severe than #114. | ⏸️ Deferred by user ("skip for now, note it"). Tracked with full root-cause + reproduction + fix options: W3WalletDaemon issue #115. Reproduced concretely (a remote caller deletes bank.example's cap by asserting origin=https://bank.example). No band-aid-free surgical fix exists — the legitimate cross-NAT demo flow relies on the owner shortcut, so securing it needs a design decision. |
| FU2 | KompileRemoteBuildServiceServer / KompileRemoteBuildWui (named KompileBuildCiServiceServer / KompileBuildCiWui at the time) advertised "verifies W3Wallet capabilities" but enforce nothing. | ✅ DONE. The drift was metadata-only — the READMEs/code are honest ("v1 does NOT verify… planned for v1.1/v2"); only the two GitHub repo descriptions over-claimed. Both descriptions corrected via gh repo edit to state enforcement is planned/not-yet-enforced. No code change warranted. |
| FU3 | W3WalletJvmServerSideCoinCollectorDemo false success: /api/{discard,change-value,change-color} ignored the daemon's updateCapability/deleteCapability booleans and the empty-match case, always redirecting a success message even when nothing happened or the daemon denied it. | 🟡 Code complete + verified; CI-blocked (infra). PR #93 (branch fix/surface-daemon-denials-not-false-success). See below. |
| FU4 | browser-nat-e2e asserts only coin-count; no negative cross-NAT enforcement assertion. | ✅ Tracked (infeasible to implement safely as e2e code from here — the Node/Playwright runner has no url:// client and can't run in-sandbox). Concrete design + note that the bug class is already guarded by #114's UrlProtocolOriginSpoofingTest: W3WalletTests issue #26. |
FU3 detail — PR #93 (the one still in flight)
Branch: fix/surface-daemon-denials-not-false-success (pushed; PR open). State at write time: OPEN, mergeStateStatus=BLOCKED.
- Fix: each handler now redirects with a descriptive
?error=… when no capability matches the coinId or when the daemon rejects an update/delete; reports success only when the op genuinely succeeded. (src/main/kotlin/w3wallet/demo/WalletDemoServer.kt — added errorRedirect, fixed all three handlers.)
- Testability seam:
WalletDemoServer/ApiServlet/IndexServlet gained an injectable WalletConnectionProvider (defaults to the real openWalletConnection — production unchanged). New WalletDemoServerDenialTest drives the real Jetty handlers over HTTP against a fake W3WalletSpenderApi (a real in-process interface impl, not a mock — a real daemon over libp2p can't be stood up in the sandbox, so a fake is the sanctioned fallback). Covers empty-match and daemon-denied (boolean=false) paths — the latter is coverage the real same-domain flow can't even produce. Verified red→green locally (reverting the discard guards flips the two discard-failure assertions to red; restoring passes 5/5). ./gradlew build -x test (what build.yml runs) is green.
- CI on #93:
Build with Gradle ✅ pass, bld-build-fat-jars ✅ pass, bld-all-tests ❌ fail — an infra regression, not this change (see next section).
Why bld-all-tests is red (root-caused; NOT a code issue)
All 19 browser-e2e specs die identically at npm install, before any test runs:
npm error command git … ls-remote ssh://git@github.com/CodexCoder21Organization/W3WalletTestHarness.git
npm error ssh -oStrictHostKeyChecking=accept-new: 1: ssh: not found
Mechanism, with everything ruled out:
- The e2e image (
ghcr.io/codexcoder21organization/w3wallet-browser-e2e:latest) has no ssh binary — confirmed in ci/Dockerfile (FROM eclipse-temurin:21-jdk-jammy, no openssh-client); built 2026-06-14, unchanged since.
bld-all-tests fetches the internal/private W3WalletTestHarness npm dep (package.json: "w3wallet-test-harness": "github:CodexCoder21Organization/W3WalletTestHarness").
- With no ssh in the image, this only ever worked over authenticated HTTPS — the workflow's
git config … url."https://x-access-token:${SUBMODULE_TOKEN}@github.com/".insteadOf "https://github.com/" rewrite.
- It was green on 2026-06-19 → HTTPS+token worked then. It's red now with npm reaching the
ssh:// candidate → the HTTPS+token path stopped working; ssh: not found is a symptom masking the real failure.
- The one thing that changed:
SUBMODULE_TOKEN can no longer read the internal W3WalletTestHarness over HTTPS — expired/rotated, or the harness's access changed (it's internal; only in-window activity was a push on 2026-06-21, two days after the last green run). Provably constant: my PR (2 Kotlin files, nothing npm), the repo code (one commit since green: #92, a relay change green on 6/19), the image (6/14, no ssh), the lockfile (unchanged).
This blocks every PR to that repo (bld-all-tests is a required check; ruleset requires kotlin.build (remote) + bld-all-tests, merge queue SQUASH). The user's CLAUDE.md forbids bypassing branch protection on infra failures.
Task list — next steps, priority order
1. Unblock and merge FU3 (PR #93)
- Primary fix is admin-side: refresh/rotate
SUBMODULE_TOKEN (repo/org secret) so it can read the internal W3WalletTestHarness again. Cannot be done from an agent session — no secret access. Confirm with the user/an admin. A "pin to git+https" change alone will not fix it if the token is dead.
- Optional hardening PR (separate, offered but not yet opened; only goes green once the token is valid): (a) add
git config … insteadOf "ssh://git@github.com/" (and git@github.com:) to bld-all-tests.yml so npm's ssh candidate is rewritten to token'd HTTPS; (b) add openssh-client to ci/Dockerfile. This removes the npm-picks-ssh fragility going forward.
- Once
bld-all-tests is green: #93 merge is not yet user-approved (the user approved merging #114 specifically, and this handoff PR — not #93). Get explicit approval, then enqueue via the merge queue (the repo has merge-queue; gh pr merge --auto may be disabled — enqueue via GraphQL enqueuePullRequest if so). Do not merge without approval.
- Re-run the deterministic tests locally to re-confirm before merge: publish the daemon to mavenLocal as the version the demo pins, then
./gradlew test --tests "w3wallet.demo.WalletDemoServerDenialTest" (see "Repro recipes").
2. FU1 — the deep url:// owner-shortcut (issue #115)
Deferred pending a design decision. Options in the issue: A extension-issued short-lived per-domain grant (most correct; spans daemon + extension); B restrict the discovery aliases to health/ping only (daemon-only, partial); C full token-capability model (aspirational). This is the highest-severity remaining security gap — a remote peer can invoke signing/proxy capabilities (exercise real secrets) for any domain via a guessable alias. Bring options to the user; do not ship a band-aid that breaks the legitimate cross-NAT demo.
3. FU4 — e2e negative assertion (issue #26)
Add a cross-NAT capability-rejection probe to browser-nat-e2e (e.g. a spoofed chrome-extension:// origin over the relay must be PERMISSION_DENIED). Needs a url:// client in the Node runner (least-invasive: a tiny JVM probe using the daemon jar's bundled UrlResolver client classes) and must run against a main-built daemon (which now has #114). Cannot be verified from the sandbox (needs managed droplets); CI verifies.
4. The CI infra fix itself
Refreshing SUBMODULE_TOKEN (task 1) also unblocks the whole repo's e2e CI. Worth confirming whether the harness went private on ~6/21 (which would mean the token needs internal-repo read scope) vs. a plain token expiry — the fix (valid token with read access) is the same either way.
Repos, branches, and where the changes live (all pushed — nothing uncommitted)
| Repo | Branch / ref | What |
|---|---|---|
| W3WalletDaemon | main (commit 34edb23) | #114 fix — MERGED |
| W3WalletJvmServerSideCoinCollectorDemo | fix/surface-daemon-denials-not-false-success | FU3 fix + test — pushed, in PR #93 |
| KompileRemoteBuildServiceServer / KompileRemoteBuildWui (formerly KompileBuildCiServiceServer / KompileBuildCiWui) | — | FU2: GitHub repo descriptions corrected (metadata, no repo files) |
| W3WalletTests | — | FU4: issue #26 only |
All working trees were clean at handoff (verified git status) — every change is either merged or on a pushed branch behind a PR. There are no uncommitted/unsaved local changes to upload.
Gotchas / environment notes for whoever picks this up
- Sandbox can't run libp2p loopback or browser-e2e. The demo's Gradle
E2EIntegrationTest (and the whole typed proxy family) fail here because the demo↔daemon url:// connection can't establish in a Docker-less sandbox — not a code fault. That's why FU3's verifiable test uses the injectable-provider + fake seam instead. Browser-e2e (tests/*.kts Playwright) is Docker-image-only.
- Demo E2E dependency:
W3WalletJvmServerSideCoinCollectorDemo pins com.w3wallet:W3WalletDaemon:1.0.1-SNAPSHOT (a mavenLocal snapshot, per build.yml's comment). The daemon's own version is 1.0.4; to run the demo's Gradle E2E you must temporarily publish the daemon as 1.0.1-SNAPSHOT (sed the two version = "1.0.4" lines → 1.0.1-SNAPSHOT, ./gradlew publishToMavenLocal -x test, then revert the version edit).
- This repo's CI runs kompile
tests/*.kts Playwright drivers + ./gradlew build -x test — Gradle unit/integration tests are -x test excluded from CI (they may not even compile-check in CI). FU3's WalletDemoServerDenialTest is a local verifier; it is compiled+run by ./gradlew test but not by CI.
- Merge queues everywhere. W3WalletDaemon required check =
kotlin.build (remote) (runs at merge-queue time, not always on the PR). The demo repo requires kotlin.build (remote) + bld-all-tests.
- git auth in-session:
gh auth setup-git was run; git push/fetch over HTTPS work via the gh credential helper.
Repro recipes (quick)
- #114 daemon test (deterministic, runs anywhere): in
W3WalletDaemon, CI=true ./gradlew test --tests "w3wallet.daemon.server.UrlProtocolOriginSpoofingTest".
- FU3 demo test: in
W3WalletJvmServerSideCoinCollectorDemo, first publish the daemon as 1.0.1-SNAPSHOT to mavenLocal (see gotcha), then ./gradlew test --tests "w3wallet.demo.WalletDemoServerDenialTest" (5 tests). To see red: revert the if (coinCaps.isEmpty()) + if (!walletApi.deleteCapability(...)) guards in handleDiscard and re-run — the two discard-failure assertions flip red.
- FU1 reproduction: in
W3WalletDaemon, a WalletServiceHandler.handleRequest("delete_capability", {capabilityId}, {"origin":"https://<domain>"}) against a real DB deletes a cap owned by <domain> with no grant (see issue #115 for the full test snippet).