Skip to content

Commit b0040cf

Browse files
fix(hlsserver): the local origin answers only its own session
The listener binds 0.0.0.0 so an AirPlay receiver can reach it over the LAN (#86). That also puts it in front of every other host on that network, and it had no access control at all: the endpoint names are fixed (/master.m3u8, /media.m3u8, /init.mp4, /segN.mp4), peerAddress was read for the #227 diagnostic line and never to filter, so the ephemeral port was the only thing between a port scan on the same WiFi and the stream that is playing. Every path now carries a 128-bit per-session token as its first component, and anything without it is refused before it reaches the router. This costs nothing to carry: playlist URIs are relative, so segments, init and the subtitle renditions resolve under the prefix on their own, and only the three entry-point accessors name it. AirPlayPlaylistDecision.receiverURL used to overwrite the whole path with /media.m3u8, which would have handed the receiver an address this server now refuses. It swaps the last component and keeps what precedes it. Scope, so it is not read as more than it is: no path traversal existed and none is added (parseSubsPath parses integers, segments come from memory, no request maps to the file system), and no credential was ever reachable here. What was reachable was the stream being played. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WbciAqCSxpaiUpTumrCuA9
1 parent fea5b5b commit b0040cf

7 files changed

Lines changed: 176 additions & 17 deletions

Sources/AetherEngine/Native/AirPlayPlaylistDecision.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,14 @@ enum AirPlayPlaylistDecision {
9999
static func receiverURL(base: URL, lanIP: String, playlist: ReceiverPlaylist) -> URL? {
100100
var components = URLComponents(url: base, resolvingAgainstBaseURL: false)
101101
components?.host = lanIP
102-
if playlist == .media { components?.path = "/media.m3u8" }
102+
if playlist == .media {
103+
// Swap only the playlist name and keep whatever precedes it: the server puts a
104+
// per-session token in front of every path, and overwriting the whole path here
105+
// would send the receiver to an address the server refuses.
106+
var parts = (components?.path ?? "").split(separator: "/", omittingEmptySubsequences: true)
107+
if !parts.isEmpty { parts.removeLast() }
108+
components?.path = "/" + (parts + ["media.m3u8"]).joined(separator: "/")
109+
}
103110
return components?.url
104111
}
105112
}

Sources/AetherEngine/Network/HLSLocalServer.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,29 @@ final class HLSLocalServer: @unchecked Sendable {
181181

182182
// MARK: - Public state
183183

184+
/// Per-session capability token, first component of every path this server answers.
185+
/// The listener binds 0.0.0.0 so an AirPlay receiver can reach it over the LAN (#86), which
186+
/// also puts it in front of every other host on that network. The endpoint names are fixed,
187+
/// so without this the ephemeral port is the only thing between a stranger's port scan and
188+
/// the stream. It costs nothing to carry: playlist URIs are relative, so they resolve under
189+
/// the prefix on their own, and only the three entry-point accessors below have to name it.
190+
let pathToken: String = {
191+
var bytes = [UInt8](repeating: 0, count: 16)
192+
var generator = SystemRandomNumberGenerator()
193+
for i in bytes.indices { bytes[i] = UInt8.random(in: UInt8.min...UInt8.max, using: &generator) }
194+
return bytes.map { String(format: "%02x", $0) }.joined()
195+
}()
196+
197+
/// The request path with the session token removed, or nil when the request does not carry it.
198+
/// Static and internal so the check is unit-testable without a live socket.
199+
static func pathAfterToken(_ token: String, in path: String) -> String? {
200+
let prefix = "/" + token
201+
guard path.hasPrefix(prefix) else { return nil }
202+
let rest = String(path.dropFirst(prefix.count))
203+
guard rest.hasPrefix("/") else { return nil }
204+
return rest
205+
}
206+
184207
/// Kernel-assigned ephemeral port. Zero until start() succeeds.
185208
private(set) var port: UInt16 = 0
186209

@@ -193,15 +216,15 @@ final class HLSLocalServer: @unchecked Sendable {
193216
// is still the playlist AVPlayer must open, or the injected renditions never reach media selection.
194217
let hasMaster = provider?.masterCodecs != nil || provider?.staticMasterPlaylistBody != nil
195218
let path = hasMaster ? "master.m3u8" : "media.m3u8"
196-
return URL(string: "http://127.0.0.1:\(port)/\(path)")
219+
return URL(string: "http://127.0.0.1:\(port)/\(pathToken)/\(path)")
197220
}
198221

199222
/// Direct media.m3u8 URL, bypassing master-playlist variant selection (used when the DV/HDR handshake is unavailable so AVPlayer doesn't try to match a dvh1 master on an SDR panel).
200223
var mediaPlaylistURL: URL? {
201224
stateLock.lock()
202225
defer { stateLock.unlock() }
203226
guard port > 0 else { return nil }
204-
return URL(string: "http://127.0.0.1:\(port)/media.m3u8")
227+
return URL(string: "http://127.0.0.1:\(port)/\(pathToken)/media.m3u8")
205228
}
206229

207230
/// HDR-preserving reduced master (#98): source VIDEO-RANGE kept, no SUPPLEMENTAL-CODECS
@@ -210,7 +233,7 @@ final class HLSLocalServer: @unchecked Sendable {
210233
stateLock.lock()
211234
defer { stateLock.unlock() }
212235
guard port > 0, provider?.masterCodecs != nil else { return nil }
213-
return URL(string: "http://127.0.0.1:\(port)/master_hdr.m3u8")
236+
return URL(string: "http://127.0.0.1:\(port)/\(pathToken)/master_hdr.m3u8")
214237
}
215238

216239
/// Numeric address of the peer on an accepted connection, or nil if the socket is already gone (#227 diag).
@@ -622,7 +645,16 @@ final class HLSLocalServer: @unchecked Sendable {
622645
path = rawTarget
623646
query = ""
624647
}
625-
let normalizedPath = (path == "/audio.m3u8") ? "/media.m3u8" : path
648+
// Reject anything that does not carry this session's token before it reaches the router.
649+
// The listener is reachable from the whole LAN, so an unprefixed request is a scan or a
650+
// stale URL, never AVPlayer following a playlist we handed out.
651+
guard let routePath = Self.pathAfterToken(pathToken, in: path) else {
652+
EngineLog.emit("[HLSLocalServer] rejected request without a valid session token: \(firstLine)",
653+
category: .hlsServer)
654+
_ = send404(fd: fd, path: path, reason: "bad session token")
655+
return false
656+
}
657+
let normalizedPath = (routePath == "/audio.m3u8") ? "/media.m3u8" : routePath
626658

627659
// #50 diag: promoted to .info so the host mirror names the failing path without a verbose build. Revert once #50 is root-caused.
628660
EngineLog.emit("[HLSLocalServer] \(firstLine)", category: .hlsServer)

Tests/AetherEngineTests/AirPlayPlaylistDecisionTests.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ struct AirPlayPlaylistDecisionTests {
5555
#expect(media.absoluteString == "http://192.168.8.166:52341/media.m3u8")
5656
}
5757

58+
@Test("The session token in front of the playlist name survives the media rewrite")
59+
func mediaRewriteKeepsSessionToken() throws {
60+
// The server refuses any path without its token, so overwriting the whole path here
61+
// would hand the receiver an address that 404s.
62+
let base = try #require(URL(string: "http://127.0.0.1:52341/9f2c/master.m3u8"))
63+
let media = try #require(AirPlayPlaylistDecision.receiverURL(
64+
base: base, lanIP: "192.168.8.166", playlist: .media))
65+
#expect(media.absoluteString == "http://192.168.8.166:52341/9f2c/media.m3u8")
66+
}
67+
5868
@Test("#227: rewriting the media URL again (the fallback path) is idempotent")
5969
func mediaRewriteIsIdempotent() throws {
6070
let base = try #require(URL(string: "http://127.0.0.1:52341/media.m3u8"))
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import Testing
2+
import Foundation
3+
@testable import AetherEngine
4+
5+
/// The listener binds 0.0.0.0 so an AirPlay receiver can reach it over the LAN (#86). That also
6+
/// exposes it to every other host on that network, and the endpoint names are fixed, so the
7+
/// ephemeral port was the only thing standing between a port scan and the stream. These pin the
8+
/// per-session path token that replaced that assumption.
9+
struct HLSLocalServerSessionTokenTests {
10+
11+
// MARK: - Path check
12+
13+
@Test("The token is stripped and the remainder routes")
14+
func tokenIsStripped() {
15+
#expect(HLSLocalServer.pathAfterToken("abc123", in: "/abc123/media.m3u8") == "/media.m3u8")
16+
#expect(HLSLocalServer.pathAfterToken("abc123", in: "/abc123/seg7.mp4") == "/seg7.mp4")
17+
}
18+
19+
@Test("A request without the token is refused")
20+
func unprefixedIsRefused() {
21+
#expect(HLSLocalServer.pathAfterToken("abc123", in: "/media.m3u8") == nil)
22+
#expect(HLSLocalServer.pathAfterToken("abc123", in: "/") == nil)
23+
#expect(HLSLocalServer.pathAfterToken("abc123", in: "") == nil)
24+
}
25+
26+
@Test("A wrong token is refused")
27+
func wrongTokenIsRefused() {
28+
#expect(HLSLocalServer.pathAfterToken("abc123", in: "/def456/media.m3u8") == nil)
29+
}
30+
31+
@Test("A token that only prefixes the first segment is refused, not truncated")
32+
func partialSegmentMatchIsRefused() {
33+
// "/abc123extra/..." starts with "/abc123" as a STRING but is a different path segment.
34+
// Comparing on the string alone would let it through with a mangled remainder.
35+
#expect(HLSLocalServer.pathAfterToken("abc123", in: "/abc123extra/media.m3u8") == nil)
36+
}
37+
38+
@Test("The bare token with nothing after it is refused")
39+
func bareTokenIsRefused() {
40+
#expect(HLSLocalServer.pathAfterToken("abc123", in: "/abc123") == nil)
41+
}
42+
43+
// MARK: - Token shape
44+
45+
@Test("Each server draws its own 128-bit token")
46+
func tokensAreDistinctAndFullWidth() {
47+
let a = HLSLocalServer(provider: StubProvider())
48+
let b = HLSLocalServer(provider: StubProvider())
49+
#expect(a.pathToken.count == 32)
50+
#expect(a.pathToken.allSatisfy { $0.isHexDigit })
51+
#expect(a.pathToken != b.pathToken)
52+
}
53+
54+
// MARK: - Over a real socket
55+
56+
@Test("The served URL carries the token and an unprefixed request 404s")
57+
func unprefixedRequestIsRefusedOverTheSocket() throws {
58+
let server = HLSLocalServer(provider: StubProvider())
59+
try server.start()
60+
defer { server.stop() }
61+
62+
let served = try #require(server.mediaPlaylistURL)
63+
#expect(served.path == "/\(server.pathToken)/media.m3u8")
64+
65+
#expect(Self.status(port: server.port, path: "/\(server.pathToken)/media.m3u8") == 200)
66+
// The shape a LAN scanner would try: right port, right endpoint name, no token.
67+
#expect(Self.status(port: server.port, path: "/media.m3u8") == 404)
68+
#expect(Self.status(port: server.port, path: "/init.mp4") == 404)
69+
#expect(Self.status(port: server.port, path: "/seg0.mp4") == 404)
70+
}
71+
72+
// MARK: - Helpers
73+
74+
/// Status line of a plain GET, or 0 when the request could not be completed.
75+
private static func status(port: UInt16, path: String) -> Int {
76+
let fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)
77+
guard fd >= 0 else { return 0 }
78+
defer { close(fd) }
79+
var addr = sockaddr_in()
80+
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
81+
addr.sin_family = sa_family_t(AF_INET)
82+
addr.sin_port = port.bigEndian
83+
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
84+
let connected = withUnsafePointer(to: &addr) { ptr -> Int32 in
85+
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
86+
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
87+
}
88+
}
89+
guard connected == 0 else { return 0 }
90+
let request = "GET \(path) HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"
91+
let sent = Array(request.utf8).withUnsafeBytes { send(fd, $0.baseAddress, $0.count, 0) }
92+
guard sent > 0 else { return 0 }
93+
var buffer = [UInt8](repeating: 0, count: 256)
94+
let received = recv(fd, &buffer, buffer.count, 0)
95+
guard received > 0,
96+
let line = String(bytes: buffer[0..<received], encoding: .utf8)?
97+
.components(separatedBy: "\r\n").first else { return 0 }
98+
let parts = line.split(separator: " ")
99+
return parts.count >= 2 ? (Int(parts[1]) ?? 0) : 0
100+
}
101+
}
102+
103+
/// Smallest provider that lets the server build and serve a media playlist.
104+
private final class StubProvider: HLSSegmentProvider, @unchecked Sendable {
105+
func initSegment() -> Data? { Data([0x00]) }
106+
func mediaSegment(at index: Int) -> Data? { Data([0x00]) }
107+
var segmentCount: Int { 1 }
108+
func segmentDuration(at index: Int) -> Double { 4.0 }
109+
var playlistType: HLSPlaylistType { .vod }
110+
}

