Skip to content

Commit 93f9e55

Browse files
committed
fix: force artifact downloads to use ios-safe headers
1 parent 42b2d31 commit 93f9e55

4 files changed

Lines changed: 98 additions & 14 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package dev.typetype.server.routes
2+
3+
import dev.typetype.server.services.DownloaderGatewayResponse
4+
import io.ktor.http.HttpHeaders
5+
import io.ktor.server.application.ApplicationCall
6+
7+
fun shouldForwardGatewayResponseHeader(name: String, forceDownload: Boolean = false): Boolean {
8+
val lower = name.lowercase()
9+
val blocked = lower == "content-length" || lower == "transfer-encoding" || lower == "connection"
10+
if (blocked) return false
11+
if (!forceDownload) return true
12+
return lower != "content-type" &&
13+
lower != "content-disposition" &&
14+
lower != "x-content-type-options" &&
15+
lower != "cache-control" &&
16+
lower != "pragma" &&
17+
lower != "expires"
18+
}
19+
20+
fun shouldForceArtifactDownload(path: String, query: String?): Boolean {
21+
if (!path.endsWith("/artifact")) return false
22+
val raw = queryParam(query, "download") ?: return true
23+
val value = raw.lowercase()
24+
return value != "0" && value != "false" && value != "no"
25+
}
26+
27+
fun applyArtifactDownloadHeaders(call: ApplicationCall, response: DownloaderGatewayResponse) {
28+
call.response.headers.append(HttpHeaders.ContentDisposition, attachmentDisposition(response), safeOnly = false)
29+
call.response.headers.append("X-Content-Type-Options", "nosniff", safeOnly = false)
30+
call.response.headers.append(HttpHeaders.CacheControl, "no-store, no-cache, must-revalidate, max-age=0", safeOnly = false)
31+
call.response.headers.append(HttpHeaders.Pragma, "no-cache", safeOnly = false)
32+
call.response.headers.append(HttpHeaders.Expires, "0", safeOnly = false)
33+
}
34+
35+
private fun queryParam(query: String?, key: String): String? {
36+
if (query.isNullOrBlank()) return null
37+
return query.split('&')
38+
.asSequence()
39+
.map { it.split('=', limit = 2) }
40+
.firstOrNull { it.firstOrNull() == key }
41+
?.getOrNull(1)
42+
}
43+
44+
private fun attachmentDisposition(response: DownloaderGatewayResponse): String {
45+
val current = response.headers.firstOrNull { it.first.equals(HttpHeaders.ContentDisposition, ignoreCase = true) }
46+
?.second
47+
.orEmpty()
48+
if (current.isBlank()) return "attachment"
49+
if (current.startsWith("attachment", ignoreCase = true)) return current
50+
return current.replaceFirst(Regex("^inline", RegexOption.IGNORE_CASE), "attachment")
51+
}

src/main/kotlin/dev/typetype/server/routes/DownloaderGatewayRoutes.kt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,22 @@ private suspend fun forwardDownloaderRequest(call: ApplicationCall, gateway: Dow
7777
response
7878
}
7979

80+
val forceDownload = shouldForceArtifactDownload(path, query)
81+
8082
effectiveResponse.headers.forEach { (name, value) ->
81-
if (shouldForwardResponseHeader(name)) call.response.headers.append(name, value, safeOnly = false)
83+
if (shouldForwardGatewayResponseHeader(name, forceDownload)) {
84+
call.response.headers.append(name, value, safeOnly = false)
85+
}
8286
}
87+
if (forceDownload) applyArtifactDownloadHeaders(call, effectiveResponse)
8388

