diff --git a/CHANGELOG.md b/CHANGELOG.md index 518ce9d..767c097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ Cutting that release is tracked in - Recording and transport regression coverage for every ffmpeg format/quality mapping, executable discovery, capture-failure and stop bounds, capabilities accuracy, and oversized Unix-socket requests. +- Cross-uid Unix-socket rejection and host E2E classification checks for + blocked top-frame navigation and denied page-initiated downloads. +- macOS explicitly converts download-intent links, attachment responses, and + unsupported response types into cancellable `WKDownload` objects so every + denied download is classified without writing page-controlled bytes. - Progressive context pruning: `inspect --context summary|outline|text|actions|full` with `--task` ranking, `--within @rN` scoping, and `--limit` / `--budget` / `--depth` bounds. Every focused response reports `contextStats` and `omitted`. diff --git a/apps/headless/Tests/Fixtures/dashboard.html b/apps/headless/Tests/Fixtures/dashboard.html index 8e3cb0e..a6628ec 100644 --- a/apps/headless/Tests/Fixtures/dashboard.html +++ b/apps/headless/Tests/Fixtures/dashboard.html @@ -30,7 +30,9 @@

Continue setup

Non-web browser URL

Credential-bearing URL

Suspicious installer

+

Download fixture

+ diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 48fb01f..ff5ee8a 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -1095,6 +1095,48 @@ struct ProtocolTests { try expect(response.result == nil, "oversized request must not reach the command handler") } + static func differentPeerUserIsRejected() throws { + #if os(Linux) + // The Linux CI container runs this suite as root, which lets the test + // launch one deliberately unprivileged peer. Normal developer runs + // still exercise every other transport boundary without requiring + // privilege escalation. + guard getuid() == 0 else { return } + let setpriv = "/usr/bin/setpriv" + try expect(FileManager.default.isExecutableFile(atPath: setpriv), "Linux CI should provide setpriv") + try LocalRuntime.preparePrivateDirectory() + let socketPath = LocalRuntime.directoryURL + .appendingPathComponent("peer-uid-\(UUID().uuidString).sock").path + let server = LocalSocketServer(socketPath: socketPath) + try server.start { request in CommandResponse.success(id: request.id) } + defer { + server.stop() + _ = Glibc.chmod(LocalRuntime.directoryURL.path, 0o700) + } + // The production modes are 0700/0600. Open them only inside this + // disposable test so a different uid can reach accept(), where the + // credential check must still fail closed. + try expect(Glibc.chmod(LocalRuntime.directoryURL.path, 0o777) == 0, "test runtime directory chmod failed") + try expect(Glibc.chmod(socketPath, 0o666) == 0, "test socket chmod failed") + + let process = Process() + process.executableURL = URL(fileURLWithPath: setpriv) + process.arguments = [ + "--reuid=65534", "--regid=65534", "--clear-groups", + CommandLine.arguments[0], "--peer-denied-client", socketPath, + ] + process.standardOutput = FileHandle.nullDevice + let errors = Pipe() + process.standardError = errors + try process.run() + process.waitUntilExit() + let errorText = String( + decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self + ) + try expect(process.terminationStatus == 0, "different-uid peer was not rejected: \(errorText)") + #endif + } + static func screenshotSeriesHelpers() throws { let rawPlan = JSONValue.object([ "initialY": .number(240), @@ -1386,6 +1428,24 @@ struct ProtocolTests { } static func main() { + if CommandLine.arguments.count == 3, + CommandLine.arguments[1] == "--peer-denied-client" { + do { + let descriptor = try connectRawUnixSocket(path: CommandLine.arguments[2]) + defer { closeRawSocket(descriptor) } + let response = try ProtocolCodec.decodeLine( + CommandResponse.self, from: readRawSocketLine(descriptor: descriptor) + ) + guard response.error?.code == "PEER_DENIED" else { + fputs("expected PEER_DENIED\n", stderr) + exit(1) + } + exit(0) + } catch { + fputs("peer client failed: \(error)\n", stderr) + exit(1) + } + } let tests: [TestCase] = [ ("request round-trip", requestRoundTrip), ("unsafe navigation schemes", rejectsUnsafeNavigationSchemes), @@ -1426,6 +1486,7 @@ struct ProtocolTests { ("private socket directory", serverRejectsSocketOutsidePrivateDirectory), ("shutdown bypasses busy request", shutdownBypassesBusyRequest), ("oversized socket request", oversizedSocketRequestIsRejected), + ("different peer uid", differentPeerUserIsRejected), ] var failures = 0 diff --git a/apps/headless/Tests/fixture-server.mjs b/apps/headless/Tests/fixture-server.mjs index 176fe2f..1847275 100644 --- a/apps/headless/Tests/fixture-server.mjs +++ b/apps/headless/Tests/fixture-server.mjs @@ -25,6 +25,17 @@ const server = createServer(async (request, response) => { response.end(body); return; } + if (pathname === '/download.txt') { + const body = Buffer.from('download must remain blocked'); + response.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': body.length, + 'content-disposition': 'attachment; filename="download.txt"', + 'cache-control': 'no-store', + }); + response.end(body); + return; + } const fixture = routes.get(pathname); if (!fixture) { response.writeHead(404, {'content-type': 'text/plain; charset=utf-8'}); diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh index dce6326..dd1599d 100755 --- a/apps/headless/Tests/linux-e2e.sh +++ b/apps/headless/Tests/linux-e2e.sh @@ -128,8 +128,12 @@ if SUSPICIOUS_RESULT="$(headless --session qa click --role link --name 'Suspicio exit 1 fi echo "$SUSPICIOUS_RESULT" | grep -q 'UNSAFE_RESOURCE_TYPE' -headless --session qa click --role button --name 'Scripted non-web navigation' | grep -q '"clicked"' +headless --session qa click --role button --name 'Scripted host-blocked navigation' | grep -q '"clicked"' headless --session qa wait --url /designers/dashboard --settled --timeout 10000 | grep -q 'Designers Dashboard' +BLOCKED_NAVIGATION_REPORT="$(headless --session qa qa report)" +echo "$BLOCKED_NAVIGATION_REPORT" | grep -q '"kind":"navigation-blocked"' +echo "$BLOCKED_NAVIGATION_REPORT" | grep -q 'about:blank' +headless --session qa qa clear | grep -q '"cleared"' headless --session qa screenshot --output viewport.png | grep -q '"name":"viewport.png"' headless --session qa screenshot --full-page --output full-page.png | grep -q '"name":"full-page.png"' headless --session qa screenshot --role button --name Continue --output continue.png | grep -q '"name":"continue.png"' diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index c26ad9f..499d419 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -185,28 +185,41 @@ echo "$QA_REPORT" | grep -q '"kind":"local-not-found"' STEP="safe-input-navigation" "$CLI" --session qa fill @e1 'Ada Lovelace' | grep -q '"valueLength":12' "$CLI" --session qa press Escape | grep -q '"pressed":"Escape"' +STEP="safe-input-external-link" if EXTERNAL_RESULT="$("$CLI" --session qa click --role link --name 'External application')"; then echo "external application link was not blocked" >&2 fail fi echo "$EXTERNAL_RESULT" | grep -q 'UNSAFE_NAVIGATION' +STEP="safe-input-non-web-link" if NON_WEB_RESULT="$("$CLI" --session qa click --role link --name 'Non-web browser URL')"; then echo "non-HTTP browser URL was not blocked" >&2 fail fi echo "$NON_WEB_RESULT" | grep -q 'UNSAFE_NAVIGATION' +STEP="safe-input-credential-link" if CREDENTIAL_RESULT="$("$CLI" --session qa click --role link --name 'Credential-bearing URL')"; then echo "credential-bearing browser URL was not blocked" >&2 fail fi echo "$CREDENTIAL_RESULT" | grep -q 'UNSAFE_NAVIGATION' +STEP="safe-input-suspicious-link" if SUSPICIOUS_RESULT="$("$CLI" --session qa click --role link --name 'Suspicious installer')"; then echo "suspicious installer link was not blocked" >&2 fail fi echo "$SUSPICIOUS_RESULT" | grep -q 'UNSAFE_RESOURCE_TYPE' +STEP="safe-input-scripted-navigation" "$CLI" --session qa click --role button --name 'Scripted non-web navigation' | grep -q '"clicked"' "$CLI" --session qa wait --url /designers/dashboard --settled --timeout 10000 | grep -q 'Designers Dashboard' +STEP="safe-input-download" +"$CLI" --session qa click --role link --name 'Download fixture' | grep -q '"clicked"' +sleep 1 +BLOCKED_DOWNLOAD_REPORT="$("$CLI" --session qa qa report)" +STEP="safe-input-download-report" +echo "$BLOCKED_DOWNLOAD_REPORT" | grep -q '"kind":"download-blocked"' +echo "$BLOCKED_DOWNLOAD_REPORT" | grep -q '/download.txt' +"$CLI" --session qa qa clear | grep -q '"cleared"' STEP="artifacts-visual" "$CLI" --session qa screenshot --output viewport.png | grep -q '"name":"viewport.png"' "$CLI" --session qa screenshot --full-page --output full-page.png | grep -q '"name":"full-page.png"' diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 50a98d4..57962b4 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -706,6 +706,12 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, decisionHandler(.cancel) return } + if navigationAction.shouldPerformDownload { + // Convert explicit download intent into WKDownload so the + // delegate below can classify and cancel it deterministically. + decisionHandler(.download) + return + } // Hand non-web schemes (mailto:, facetime:, app links…) to the system. if let url = navigationAction.request.url, let scheme = url.scheme?.lowercased(), !["http", "https", "file", "about", "data", "blob", "javascript"].contains(scheme) { @@ -721,10 +727,17 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, if let response = navigationResponse.response as? HTTPURLResponse { qaBridge.store.append(kind: "response", url: response.url?.absoluteString, method: "GET", status: Double(response.statusCode)) + if response.value(forHTTPHeaderField: "Content-Disposition")? + .lowercased().contains("attachment") == true { + decisionHandler(.download) + return + } } if !navigationResponse.canShowMIMEType { - showToast("Can’t display this file type") - decisionHandler(.cancel) + // Unsupported navigation responses would otherwise become + // implicit downloads. Route them through the same fail-closed + // cancellation and diagnostic path. + decisionHandler(.download) return } decisionHandler(.allow) diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index f5e1697..7dfb897 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -287,7 +287,10 @@ screenshot-plan caps and 96 px deduplication, budget text fallback, and stale element-reference errors. Recording coverage now locks every format/quality argument mapping, strict executable discovery, consecutive-failure aborts, and bounded stop timeouts; capabilities and oversized socket requests are covered. -Peer-credential and host navigation/download classification gaps remain. +Linux CI also connects through a deliberately different uid and requires +`PEER_DENIED`; Linux and macOS E2E assert `navigation-blocked` and +`download-blocked` diagnostics respectively. **Done:** every gap named in D2 +now has direct regression coverage. **D3. Web CI:** ~~`next build` + eslint on PR (site can break invisibly today).~~ **Done** — the `web` job in `ci.yml` runs `pnpm --filter @headless/web lint`