Tests/AetherEngineTests/Issue93SlowSegmentServeTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ struct Issue93SlowSegmentServeTests {
296296
try server.start()
297297
defer { server.stop() }
298298

299-
let (raw, _) = Self.rawGET(port: server.port, path: "/seg1.mp4", deadline: 1.0)
299+
let (raw, _) = Self.rawGET(port: server.port, path: "/\(server.pathToken)/seg1.mp4", deadline: 1.0)
300300
let (header, body) = Self.splitResponse(raw)
301301
#expect(header.contains("200 OK"))
302302
#expect(header.contains("Content-Length: \(payload.count)"))
@@ -314,7 +314,7 @@ struct Issue93SlowSegmentServeTests {
314314
try server.start()
315315
defer { server.stop() }
316316

317-
let (raw, firstByteAfter) = Self.rawGET(port: server.port, path: "/seg1.mp4", deadline: 1.0)
317+
let (raw, firstByteAfter) = Self.rawGET(port: server.port, path: "/\(server.pathToken)/seg1.mp4", deadline: 1.0)
318318
let (header, body) = Self.splitResponse(raw)
319319
#expect(firstByteAfter >= 0)
320320
#expect(firstByteAfter < 0.7, "header must arrive near the slow signal, got \(firstByteAfter)s")
@@ -330,7 +330,7 @@ struct Issue93SlowSegmentServeTests {
330330
try server.start()
331331
defer { server.stop() }
332332

333-
let (raw, firstByteAfter) = Self.rawGET(port: server.port, path: "/seg1.mp4", deadline: 1.5)
333+
let (raw, firstByteAfter) = Self.rawGET(port: server.port, path: "/\(server.pathToken)/seg1.mp4", deadline: 1.5)
334334
let (header, body) = Self.splitResponse(raw)
335335
#expect(firstByteAfter >= 0)
336336
#expect(header.contains("Transfer-Encoding: chunked"))

Tests/AetherEngineTests/LiveProductionHaltTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ final class LiveProductionHaltTests: XCTestCase {
160160
let server = HLSLocalServer(provider: provider)
161161
try server.start()
162162
defer { server.stop() }
163-
let result = try fetch(URL(string: "http://127.0.0.1:\(server.port)/media.m3u8?_HLS_msn=99")!)
163+
let result = try fetch(URL(string: "http://127.0.0.1:\(server.port)/\(server.pathToken)/media.m3u8?_HLS_msn=99")!)
164164
XCTAssertEqual(result.status, 503,
165165
"a held blocking reload that cannot be satisfied must 503 (retriable), never serve the unchanged playlist (-15410)")
166166
}
@@ -170,7 +170,7 @@ final class LiveProductionHaltTests: XCTestCase {
170170
let server = HLSLocalServer(provider: provider)
171171
try server.start()
172172
defer { server.stop() }
173-
let result = try fetch(URL(string: "http://127.0.0.1:\(server.port)/media.m3u8?_HLS_msn=2")!)
173+
let result = try fetch(URL(string: "http://127.0.0.1:\(server.port)/\(server.pathToken)/media.m3u8?_HLS_msn=2")!)
174174
XCTAssertEqual(result.status, 200)
175175
XCTAssertTrue(result.body.contains("#EXTM3U"), "satisfied hold serves the playlist as before")
176176
}
@@ -182,7 +182,7 @@ final class LiveProductionHaltTests: XCTestCase {
182182
let server = HLSLocalServer(provider: provider)
183183
try server.start()
184184
defer { server.stop() }
185-
let result = try fetch(URL(string: "http://127.0.0.1:\(server.port)/media.m3u8?_HLS_msn=99")!)
185+
let result = try fetch(URL(string: "http://127.0.0.1:\(server.port)/\(server.pathToken)/media.m3u8?_HLS_msn=99")!)
186186
XCTAssertEqual(result.status, 200)
187187
XCTAssertTrue(result.body.contains("#EXTM3U"))
188188
XCTAssertEqual(provider.holdCalls, 0, "gate OFF must never park the request in waitForLiveSegment")

Tests/AetherEngineTests/RemoteHLSSubtitleProxyTests.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ struct RemoteHLSSubtitleProxyTests {
8383
try server.start()
8484
defer { server.stop() }
8585

86-
let (status, body) = try await Self.get("/master.m3u8", port: server.port)
86+
let (status, body) = try await Self.get("/\(server.pathToken)/master.m3u8", port: server.port)
8787
#expect(status == 200)
8888
#expect(body == Self.master)
8989
}
@@ -97,7 +97,7 @@ struct RemoteHLSSubtitleProxyTests {
9797
let server = HLSLocalServer(provider: provider)
9898
try server.start()
9999
defer { server.stop() }
100-
#expect(server.playlistURL?.path == "/master.m3u8")
100+
#expect(server.playlistURL?.lastPathComponent == "master.m3u8")
101101
}
102102

103103
@Test("The rendition playlist is a finished whole-program VOD playlist")
@@ -109,7 +109,7 @@ struct RemoteHLSSubtitleProxyTests {
109109
try server.start()
110110
defer { server.stop() }
111111

112-
let (status, body) = try await Self.get("/subs_0.m3u8", port: server.port)
112+
let (status, body) = try await Self.get("/\(server.pathToken)/subs_0.m3u8", port: server.port)
113113
#expect(status == 200)
114114
#expect(body.contains("#EXT-X-PLAYLIST-TYPE:VOD"))
115115
#expect(body.contains("#EXT-X-TARGETDURATION:1235"))
@@ -127,8 +127,8 @@ struct RemoteHLSSubtitleProxyTests {
127127
try server.start()
128128
defer { server.stop() }
129129

130-
#expect(try await Self.get("/seg0.mp4", port: server.port).status == 404)
131-
#expect(try await Self.get("/init.mp4", port: server.port).status == 404)
130+
#expect(try await Self.get("/\(server.pathToken)/seg0.mp4", port: server.port).status == 404)
131+
#expect(try await Self.get("/\(server.pathToken)/init.mp4", port: server.port).status == 404)
132132
}
133133

134134
@Test("A decoded sidecar is served as whole-program WebVTT", .timeLimit(.minutes(2)))
@@ -161,7 +161,7 @@ struct RemoteHLSSubtitleProxyTests {
161161
provider.startFill()
162162
await provider.awaitFill()
163163

164-
let (status, body) = try await Self.get("/subs_0_0.vtt", port: server.port)
164+
let (status, body) = try await Self.get("/\(server.pathToken)/subs_0_0.vtt", port: server.port)
165165
#expect(status == 200)
166166
#expect(body.hasPrefix("WEBVTT"))
167167
#expect(body.contains("Erste Zeile"))

0 commit comments

Comments
 (0)