Skip to content

Commit 48e0b6e

Browse files
authored
fix(security): classify peer and host denials (#105)
* test(security): cover peer and host denials * test(e2e): isolate host-blocked navigation fixture * test(macos): identify navigation denial failures * fix(macos): classify and cancel download intent
1 parent e3bef7b commit 48e0b6e

8 files changed

Lines changed: 116 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ Cutting that release is tracked in
3636
- Recording and transport regression coverage for every ffmpeg format/quality
3737
mapping, executable discovery, capture-failure and stop bounds, capabilities
3838
accuracy, and oversized Unix-socket requests.
39+
- Cross-uid Unix-socket rejection and host E2E classification checks for
40+
blocked top-frame navigation and denied page-initiated downloads.
41+
- macOS explicitly converts download-intent links, attachment responses, and
42+
unsupported response types into cancellable `WKDownload` objects so every
43+
denied download is classified without writing page-controlled bytes.
3944
- Progressive context pruning: `inspect --context summary|outline|text|actions|full`
4045
with `--task` ranking, `--within @rN` scoping, and `--limit` / `--budget` /
4146
`--depth` bounds. Every focused response reports `contextStats` and `omitted`.

‎apps/headless/Tests/Fixtures/dashboard.html‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ <h2>Continue setup</h2>
3030
<p><a href="about:blank">Non-web browser URL</a></p>
3131
<p><a href="http://qa:secret@127.0.0.1/private">Credential-bearing URL</a></p>
3232
<p><a href="/suspicious-installer.dmg">Suspicious installer</a></p>
33+
<p><a href="/download.txt" download>Download fixture</a></p>
3334
<button onclick="location.href='data:text/html,blocked'">Scripted non-web navigation</button>
35+
<button onclick="location.href='about:blank'">Scripted host-blocked navigation</button>
3436
<button onclick="location.href='/next'">Continue</button>
3537
</section>
3638
</main>

‎apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,48 @@ struct ProtocolTests {
10951095
try expect(response.result == nil, "oversized request must not reach the command handler")
10961096
}
10971097

1098+
static func differentPeerUserIsRejected() throws {
1099+
#if os(Linux)
1100+
// The Linux CI container runs this suite as root, which lets the test
1101+
// launch one deliberately unprivileged peer. Normal developer runs
1102+
// still exercise every other transport boundary without requiring
1103+
// privilege escalation.
1104+
guard getuid() == 0 else { return }
1105+
let setpriv = "/usr/bin/setpriv"
1106+
try expect(FileManager.default.isExecutableFile(atPath: setpriv), "Linux CI should provide setpriv")
1107+
try LocalRuntime.preparePrivateDirectory()
1108+
let socketPath = LocalRuntime.directoryURL
1109+
.appendingPathComponent("peer-uid-\(UUID().uuidString).sock").path
1110+
let server = LocalSocketServer(socketPath: socketPath)
1111+
try server.start { request in CommandResponse.success(id: request.id) }
1112+
defer {
1113+
server.stop()
1114+
_ = Glibc.chmod(LocalRuntime.directoryURL.path, 0o700)
1115+
}
1116+
// The production modes are 0700/0600. Open them only inside this
1117+
// disposable test so a different uid can reach accept(), where the
1118+
// credential check must still fail closed.
1119+
try expect(Glibc.chmod(LocalRuntime.directoryURL.path, 0o777) == 0, "test runtime directory chmod failed")
1120+
try expect(Glibc.chmod(socketPath, 0o666) == 0, "test socket chmod failed")
1121+
1122+
let process = Process()
1123+
process.executableURL = URL(fileURLWithPath: setpriv)
1124+
process.arguments = [
1125+
"--reuid=65534", "--regid=65534", "--clear-groups",
1126+
CommandLine.arguments[0], "--peer-denied-client", socketPath,
1127+
]
1128+
process.standardOutput = FileHandle.nullDevice
1129+
let errors = Pipe()
1130+
process.standardError = errors
1131+
try process.run()
1132+
process.waitUntilExit()
1133+
let errorText = String(
1134+
decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self
1135+
)
1136+
try expect(process.terminationStatus == 0, "different-uid peer was not rejected: \(errorText)")
1137+
#endif
1138+
}
1139+
10981140
static func screenshotSeriesHelpers() throws {
10991141
let rawPlan = JSONValue.object([
11001142
"initialY": .number(240),
@@ -1386,6 +1428,24 @@ struct ProtocolTests {
13861428
}
13871429

13881430
static func main() {
1431+
if CommandLine.arguments.count == 3,
1432+
CommandLine.arguments[1] == "--peer-denied-client" {
1433+
do {
1434+
let descriptor = try connectRawUnixSocket(path: CommandLine.arguments[2])
1435+
defer { closeRawSocket(descriptor) }
1436+
let response = try ProtocolCodec.decodeLine(
1437+
CommandResponse.self, from: readRawSocketLine(descriptor: descriptor)
1438+
)
1439+
guard response.error?.code == "PEER_DENIED" else {
1440+
fputs("expected PEER_DENIED\n", stderr)
1441+
exit(1)
1442+
}
1443+
exit(0)
1444+
} catch {
1445+
fputs("peer client failed: \(error)\n", stderr)
1446+
exit(1)
1447+
}
1448+
}
13891449
let tests: [TestCase] = [
13901450
("request round-trip", requestRoundTrip),
13911451
("unsafe navigation schemes", rejectsUnsafeNavigationSchemes),
@@ -1426,6 +1486,7 @@ struct ProtocolTests {
14261486
("private socket directory", serverRejectsSocketOutsidePrivateDirectory),
14271487
("shutdown bypasses busy request", shutdownBypassesBusyRequest),
14281488
("oversized socket request", oversizedSocketRequestIsRejected),
1489+
("different peer uid", differentPeerUserIsRejected),
14291490
]
14301491

14311492
var failures = 0

‎apps/headless/Tests/fixture-server.mjs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ const server = createServer(async (request, response) => {
2525
response.end(body);
2626
return;
2727
}
28+
if (pathname === '/download.txt') {
29+
const body = Buffer.from('download must remain blocked');
30+
response.writeHead(200, {
31+
'content-type': 'text/plain; charset=utf-8',
32+
'content-length': body.length,
33+
'content-disposition': 'attachment; filename="download.txt"',
34+
'cache-control': 'no-store',
35+
});
36+
response.end(body);
37+
return;
38+
}
2839
const fixture = routes.get(pathname);
2940
if (!fixture) {
3041
response.writeHead(404, {'content-type': 'text/plain; charset=utf-8'});

‎apps/headless/Tests/linux-e2e.sh‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,12 @@ if SUSPICIOUS_RESULT="$(headless --session qa click --role link --name 'Suspicio
128128
exit 1
129129
fi
130130
echo "$SUSPICIOUS_RESULT" | grep -q 'UNSAFE_RESOURCE_TYPE'
131-
headless --session qa click --role button --name 'Scripted non-web navigation' | grep -q '"clicked"'
131+
headless --session qa click --role button --name 'Scripted host-blocked navigation' | grep -q '"clicked"'
132132
headless --session qa wait --url /designers/dashboard --settled --timeout 10000 | grep -q 'Designers Dashboard'
133+
BLOCKED_NAVIGATION_REPORT="$(headless --session qa qa report)"
134+
echo "$BLOCKED_NAVIGATION_REPORT" | grep -q '"kind":"navigation-blocked"'
135+
echo "$BLOCKED_NAVIGATION_REPORT" | grep -q 'about:blank'
136+
headless --session qa qa clear | grep -q '"cleared"'
133137
headless --session qa screenshot --output viewport.png | grep -q '"name":"viewport.png"'
134138
headless --session qa screenshot --full-page --output full-page.png | grep -q '"name":"full-page.png"'
135139
headless --session qa screenshot --role button --name Continue --output continue.png | grep -q '"name":"continue.png"'

‎apps/headless/Tests/macos-e2e.sh‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,28 +185,41 @@ echo "$QA_REPORT" | grep -q '"kind":"local-not-found"'
185185
STEP="safe-input-navigation"
186186
"$CLI" --session qa fill @e1 'Ada Lovelace' | grep -q '"valueLength":12'
187187
"$CLI" --session qa press Escape | grep -q '"pressed":"Escape"'
188+
STEP="safe-input-external-link"
188189
if EXTERNAL_RESULT="$("$CLI" --session qa click --role link --name 'External application')"; then
189190
echo "external application link was not blocked" >&2
190191
fail
191192
fi
192193
echo "$EXTERNAL_RESULT" | grep -q 'UNSAFE_NAVIGATION'
194+
STEP="safe-input-non-web-link"
193195
if NON_WEB_RESULT="$("$CLI" --session qa click --role link --name 'Non-web browser URL')"; then
194196
echo "non-HTTP browser URL was not blocked" >&2
195197
fail
196198
fi
197199
echo "$NON_WEB_RESULT" | grep -q 'UNSAFE_NAVIGATION'
200+
STEP="safe-input-credential-link"
198201
if CREDENTIAL_RESULT="$("$CLI" --session qa click --role link --name 'Credential-bearing URL')"; then
199202
echo "credential-bearing browser URL was not blocked" >&2
200203
fail
201204
fi
202205
echo "$CREDENTIAL_RESULT" | grep -q 'UNSAFE_NAVIGATION'
206+
STEP="safe-input-suspicious-link"
203207
if SUSPICIOUS_RESULT="$("$CLI" --session qa click --role link --name 'Suspicious installer')"; then
204208
echo "suspicious installer link was not blocked" >&2
205209
fail
206210
fi
207211
echo "$SUSPICIOUS_RESULT" | grep -q 'UNSAFE_RESOURCE_TYPE'
212+
STEP="safe-input-scripted-navigation"
208213
"$CLI" --session qa click --role button --name 'Scripted non-web navigation' | grep -q '"clicked"'
209214
"$CLI" --session qa wait --url /designers/dashboard --settled --timeout 10000 | grep -q 'Designers Dashboard'
215+
STEP="safe-input-download"
216+
"$CLI" --session qa click --role link --name 'Download fixture' | grep -q '"clicked"'
217+
sleep 1
218+
BLOCKED_DOWNLOAD_REPORT="$("$CLI" --session qa qa report)"
219+
STEP="safe-input-download-report"
220+
echo "$BLOCKED_DOWNLOAD_REPORT" | grep -q '"kind":"download-blocked"'
221+
echo "$BLOCKED_DOWNLOAD_REPORT" | grep -q '/download.txt'
222+
"$CLI" --session qa qa clear | grep -q '"cleared"'
210223
STEP="artifacts-visual"
211224
"$CLI" --session qa screenshot --output viewport.png | grep -q '"name":"viewport.png"'
212225
"$CLI" --session qa screenshot --full-page --output full-page.png | grep -q '"name":"full-page.png"'

‎apps/headless/main.swift‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,12 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
706706
decisionHandler(.cancel)
707707
return
708708
}
709+
if navigationAction.shouldPerformDownload {
710+
// Convert explicit download intent into WKDownload so the
711+
// delegate below can classify and cancel it deterministically.
712+
decisionHandler(.download)
713+
return
714+
}
709715
// Hand non-web schemes (mailto:, facetime:, app links…) to the system.
710716
if let url = navigationAction.request.url, let scheme = url.scheme?.lowercased(),
711717
!["http", "https", "file", "about", "data", "blob", "javascript"].contains(scheme) {
@@ -721,10 +727,17 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
721727
if let response = navigationResponse.response as? HTTPURLResponse {
722728
qaBridge.store.append(kind: "response", url: response.url?.absoluteString,
723729
method: "GET", status: Double(response.statusCode))
730+
if response.value(forHTTPHeaderField: "Content-Disposition")?
731+
.lowercased().contains("attachment") == true {
732+
decisionHandler(.download)
733+
return
734+
}
724735
}
725736
if !navigationResponse.canShowMIMEType {
726-
showToast("Can’t display this file type")
727-
decisionHandler(.cancel)
737+
// Unsupported navigation responses would otherwise become
738+
// implicit downloads. Route them through the same fail-closed
739+
// cancellation and diagnostic path.
740+
decisionHandler(.download)
728741
return
729742
}
730743
decisionHandler(.allow)

‎docs/roadmap/improvements-backlog.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,10 @@ screenshot-plan caps and 96 px deduplication, budget text fallback, and stale
287287
element-reference errors. Recording coverage now locks every format/quality
288288
argument mapping, strict executable discovery, consecutive-failure aborts, and
289289
bounded stop timeouts; capabilities and oversized socket requests are covered.
290-
Peer-credential and host navigation/download classification gaps remain.
290+
Linux CI also connects through a deliberately different uid and requires
291+
`PEER_DENIED`; Linux and macOS E2E assert `navigation-blocked` and
292+
`download-blocked` diagnostics respectively. **Done:** every gap named in D2
293+
now has direct regression coverage.
291294

292295
**D3. Web CI:** ~~`next build` + eslint on PR (site can break invisibly today).~~
293296
**Done** — the `web` job in `ci.yml` runs `pnpm --filter @headless/web lint`

0 commit comments

Comments
 (0)