diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/web/WebFetchTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/web/WebFetchTool.kt index 3b990c70..5757d329 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/web/WebFetchTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/web/WebFetchTool.kt @@ -43,6 +43,13 @@ class WebFetchTool( private val maxBytes: Long = DEFAULT_MAX_BYTES, ) : Tool, ToolExecutor { + // Do NOT follow redirects. Plane-2 egress (NetworkHostRule) validates the REQUESTED host before + // this tool runs; ktor's default redirect-following would then let a server 302 the fetch to an + // internal host that was never validated (SSRF). With redirects off, a 3xx never opens that + // connection — the model must re-issue web_fetch for the new URL, which re-triggers egress + // validation. Derived client shares the injected engine (no separate resource to close). + private val client: HttpClient = httpClient.config { followRedirects = false } + override val name: String = "web_fetch" override val description: String = "Fetch a URL and return its main content as clean markdown." override val tier: Tier = Tier.T2 @@ -77,10 +84,19 @@ class WebFetchTool( } private suspend fun fetch(invocationId: ToolInvocationId, url: String): ToolResult = - httpClient.prepareGet(url).execute { response -> + client.prepareGet(url).execute { response -> val contentType = response.headers[HttpHeaders.ContentType] val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull() when { + response.status.value in 300..399 -> + fail( + invocationId, + "URL redirected (HTTP ${response.status.value}) to " + + "${response.headers[HttpHeaders.Location] ?: "an unspecified location"}. Redirects are " + + "not followed automatically — re-issue web_fetch with that URL so egress is validated " + + "for its host.", + recoverable = true, + ) !response.status.isSuccess() -> fail(invocationId, "HTTP ${response.status.value} for $url", recoverable = true) declaredLength != null && declaredLength > maxBytes -> @@ -123,13 +139,13 @@ class WebFetchTool( /** Reads the channel up to [maxBytes]; returns null if the body exceeds the cap. */ private suspend fun readBounded(channel: io.ktor.utils.io.ByteReadChannel): ByteArray? { - val out = ArrayList() + val out = java.io.ByteArrayOutputStream() val buffer = ByteArray(READ_CHUNK) while (true) { val read = channel.readAvailable(buffer, 0, buffer.size) if (read == -1) break - if (out.size + read > maxBytes) return null - for (i in 0 until read) out.add(buffer[i]) + if (out.size() + read > maxBytes) return null + out.write(buffer, 0, read) } return out.toByteArray() } diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt index a34d658f..3d6bd8f9 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/shell/ShellToolTest.kt @@ -61,7 +61,8 @@ class ShellToolTest { // `cd dir && echo x` — cd is a builtin (implicitly allowed), echo is allowlisted, `dir`/`x` // are arguments (not command positions), so the chain is valid. val tool = ShellTool(allowedExecutables = setOf("echo")) - assertEquals(ValidationResult.Valid, tool.validateRequest(createRequest(listOf("cd", "dir", "&&", "echo", "x")))) + val result = tool.validateRequest(createRequest(listOf("cd", "dir", "&&", "echo", "x"))) + assertEquals(ValidationResult.Valid, result) } @Test diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/web/WebFetchToolTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/web/WebFetchToolTest.kt index 4ffb820a..fe97ab7d 100644 Binary files a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/web/WebFetchToolTest.kt and b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/web/WebFetchToolTest.kt differ