8489
val status = HttpStatusCode.fromValue(effectiveResponse.status)
85-
val contentType = effectiveResponse.contentType?.let { runCatching { ContentType.parse(it) }.getOrNull() }
86-
?: ContentType.Application.OctetStream
90+
val contentType = if (forceDownload) {
91+
ContentType.Application.OctetStream
92+
} else {
93+
effectiveResponse.contentType?.let { runCatching { ContentType.parse(it) }.getOrNull() }
94+
?: ContentType.Application.OctetStream
95+
}
8796
call.respondBytes(effectiveResponse.body, contentType = contentType, status = status)
8897
}
8998

@@ -109,8 +118,3 @@ private fun isInternalHost(location: String): Boolean {
109118
val host = runCatching { URI(location).host }.getOrNull() ?: return false
110119
return host.equals("garage", ignoreCase = true)
111120
}
112-
113-
private fun shouldForwardResponseHeader(name: String): Boolean {
114-
val lower = name.lowercase()
115-
return lower != "content-length" && lower != "transfer-encoding" && lower != "connection"
116-
}

src/main/kotlin/dev/typetype/server/routes/DownloaderGatewaySseProxy.kt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ suspend fun forwardDownloaderSseRequest(
2525

2626
upstream.use { response ->
2727
response.headers.names().forEach { name ->
28-
if (shouldForwardResponseHeader(name)) {
28+
if (shouldForwardGatewayResponseHeader(name)) {
2929
response.headers(name).forEach { value ->
3030
call.response.headers.append(name, value, safeOnly = false)
3131
}
@@ -50,8 +50,3 @@ suspend fun forwardDownloaderSseRequest(
5050
} ?: call.respond(HttpStatusCode.fromValue(response.code))
5151
}
5252
}
53-
54-
private fun shouldForwardResponseHeader(name: String): Boolean {
55-
val lower = name.lowercase()
56-
return lower != "content-length" && lower != "transfer-encoding" && lower != "connection"
57-
}

src/test/kotlin/dev/typetype/server/DownloaderGatewayRoutesTest.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import dev.typetype.server.routes.downloaderGatewayRoutes
44
import dev.typetype.server.services.DownloaderGatewayService
55
import io.ktor.client.request.get
66
import io.ktor.client.request.header
7+
import io.ktor.client.statement.bodyAsText
78
import io.ktor.http.ContentType
89
import io.ktor.http.HttpHeaders
910
import io.ktor.http.HttpStatusCode
@@ -48,4 +49,37 @@ class DownloaderGatewayRoutesTest {
4849
upstream.stop(0)
4950
}
5051
}
52+
53+
@Test
54+
fun `artifact route forces attachment headers for ios safari`() = testApplication {
55+
val upstream = HttpServer.create(InetSocketAddress(0), 0)
56+
upstream.createContext("/jobs/test/artifact") { exchange ->
57+
val payload = "abc".toByteArray()
58+
exchange.responseHeaders.add(HttpHeaders.ContentType, "video/mp4")
59+
exchange.responseHeaders.add(HttpHeaders.ContentDisposition, "inline; filename=\"demo.mp4\"")
60+
exchange.sendResponseHeaders(200, payload.size.toLong())
61+
exchange.responseBody.use { it.write(payload) }
62+
}
63+
upstream.start()
64+
65+
val port = upstream.address.port
66+
val gateway = DownloaderGatewayService("http://127.0.0.1:$port")
67+
68+
application {
69+
routing {
70+
downloaderGatewayRoutes(gateway)
71+
}
72+
}
73+
74+
try {
75+
val response = client.get("/downloader/jobs/test/artifact")
76+
assertEquals(HttpStatusCode.OK, response.status)
77+
assertTrue(response.headers[HttpHeaders.ContentType].orEmpty().contains("application/octet-stream"))
78+
assertTrue(response.headers[HttpHeaders.ContentDisposition].orEmpty().startsWith("attachment"))
79+
assertEquals("nosniff", response.headers["X-Content-Type-Options"])
80+
assertEquals("abc", response.bodyAsText())
81+
} finally {
82+
upstream.stop(0)
83+
}
84+
}
5185
}

0 commit comments

Comments
 (0)