Review of seed-hypermedia/seed#1138 at head b5013b044, read entirely through git refs against origin/main (52e25b3) on 2026-10-19. Scope, in the order asked: the Go daemon changes under backend/, the desktop main process (flag gate, WebContentsView, app secret, preload, IPC, CSP), and app-wide changes that every user gets regardless of the flag (Electron 44, fuses, entitlements, update manifests). Disclosure context: #1187.
Verdict. The daemon gate is correct and the open default is preserved for servers and the CLI, with one small CORS deviation. Nothing here is a blocker. Three items should be fixed before merge, the first because the PR claims to close #1187 and it closes only the HTTP half.
Severity | Count |
|---|---|
Blocker | 0 |
Should fix before merge | 3 |
Follow-up | 10 |
Nit | 5 |
Should fix before merge
1. The LAN half of #1187 stays open: plain gRPC still binds every interface. backend/daemon/daemon.go:408 listens on ":"+port for the plain gRPC server with only recovery interceptors and no authentication. -http.loopback moves only the HTTP listener (backend/daemon/http.go:139-150). The desktop passes -grpc.port=56002, so a desktop daemon keeps an unauthenticated gRPC endpoint reachable from the LAN (every RPC: keys, signing, publishing, wallets). The new docs admit it ("Plain gRPC still binds all interfaces and needs a firewall", hypermedia/apps/daemon.md:22), but #1187 and the audit-log row (.agents/security/audit-log.md:46) say the LAN exposure is fixed. Failure scenario: anyone on the same Wi-Fi opens a gRPC connection to port 56002 and signs as the user, exactly as described in the disclosure. Fix: in loopback mode bind the gRPC listener to 127.0.0.1 too (the desktop never needs LAN gRPC), or correct #1187 and the audit log to say HTTP only.
2. Users who never opt in still pay for the browser: filter engines, disk writes, daily third-party fetches and app-wide secure DNS. createAppWindow calls setupWebBrowser unconditionally (frontend/apps/desktop/src/app-windows.ts:532), which calls setupBrowserSessionPolicy() for both partitions before any flag check (app-web-browser.ts:91-92). On the first window that: deserializes the bundled 5.6 MB adblock engine (browser-content-blocker.ts:38, the module-level singleton does it at import time), writes browser/content-blocker.json and browser/blocklist.json under userData, runs browserBlocklist.refresh() immediately so first launch fetches https://openphish.com/feed.txt and every day after (browser-blocklist.ts:11,112; the blocklist cache starts at checkedAt: 0), fetches easylist.to after 24 hours (browser-content-blocker.ts:11,172), and sets app.configureHostResolver({secureDnsMode: 'automatic'}) for the whole app (browser-session-policy.ts:218). Failure scenario: a user who leaves the toggle off still contacts openphish.com and easylist.to daily from the Seed app, which contradicts the PR's "merging this PR changes nothing for users who do not opt in". Fix: call setupBrowserSessionPolicy lazily from web-browser-create (and from the web-import mutation, which already calls it) rather than at window creation, and keep the DNS change inside the browser partition's lifecycle.
3. The open default is not byte-for-byte: grpc-web CORS for unlisted origins changed. backend/daemon/http.go:135 now passes grpcweb.WithOriginFunc(allowedOrigin) in every mode; before it was func(_ string) bool { return true }. In the open default, rs/cors (v1.7.0, handleActualRequest) therefore no longer sets Access-Control-Allow-Origin: <origin> and Access-Control-Allow-Credentials: true on grpc-web responses to origins outside localhost:17654-17664; the response keeps the outer * from openCORSMiddleware and gains Vary: Origin. Any browser grpc-web client that sends credentials cross-origin would break. I found none in this repo (the web app's credentialed fetches go to its own /hm/api/* routes, frontend/apps/web/app/universal-client.tsx:104-136), so this is about the stated guarantee rather than a known breakage. Fix: keep the permissive origin func in the open branch (if cfg.PublicOnly || !browserAuth), and add an integration assertion for a foreign-origin grpc-web request in open mode (the existing TestDaemonCORS was switched to gated mode, so open-mode preflight is no longer covered).
Follow-up
4. The app secret lives in the renderer main world. preload.ts:80 exposes window.daemonAppSecret, and daemon-http.ts:12 reads it from there. Every script at the app origin can read it, including the three third-party scripts the CSP allows in app-window-policy.ts:65 (Twitter widgets, Instagram embed, Plausible). Those scripts could already call the daemon before this PR, so this is not a regression, but the secret's stated purpose is to make "the app" the only caller. Keeping the secret inside the preload (attach the header from a bridged fetch or transport) would make it unreadable from page scripts.
5. An unsigned manifest still sets the Chromium kill-switch policy. auto-update.ts calls updateChromiumPolicy(updateInfo) right after verifyUpdateManifest, and with UPDATE_SIGNATURE_REQUIRED = false and PROD_UPDATE_PUBLIC_KEY = '' (update-signing.ts:2,10) a prod manifest without a signature is accepted after schema validation only. Whoever can alter prod/latest.json can disable the browser for everyone or set an arbitrary minimum. Low impact (that party could already serve a different download), but the comment in app-chromium-age.ts:25 ("only after the updater has validated the manifest") overstates it. Apply the policy only from signed manifests.
6. Fuses and entitlements are not exercised by a packaged launch in CI. desktop-security-e2e.yml runs unpackaged on ubuntu-latest ("None of them need a packaged app"). The only packaged check is the fuse-wire read in forge's postPackage. EnableEmbeddedAsarIntegrityValidation and OnlyLoadAppFromAsar (scripts/electron-fuses.ts), the removal of com.apple.security.cs.disable-library-validation and of the device entitlements (entitlements.plist), and RunAsNode: false each can stop a notarized mac build or a Windows build from launching or from loading a native module. Smoke-launch a notarized mac build and a Windows build from this branch before the first release that carries it.
7. The browser switches itself off on 2026-11-28. electron-release.json records releasedAt: 2026-09-29 and app-chromium-age.ts:23 sets a 60-day limit. Unless a release with a newer Electron (and an updated electron-release.json) ships first, every build from this branch disables the pane on that date, whatever the toggle says. Worth a calendar entry; the check-electron-support.yml weekly job only checks the major.
8. GET /ipfs/{cid} and .dagjson stay public to every web page. By design (http.go:361-368), but in the desktop the daemon is not public-only, so a page that knows a CID can read any blob the daemon holds, including blobs of private documents, as decoded DAG-JSON, and can make the daemon search the IPFS network for any CID. CIDs are unguessable, so this is a leak only when a CID has leaked. Consider denying .dagjson or private blobs to browser callers in gated mode.
9. /debug/grpcui cannot invoke methods on a desktop daemon anymore. Its page is served from the daemon's own origin, so its POSTs carry Origin: http://127.0.0.1:56001, which appOriginAllowlist does not include; the gate returns 403. A same-origin page cannot be attacker-controlled (see "what is solid"), so allowing Origin == the daemon's own origin is safe, or document -http.app-origins for this.
10. The app's own API server accepts Sec-Fetch-Site: same-site. app-http-server.ts:64 lets any page served from another localhost:* port call /api/*, which proxies to the daemon from the main process (no browser metadata, so the gate waves it through). Pre-existing and outside this diff, but it is the same surface as #1187.
11. open_path stays an unguarded shell.openPath. main.ts:539 while the neighbouring open-external-link handler now checks frame and activation. Pre-existing.
12. The disclosure text no longer matches the design. #1187 says "Loopback bind by default… -http.listen-all for servers"; the branch ships the opposite (open default, -http.loopback opt-in). Update the issue and the audit-log row when merging.
13. The web pane relies on Electron's private -run-dialog event (app-web-browser.ts:331). The code says so; add it to the Electron-upgrade checklist so dialog notices do not silently stop.
14. A window now refuses to open without the local renderer server. app-windows.ts:791 throws where it used to fall back to file://. Correct for the CSP and origin checks, but if ports 17654-17664 are all busy the app has no window at all. A clearer error dialog would help.
Nits
webviewTag: false is explicit only for guests (browser-session-policy.ts:75); the BrowserWindows rely on Electron's default. State it next to contextIsolation in createAppWindow so the static scan and readers see it.
TestHTTPBrowserGate uses the secret "launch-secret", which the real appSecretFromEnv would reject; a 64-hex value reads truer.
appOriginAllowlist hardcodes 17654-17664 in Go while local-server.ts:74 owns the same range; a shared constant or a comment on each side would prevent drift.
Setting UPDATE_SIGNING_PRIVATE_KEY_PROD before PROD_UPDATE_PUBLIC_KEY is populated fails the prod latest.json workflow (sign-release-manifest.ts throws "does not match"). Order the rollout: public key in a release first, then the secret.
-http.browser-auth without SEED_APP_SECRET generates a secret nobody learns; the flag text says so, but the name suggests the opposite of what a server operator would expect. Fine as is, just documenting that I checked.
What is solid
Browser-page detection. browserGateMiddleware (http.go:327-374) treats a request as a browser page when Sec-Fetch-Site is anything but empty, none or same-origin, or when an Origin header is present and not allowlisted. Origin: null is denied, same-site (another localhost port) is denied, omitted headers mean a non-browser local client, which is the intended trust boundary (same-user processes). Preflights from unlisted origins get 403 with no Access-Control-Allow-* headers, so a page cannot even negotiate. A bad bearer token from a browser page gets 403 rather than 401. Path tricks fail: /ipfs/x/../debug/logs contains a slash and is refused before the mux cleans it; /IPFS/, //debug/ and /hm/api/config/.. do not match the public set. The grpc-web wrapper is built without WithWebsockets, so there is no WebSocket path. The daemon's own origin cannot host attacker script: /ipfs/{cid} is served as application/octet-stream (backend/hmnet/filemanager.go:157) and .dagjson as JSON, so the same-origin allowance is not abusable.
Secret handling. Read only from SEED_APP_SECRET, validated as 32-byte hex (http.go:248-269), compared with subtle.ConstantTimeCompare (http.go:340), never written to a response. The desktop logs the daemon's arguments but not its environment (daemon.ts:122-138), the secret is generated once per launch with randomBytes(32) and kept in memory, and the renderer's loggingInterceptor logs messages, not headers. initWindow returns the secret only to the window's main frame when its origin equals the app's local server or Vite origin (app-windows.ts:572-581); the preload runs only in the main frame (nodeIntegrationInSubFrames is off), guests have no preload, and the generic IPC bridge is an allowlist (preload-ipc.ts).
Loopback bind. net.JoinHostPort("127.0.0.1", port) with -http.loopback, and TestHTTPListenAddress asserts the listener IP. The desktop reaches the daemon at http://127.0.0.1:56001 (constants.ts:88-89), so there is no localhost-resolves-to-::1 mismatch.
Open default. The open branch keeps the previous chain: openCORSMiddleware is the old function unchanged, bearerAuthMiddleware is the old authContextMiddleware with the same logic, then publicOnlyMiddleware, metrics and bandwidth accounting. Only the grpc-web origin func differs (item 3).
Tests. I exported the branch's Go tree and ran the daemon HTTP tests with Go 1.27.1: TestHTTPBrowserGate (22 cases), TestHTTPCORS, TestHTTPAppSecret, TestHTTPListenAddress, TestHTTPGateRoutes (full router, both modes), TestIPFSGetHandlerRoutesDAGJSONSuffix, TestMakeBlobDAGJSONHandler_PublicOnly, TestDaemonCORS: all pass. The cases cover the questions above (null origin, same-site, origin overriding metadata, bad secret, bad bearer, public paths, /ipfs/x/extra not public). The Electron daemon-auth e2e drives the real daemon with a public page and asserts 403 with no CORS headers on an RPC and its preflight, and 200 on a public blob.
Desktop gate. The only WebContentsView for websites is created in createGuest, reachable only through web-browser-create, which requires the sender to be the host's main frame and isWebBrowserEnabled() to be true (app-web-browser.ts:163-178); navigate, tracker toggles, bounds visibility, agent access and agent execution re-check the flag, so turning it off hides and freezes an existing guest. Guest preferences are a fixed allowlist that deletes anything the caller passed (sandbox, context isolation, no preload, webviewTag: false, safe dialogs). The partition denies every permission except a gesture-gated, prompted clipboard write, refuses client certificates and certificate errors, strips third-party cookies both ways, sends Sec-GPC, cancels sub-requests from public pages into private hosts, and gates downloads behind a consumed gesture plus a second confirmation for executable types. Agent commands are schema-validated (app-browser-agent.ts:12-35), need a per-origin grant at the right level, run with gestures suppressed, and every redirect hop is re-checked against the allowlist and DNS (browser-network-policy.ts:165-199). App windows get will-navigate, will-frame-navigate, will-redirect and window-open guards plus a CSP whose frame-src is limited to the app, daemon and embed domains.
Updates. The live prod/latest.json and dev/latest.json (fetched 2026-10-19) parse under the new updateManifestSchema, including the empty app_image and flatpak objects, so the updater keeps working after merge. The download hash is bound to the specific request URL, and a mismatched asset is deleted.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime