{ "assetKey": "a13-f008-host-wire", "kind": "terminal", "command": "set -e\nset -o pipefail\nD=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT\nC=/shared/codebase/clean-architecture-backend-template\nN=$C/src/adapter/outbound/notification\nCACHE=/root/.gradle/caches/modules-2/files-2.1\njava -version 2>&1 | head -1\ncp=\"\"\nwhile IFS= read -r line; do\n coord=${line%%=*}; confs=${line#*=}\n case \",$confs,\" in *\",runtimeClasspath,\"*) ;; *) continue ;; esac\n g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}\n jar=$(find \"$CACHE/$g/$n/$v\" -name '*.jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1)\n [ -n \"$jar\" ] && cp=\"$cp:$jar\"\ndone < <(grep -E '^[a-zA-Z0-9._-]+:[^=]+=' \"$N/gradle.lockfile\")\nCP=$(find \"$N/build/libs\" \"$C/src/application-core/build/libs\" -name '*+21234e38cdb9.jar' | tr '\\n' ':')${cp#:}\ncat > \"$D/SignedHostProbe.java\" <<'JAVA'\nimport com.sun.net.httpserver.HttpServer;\nimport dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;\nimport dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;\nimport java.net.InetSocketAddress;\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicReference;\n\n/** 매퍼가 서명하는 호스트 값과 JDK 가 실제로 보내는 Host 헤더를 나란히 잰다. */\npublic final class SignedHostProbe {\n\n private static final String PATH = \"/v2/email/outbound-emails\";\n\n /** SesRequestMapper:103 이 쓰는 식 그대로. */\n private static String signedHost(URI endpoint) {\n return endpoint.getHost();\n }\n\n /** ExternalRequestUrlResolver:57-63 이 쓰는 판정. 전선의 Host 가 따르는 규칙이다. */\n private static boolean defaultPort(String scheme, int port) {\n return port < 0\n || (\"https\".equalsIgnoreCase(scheme) && port == 443)\n || (\"http\".equalsIgnoreCase(scheme) && port == 80);\n }\n\n private static String expectedWireHost(URI endpoint) {\n return defaultPort(endpoint.getScheme(), endpoint.getPort())\n ? endpoint.getHost()\n : endpoint.getHost() + \":\" + endpoint.getPort();\n }\n\n public static void main(String[] args) throws Exception {\n HttpServer server = HttpServer.create(new InetSocketAddress(\"127.0.0.1\", 0), 0);\n AtomicReference received = new AtomicReference<>(\"(받지 못함)\");\n server.createContext(PATH, exchange -> {\n received.set(String.valueOf(exchange.getRequestHeaders().getFirst(\"Host\")));\n byte[] ok = \"{}\".getBytes(StandardCharsets.UTF_8);\n exchange.sendResponseHeaders(200, ok.length);\n exchange.getResponseBody().write(ok);\n exchange.close();\n });\n server.start();\n int port = server.getAddress().getPort();\n URI endpoint = URI.create(\"http://127.0.0.1:\" + port);\n String signed = signedHost(endpoint);\n JdkNotificationHttpGateway gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(5));\n\n try {\n System.out.println();\n System.out.println(\"[헤더 맵 세 모양으로 같은 서버에 보낸다]\");\n Map> shapes = new LinkedHashMap<>();\n shapes.put(\"매퍼가 실제로 보내는 넷\", Map.of(\n \"content-type\", \"application/json\", \"x-amz-date\", \"20260903T000000Z\",\n \"x-amz-content-sha256\", \"UNSIGNED-PAYLOAD\", \"authorization\", \"AWS4-HMAC-SHA256 ...\"));\n shapes.put(\"거기에 host 를 더한 것\", Map.of(\"content-type\", \"application/json\", \"host\", signed));\n shapes.put(\"대문자 Host 로 더한 것\", Map.of(\"content-type\", \"application/json\", \"Host\", signed));\n for (var shape : shapes.entrySet()) {\n received.set(\"(받지 못함)\");\n NotificationHttpRequest request =\n new NotificationHttpRequest(\"POST\", URI.create(endpoint + PATH),\n JdkNotificationHttpGateway.headers(shape.getValue()),\n \"{}\".getBytes(StandardCharsets.UTF_8), Duration.ofSeconds(5));\n int status = gateway.exchange(request).statusCode();\n // 포트는 실행마다 다르다. 재는 것은 두 값이 갈리느냐이므로 자리표시로 바꾼다.\n System.out.printf(\" %-22s 상태 %d · 서버가 받은 Host %s%n\",\n shape.getKey(), status, received.get().replace(Integer.toString(port), \"<포트>\"));\n }\n System.out.printf(\" %-22s %s%n\", \"서명 대상\", signed);\n System.out.printf(\" %-22s %s%n\", \"둘이 같은가\",\n received.get().equals(signed) ? \"예\" : \"아니오\");\n System.out.println();\n\n System.out.println(\"[갈리는 조건 — 위 규칙을 엔드포인트마다 적용한다]\");\n System.out.println(\" 전선과 갈리면 서명이 어긋난다. 권한부는 갈려도 어긋나지 않는 자리가 있다.\");\n System.out.printf(\" %-46s %-14s %s%n\", \"엔드포인트\", \"전선과 갈리나\", \"권한부와 갈리나\");\n URI[] endpoints = {\n URI.create(\"https://email.eu-central-1.amazonaws.com\"),\n URI.create(\"https://email.eu-central-1.amazonaws.com:8443\"),\n URI.create(\"https://email.eu-central-1.amazonaws.com:443\"),\n URI.create(\"http://127.0.0.1:80\"),\n URI.create(\"https://user@email.example.com\"),\n URI.create(\"http://localhost:4566\"),\n };\n for (URI each : endpoints) {\n String host = signedHost(each);\n System.out.printf(\" %-46s %-16s %s%n\", each,\n host.equals(expectedWireHost(each)) ? \"아니오\" : \"예\",\n host.equals(each.getAuthority()) ? \"아니오\" : \"예\");\n }\n } finally {\n server.stop(0);\n }\n }\n}\nJAVA\njavac -encoding UTF-8 -cp \"$CP\" -d \"$D\" \"$D/SignedHostProbe.java\"\njava -Dstdout.encoding=UTF-8 -cp \"$CP:$D\" SignedHostProbe\n", "cwd": "/shared/codebase/clean-architecture-backend-template/src", "exitCode": 0, "executedAt": "2026-09-02T17:45:59+00:00", "sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916", "raw": "evidence/raw/a13-f008-host-wire.txt", "svg": "evidence/rendered/a13-f008-host-wire.svg", "rawSha256": "f84c7e457cf0302579f938585c75582132cde272b0d521f9c68e0e9a9e0cfcea", "lines": 18, "redaction": "none — 엔드포인트와 헤더 값과 응답은 이 프로브가 만든 합성 값이다" }