From ff20418d6abb2c6a9ff73a9943006e48a494bb5a Mon Sep 17 00:00:00 2001 From: nicodes Date: Sun, 27 Sep 2026 03:08:20 +0200 Subject: [PATCH 1/2] Add explicit zero-body POST transport post_json({}) still serializes "{}". post_zero_body sends an empty string through the existing header, timeout, native, and web paths. --- addon/src/http_client_module.gd | 20 +-- tests/http_client_module_test.gd | 200 +++++++++++++++++++++++++- tests/web_fetch_bridge_module_test.gd | 31 +++- 3 files changed, 233 insertions(+), 18 deletions(-) diff --git a/addon/src/http_client_module.gd b/addon/src/http_client_module.gd index b49c8cd..cb368cc 100644 --- a/addon/src/http_client_module.gd +++ b/addon/src/http_client_module.gd @@ -69,10 +69,14 @@ func get_json(endpoint: String, callback: Callable, headers: PackedStringArray = return _execute_request(HTTPClient.METHOD_GET, endpoint, callback, headers) func post_json(endpoint: String, body: Dictionary, callback: Callable, headers: PackedStringArray = PackedStringArray()) -> String: - return _execute_request(HTTPClient.METHOD_POST, endpoint, callback, headers, body) + return _execute_request(HTTPClient.METHOD_POST, endpoint, callback, headers, JSON.stringify(body)) + +## POST with exactly zero body bytes. post_json({}) still serializes "{}". +func post_zero_body(endpoint: String, callback: Callable, headers: PackedStringArray = PackedStringArray()) -> String: + return _execute_request(HTTPClient.METHOD_POST, endpoint, callback, headers, "") func put_json(endpoint: String, body: Dictionary, callback: Callable, headers: PackedStringArray = PackedStringArray()) -> String: - return _execute_request(HTTPClient.METHOD_PUT, endpoint, callback, headers, body) + return _execute_request(HTTPClient.METHOD_PUT, endpoint, callback, headers, JSON.stringify(body)) func delete_json(endpoint: String, callback: Callable, headers: PackedStringArray = PackedStringArray()) -> String: return _execute_request(HTTPClient.METHOD_DELETE, endpoint, callback, headers) @@ -102,7 +106,7 @@ func cancel_request(request_id: String) -> bool: _send_error(callback, request_id, 0, ERROR_CANCELLED) return true -func _execute_request(method: HTTPClient.Method, endpoint: String, callback: Callable, extra_headers: PackedStringArray, body: Dictionary = {}) -> String: +func _execute_request(method: HTTPClient.Method, endpoint: String, callback: Callable, extra_headers: PackedStringArray, request_body: String = "") -> String: _request_counter += 1 _generation_counter += 1 var request_id := str(_request_counter) @@ -122,19 +126,19 @@ func _execute_request(method: HTTPClient.Method, endpoint: String, callback: Cal if method == HTTPClient.METHOD_POST or method == HTTPClient.METHOD_PUT: headers.insert(0, HEADER_CONTENT_TYPE_JSON) headers.append_array(extra_headers) - var json_body := "" + var outbound_body := "" if method == HTTPClient.METHOD_POST or method == HTTPClient.METHOD_PUT: - json_body = JSON.stringify(body) - if json_body.to_utf8_buffer().size() > _config.max_request_body_bytes: + outbound_body = request_body + if outbound_body.to_utf8_buffer().size() > _config.max_request_body_bytes: _send_error(callback, request_id, 0, ERROR_REQUEST_TOO_LARGE) return request_id if OS.has_feature("web"): _pending_requests[request_id] = RequestEntry.new(callback, _generation_counter) _schedule_web_timeout(request_id, _generation_counter) - WebFetchBridgeModule.begin_request(_client_id, request_id, full_url, _method_to_string(method), headers, json_body, _config.max_response_body_bytes, _config.max_redirects) + WebFetchBridgeModule.begin_request(_client_id, request_id, full_url, _method_to_string(method), headers, outbound_body, _config.max_response_body_bytes, _config.max_redirects) else: - _begin_native_request(request_id, full_url, method, headers, json_body, callback) + _begin_native_request(request_id, full_url, method, headers, outbound_body, callback) return request_id func _begin_native_request(request_id: String, url: String, method: HTTPClient.Method, headers: PackedStringArray, body: String, callback: Callable) -> void: diff --git a/tests/http_client_module_test.gd b/tests/http_client_module_test.gd index c8342e7..9551e01 100644 --- a/tests/http_client_module_test.gd +++ b/tests/http_client_module_test.gd @@ -2,6 +2,18 @@ extends SceneTree var _last_payload: Dictionary[String, Variant] = {} var _callback_count: int = 0 +var _transport_active := false +var _transport_server: TCPServer = null +var _transport_peers: Array[Dictionary] = [] +var _captured_requests: Dictionary[String, Dictionary] = {} +var _transport_deadline_msec := 0 +var _transport_module: Variant = null +var _transport_owner: Node = null +var _transport_port := 0 + +const _BEARER_HEADER := "Authorization: Bearer secret-token-not-in-body" +const _BEARER_TOKEN := "secret-token-not-in-body" +const _CONFIGURED_TIMEOUT_S := 8.0 func _load_http_client_module() -> Variant: return load("res://addon/src/http_client_module.gd") @@ -14,15 +26,19 @@ func _initialize() -> void: _test_bounds_validation_and_capacity(failures) _test_cancel_generation_and_cleanup(failures) _test_two_client_instances(failures) + _test_zero_body_without_setup(failures) - if failures.is_empty(): - print("PASS gd-network http_client_module_test") - quit(0) + if not failures.is_empty(): + _finish(failures) return + _start_zero_body_transport(failures) + if not failures.is_empty(): + _finish(failures) - for failure in failures: - push_error(failure) - quit(1) +func _process(_delta: float) -> bool: + if _transport_active: + _poll_zero_body_transport() + return false func _test_missing_setup_returns_error(failures: Array[String]) -> void: var http_client_module: Variant = _load_http_client_module() @@ -173,6 +189,178 @@ func _test_two_client_instances(failures: Array[String]) -> void: func _capture_payload(payload: Dictionary[String, Variant]) -> void: _last_payload = payload +func _test_zero_body_without_setup(failures: Array[String]) -> void: + var http_client_module: Variant = _load_http_client_module() + if http_client_module == null: + failures.append("Failed to load res://addon/src/http_client_module.gd") + return + var fresh_config = http_client_module.HttpClientConfig.new() + if fresh_config.default_timeout_s != 10.0: + failures.append("Expected default per-request timeout to remain 10 seconds") + _last_payload = {} + var module = http_client_module.new() + module.post_zero_body("https://example.com/sign-out", Callable(self, "_capture_payload"), PackedStringArray([_BEARER_HEADER])) + if str(_last_payload.get("error_key", "")) != "request_failed": + failures.append("Expected post_zero_body without setup to fail like other requests") + if str(_last_payload).contains(_BEARER_TOKEN): + failures.append("Expected bearer token to stay out of the zero-body error payload") + +func _start_zero_body_transport(failures: Array[String]) -> void: + _transport_port = _listen_transport() + if _transport_port == 0: + failures.append("Expected a local TCP listener for the native zero-body POST") + return + var http_client_module: Variant = _load_http_client_module() + _transport_owner = Node.new() + root.add_child(_transport_owner) + var config = http_client_module.HttpClientConfig.new() + config.default_timeout_s = _CONFIGURED_TIMEOUT_S + _transport_module = http_client_module.new() + _transport_module.setup(_transport_owner, config) + call_deferred("_send_zero_body_transport") + +func _send_zero_body_transport() -> void: + var failures: Array[String] = [] + var headers := PackedStringArray([_BEARER_HEADER]) + var zero_id: String = _transport_module.post_zero_body("http://127.0.0.1:%d/zero" % _transport_port, Callable(), headers) + var json_id: String = _transport_module.post_json("http://127.0.0.1:%d/json" % _transport_port, {}, Callable(), headers) + _assert_native_timeout(failures, zero_id, "post_zero_body") + _assert_native_timeout(failures, json_id, "post_json") + if not failures.is_empty(): + _finish(failures) + return + _transport_deadline_msec = Time.get_ticks_msec() + 4000 + _transport_active = true + +func _assert_native_timeout(failures: Array[String], request_id: String, label: String) -> void: + var entry: Variant = _transport_module._native_requests.get(request_id, null) + if entry == null: + failures.append("Expected %s to enter the native request path" % label) + return + if entry.node.timeout != _CONFIGURED_TIMEOUT_S: + failures.append("Expected %s to keep the configured 8-second timeout, got %s" % [label, str(entry.node.timeout)]) + +func _listen_transport() -> int: + for port in range(18765, 18785): + var server := TCPServer.new() + if server.listen(port, "127.0.0.1") == OK: + _transport_server = server + return port + server.stop() + return 0 + +func _poll_zero_body_transport() -> void: + while _transport_server != null and _transport_server.is_connection_available(): + var peer: StreamPeerTCP = _transport_server.take_connection() + _transport_peers.append({"peer": peer, "buffer": PackedByteArray(), "done": false}) + var index := 0 + while index < _transport_peers.size(): + var state: Dictionary = _transport_peers[index] + if not bool(state.get("done", false)): + _read_transport_peer(state) + _transport_peers[index] = state + var parsed := _complete_http_request(state.get("buffer", PackedByteArray())) + if not parsed.is_empty(): + var path := str(parsed.get("path", "")) + _captured_requests[path] = parsed + var peer: StreamPeerTCP = state.get("peer") + peer.put_data("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}".to_utf8_buffer()) + state["done"] = true + _transport_peers[index] = state + index += 1 + if _captured_requests.has("/zero") and _captured_requests.has("/json"): + var failures: Array[String] = [] + _assert_captured_requests(failures) + _finish(failures) + return + if Time.get_ticks_msec() > _transport_deadline_msec: + var failures: Array[String] = [] + failures.append("Timed out waiting for native POST bodies; captured %s" % str(_captured_requests.keys())) + _finish(failures) + +func _read_transport_peer(state: Dictionary) -> void: + var peer: StreamPeerTCP = state.get("peer") + var available := peer.get_available_bytes() + if available <= 0: + return + var chunk: Array = peer.get_data(available) + if int(chunk[0]) != OK: + return + var buffer: PackedByteArray = state.get("buffer", PackedByteArray()) + buffer.append_array(chunk[1]) + state["buffer"] = buffer + +func _complete_http_request(buffer: PackedByteArray) -> Dictionary: + var raw := buffer.get_string_from_utf8() + var marker := "\r\n\r\n" + var header_end := raw.find(marker) + if header_end < 0: + return {} + var header_text := raw.substr(0, header_end) + var body_text := raw.substr(header_end + marker.length()) + var content_length := -1 + for line in header_text.split("\r\n"): + if line.to_lower().begins_with("content-length:"): + content_length = int(line.substr(line.find(":") + 1).strip_edges()) + if content_length >= 0 and body_text.to_utf8_buffer().size() < content_length: + return {} + if content_length >= 0: + body_text = body_text.substr(0, content_length) + var request_line := header_text.get_slice("\r\n", 0) + return { + "path": request_line.get_slice(" ", 1), + "headers": header_text, + "body": body_text, + "content_length": content_length, + "raw": raw, + } + +func _assert_captured_requests(failures: Array[String]) -> void: + var zero: Dictionary = _captured_requests.get("/zero", {}) + var json_request: Dictionary = _captured_requests.get("/json", {}) + var zero_body := str(zero.get("body", "")) + var json_body := str(json_request.get("body", "")) + if zero_body.to_utf8_buffer().size() != 0: + failures.append("Expected post_zero_body to send zero bytes, got %d (%s)" % [zero_body.to_utf8_buffer().size(), zero_body]) + if int(zero.get("content_length", -1)) > 0: + failures.append("Expected post_zero_body Content-Length to be absent or zero") + if str(zero.get("raw", "")).contains("{}"): + failures.append("Expected native zero-body POST to exclude '{}'") + if json_body != "{}": + failures.append("Expected post_json({}) to remain '{}', got %s" % json_body) + if json_body.to_utf8_buffer().size() != 2: + failures.append("Expected post_json({}) to send 2 bytes") + for path in ["/zero", "/json"]: + var captured: Dictionary = _captured_requests.get(path, {}) + var headers := str(captured.get("headers", "")) + var body := str(captured.get("body", "")) + if not headers.begins_with("POST "): + failures.append("Expected native POST for %s" % path) + if not headers.contains("Content-Type: application/json"): + failures.append("Expected shared JSON content type on %s" % path) + if not headers.contains("Accept: application/json"): + failures.append("Expected shared Accept header on %s" % path) + if not headers.contains(_BEARER_HEADER): + failures.append("Expected Authorization bearer header on %s" % path) + if body.contains(_BEARER_TOKEN): + failures.append("Expected bearer token to stay out of %s body" % path) + +func _finish(failures: Array[String]) -> void: + _transport_active = false + if _transport_module != null: + _transport_module.cleanup() + if _transport_server != null: + _transport_server.stop() + if _transport_owner != null: + _transport_owner.queue_free() + if failures.is_empty(): + print("PASS gd-network http_client_module_test") + quit(0) + return + for failure in failures: + push_error(failure) + quit(1) + func _count_payload(payload: Dictionary[String, Variant]) -> void: _callback_count += 1 _last_payload = payload diff --git a/tests/web_fetch_bridge_module_test.gd b/tests/web_fetch_bridge_module_test.gd index 98f75fc..dd24ba9 100644 --- a/tests/web_fetch_bridge_module_test.gd +++ b/tests/web_fetch_bridge_module_test.gd @@ -1,16 +1,25 @@ extends SceneTree const WebFetchBridgeModule = preload("res://addon/src/web_fetch_bridge_module.gd") +const BEARER_HEADER := "Authorization: Bearer secret-token-not-in-body" +const BEARER_TOKEN := "secret-token-not-in-body" func _initialize() -> void: - var script := WebFetchBridgeModule.build_request_script( + var failures: Array[String] = [] + var json_script := WebFetchBridgeModule.build_request_script( "client-a", "request-1", "https://example.com", "POST", - PackedStringArray(["X-Test: value"]), "{}", 8388608, 5 + PackedStringArray(["X-Test: value", BEARER_HEADER]), "{}", 8388608, 5 ) - var failures: Array[String] = [] for required in ["client-a", "request-1", "AbortController", "controllers.set", "controllers.delete", "maxBytes=8388608", "maxRedirects=5", "response_too_large", "too_many_redirects", "redirect:'manual'"]: - if not script.contains(required): + if not json_script.contains(required): failures.append("Generated browser transport omitted %s" % required) + _assert_browser_body(failures, json_script, true) + var empty_script := WebFetchBridgeModule.build_request_script( + "client-a", "request-0", "https://example.com/sign-out", "POST", + PackedStringArray([BEARER_HEADER, "Content-Type: application/json", "Accept: application/json"]), + "", 8388608, 5 + ) + _assert_browser_body(failures, empty_script, false) if failures.is_empty(): print("PASS gd-network web_fetch_bridge_module_test") quit(0) @@ -18,3 +27,17 @@ func _initialize() -> void: for failure in failures: push_error(failure) quit(1) + +func _assert_browser_body(failures: Array[String], script: String, expect_json_object: bool) -> void: + if not script.contains(BEARER_TOKEN): + failures.append("Expected bearer token in browser header script") + var body_at := script.find("opts.body=") + if expect_json_object: + if not script.contains("opts.body=\"{}\""): + var snippet := script.substr(maxi(script.find("opts."), 0), 48) + failures.append("Expected browser script to include opts.body for '{}', saw %s" % snippet) + if body_at >= 0 and script.substr(body_at).contains(BEARER_TOKEN): + failures.append("Expected bearer token to stay out of opts.body") + return + if script.contains("opts.body"): + failures.append("Expected browser script to omit opts.body for empty string") From e2fb9b30d684c10c2944ffb59875ebcd8cc3cc17 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sun, 27 Sep 2026 03:55:45 +0200 Subject: [PATCH 2/2] Release zero-body POST as addon 0.0.4 Native empty POST sets Content-Length: 0 only on the Godot path. Web fetch still omits the body, fails closed on opaque and cross-origin redirects, and documents the pre-existing native GET bearer redirect risk. --- README.md | 12 +++- addon/plugin.cfg | 2 +- addon/src/http_client_module.gd | 14 +++- addon/src/web_fetch_bridge_module.gd | 2 +- tests/http_client_module_test.gd | 10 ++- tests/web_fetch_bridge_module_test.gd | 4 +- tests/web_fixture/main.gd | 45 ++++++++++++- tests/web_fixture_server.py | 95 +++++++++++++++++++++++++++ tests/web_integration_test.sh | 10 ++- tests/web_transport.spec.js | 46 +++++++++++++ 10 files changed, 229 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8334ea1..cf0535d 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,16 @@ destruction invalidate pending callbacks without invoking consumer code. Browser requests use a per-client namespace and one `AbortController` per request; native requests disconnect stale completion signals before reuse. +## Zero-body POST + +`HttpClientModule.post_zero_body(endpoint, callback, headers)` sends a POST with exactly zero body bytes. `post_json({})` still serializes and sends `{}`. Put authorization in `headers`; it is not a body field. Headers, timeout, and error callbacks are the same as `post_json`. + +Native POST sets `Content-Length: 0` only on the Godot `HTTPRequest` path. Web POST omits the fetch body and does not set `Content-Length`, which browsers forbid. + +Web fetch uses `redirect: 'manual'`. A conforming browser exposes an opaque redirect, so the bridge does not follow it and does not send a second request. A visible cross-origin `Location` is also rejected. Native GET still follows redirects through Godot `HTTPRequest` and can resend `Authorization` to the redirect target, including another host. This release does not change that pre-existing native GET behavior. + +Added in addon version 0.0.4. + `WebSocketClientModule` uses 1 MiB inbound/outbound buffers, at most 64 queued packets, and rejects outbound UTF-8 text larger than 1 MiB. @@ -98,7 +108,7 @@ packets, and rejects outbound UTF-8 text larger than 1 MiB. ## Versioning And Releases -The version in `addon/plugin.cfg` is the addon package version. Releases are created from `main` with the manual release workflow and plain semver tags like `v0.0.1`; the workflow verifies `plugin.cfg`, builds `@aviorstudio_gd-network.zip`, and publishes `@aviorstudio/gd-network` to GDAM. +The version in `addon/plugin.cfg` is the addon package version. `0.0.4` adds `post_zero_body`. Releases are created from `main` with the manual release workflow and plain semver tags like `v0.0.4`; the workflow verifies `plugin.cfg`, builds `@aviorstudio_gd-network.zip`, and publishes `@aviorstudio/gd-network` to GDAM. ## Testing diff --git a/addon/plugin.cfg b/addon/plugin.cfg index 5526d29..367b19d 100644 --- a/addon/plugin.cfg +++ b/addon/plugin.cfg @@ -2,5 +2,5 @@ name="GD Network" description="Network primitives (HTTP pool, retry/backoff, rate limiting, windowing, error catalog)." author="Avior Studio" -version="0.0.3" +version="0.0.4" script="plugin.gd" diff --git a/addon/src/http_client_module.gd b/addon/src/http_client_module.gd index cb368cc..3d4d6b4 100644 --- a/addon/src/http_client_module.gd +++ b/addon/src/http_client_module.gd @@ -153,13 +153,25 @@ func _begin_native_request(request_id: String, url: String, method: HTTPClient.M entry.completion_callable = _on_native_request_completed.bind(request_id, entry.generation) entry.node.request_completed.connect(entry.completion_callable, CONNECT_ONE_SHOT) _native_requests[request_id] = entry - var error := entry.node.request(url, headers, method, body) + var error := entry.node.request(url, _native_request_headers(method, headers, body), method, body) if error != OK: _native_requests.erase(request_id) _disconnect_entry(entry) HttpPoolModule.release_request(entry) _send_error(callback, request_id, 0, ERROR_REQUEST_FAILED) +## Godot omits Content-Length when the body is empty. Set it only on this native +## path: browsers forbid Content-Length on fetch, so it must not be shared. +func _native_request_headers(method: HTTPClient.Method, headers: PackedStringArray, body: String) -> PackedStringArray: + if method != HTTPClient.METHOD_POST or not body.is_empty(): + return headers + for header_line in headers: + if header_line.to_lower().begins_with("content-length:"): + return headers + var native_headers := headers.duplicate() + native_headers.append("Content-Length: 0") + return native_headers + func _on_native_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray, request_id: String, generation: int) -> void: var entry: HttpPoolModule.PoolEntry = _native_requests.get(request_id, null) if entry == null or entry.generation != generation or entry.request_id != request_id: diff --git a/addon/src/web_fetch_bridge_module.gd b/addon/src/web_fetch_bridge_module.gd index aba2bc5..e3dc1b4 100644 --- a/addon/src/web_fetch_bridge_module.gd +++ b/addon/src/web_fetch_bridge_module.gd @@ -67,6 +67,6 @@ static func build_request_script( + "if(!resp.body||!resp.body.getReader){const b=await resp.arrayBuffer();if(b.byteLength>maxBytes)throw {key:'response_too_large'};return new TextDecoder().decode(b);}" + "const reader=resp.body.getReader(),chunks=[];let total=0;for(;;){const part=await reader.read();if(part.done)break;total+=part.value.byteLength;if(total>maxBytes){controller.abort();throw {key:'response_too_large'};}chunks.push(part.value);}" + "const all=new Uint8Array(total);let offset=0;for(const chunk of chunks){all.set(chunk,offset);offset+=chunk.byteLength;}return new TextDecoder().decode(all);};" - + "const run=async(target,left)=>{const resp=await fetch(target,opts);if(resp.status>=300&&resp.status<400){if(left<=0)throw {key:'too_many_redirects'};const location=resp.headers.get('location');if(!location)throw {key:'redirect_error'};return run(new URL(location,target).href,left-1);}const text=await read(resp);return {ok:resp.ok,status:resp.status,text:text};};" + + "const run=async(target,left)=>{const resp=await fetch(target,opts);if(resp.type==='opaqueredirect')throw {key:'redirect_error'};if(resp.status>=300&&resp.status<400){if(left<=0)throw {key:'too_many_redirects'};const location=resp.headers.get('location');if(!location)throw {key:'redirect_error'};const next=new URL(location,target);if(next.origin!==new URL(target).origin)throw {key:'redirect_error'};return run(next.href,left-1);}const text=await read(resp);return {ok:resp.ok,status:resp.status,text:text};};" + "run(%s,maxRedirects).then(finish).catch(err=>finish({ok:false,status:0,error_key:(err&&err.key)||((err&&err.name)==='AbortError'?'cancelled':'network_error'),error:String(err)}));})();" ) % [GLOBAL_REGISTRY_NAME, JSON.stringify(client_id), JSON.stringify(request_id), JSON.stringify(method), JSON.stringify(header_dict), body_line, max_response_bytes, max_redirects, JSON.stringify(url)] diff --git a/tests/http_client_module_test.gd b/tests/http_client_module_test.gd index 9551e01..b4ab739 100644 --- a/tests/http_client_module_test.gd +++ b/tests/http_client_module_test.gd @@ -322,14 +322,20 @@ func _assert_captured_requests(failures: Array[String]) -> void: var json_body := str(json_request.get("body", "")) if zero_body.to_utf8_buffer().size() != 0: failures.append("Expected post_zero_body to send zero bytes, got %d (%s)" % [zero_body.to_utf8_buffer().size(), zero_body]) - if int(zero.get("content_length", -1)) > 0: - failures.append("Expected post_zero_body Content-Length to be absent or zero") + var zero_length_count := 0 + for line in str(zero.get("headers", "")).split("\r\n"): + if line.to_lower().begins_with("content-length:"): + zero_length_count += 1 + if zero_length_count != 1 or int(zero.get("content_length", -1)) != 0 or not str(zero.get("headers", "")).contains("Content-Length: 0"): + failures.append("Expected exactly one native Content-Length: 0, got count %d value %s" % [zero_length_count, str(zero.get("content_length", -1))]) if str(zero.get("raw", "")).contains("{}"): failures.append("Expected native zero-body POST to exclude '{}'") if json_body != "{}": failures.append("Expected post_json({}) to remain '{}', got %s" % json_body) if json_body.to_utf8_buffer().size() != 2: failures.append("Expected post_json({}) to send 2 bytes") + if int(json_request.get("content_length", -1)) != 2: + failures.append("Expected post_json({}) Content-Length to remain 2, got %s" % str(json_request.get("content_length", -1))) for path in ["/zero", "/json"]: var captured: Dictionary = _captured_requests.get(path, {}) var headers := str(captured.get("headers", "")) diff --git a/tests/web_fetch_bridge_module_test.gd b/tests/web_fetch_bridge_module_test.gd index dd24ba9..6c30ab0 100644 --- a/tests/web_fetch_bridge_module_test.gd +++ b/tests/web_fetch_bridge_module_test.gd @@ -10,7 +10,7 @@ func _initialize() -> void: "client-a", "request-1", "https://example.com", "POST", PackedStringArray(["X-Test: value", BEARER_HEADER]), "{}", 8388608, 5 ) - for required in ["client-a", "request-1", "AbortController", "controllers.set", "controllers.delete", "maxBytes=8388608", "maxRedirects=5", "response_too_large", "too_many_redirects", "redirect:'manual'"]: + for required in ["client-a", "request-1", "AbortController", "controllers.set", "controllers.delete", "maxBytes=8388608", "maxRedirects=5", "response_too_large", "too_many_redirects", "redirect:'manual'", "opaqueredirect", "next.origin"]: if not json_script.contains(required): failures.append("Generated browser transport omitted %s" % required) _assert_browser_body(failures, json_script, true) @@ -20,6 +20,8 @@ func _initialize() -> void: "", 8388608, 5 ) _assert_browser_body(failures, empty_script, false) + if empty_script.contains("Content-Length"): + failures.append("Expected browser script to omit forbidden Content-Length") if failures.is_empty(): print("PASS gd-network web_fetch_bridge_module_test") quit(0) diff --git a/tests/web_fixture/main.gd b/tests/web_fixture/main.gd index bf612a8..2d16d97 100644 --- a/tests/web_fixture/main.gd +++ b/tests/web_fixture/main.gd @@ -2,6 +2,9 @@ extends Node const HttpClientModule = preload("res://addons/@aviorstudio_gd-network/src/http_client_module.gd") +const _BEARER_HEADER := "Authorization: Bearer secret-token-not-in-body" +const _BEARER_TOKEN := "secret-token-not-in-body" + var _first := HttpClientModule.new() var _second := HttpClientModule.new() var _results: Dictionary[String, Dictionary] = {} @@ -18,6 +21,10 @@ func _ready() -> void: _second.get_json("/payload/second", _on_second) var cancel_id := _second.get_json("/slow/cancel", _on_cancel) _second.cancel_request(cancel_id) + var headers := PackedStringArray([_BEARER_HEADER]) + _second.post_zero_body("/zero", _on_zero, headers) + _second.post_json("/json-empty", {}, _on_json, headers) + _second.get_json("/redirect-cross", _on_redirect, headers) func _on_overlap(result: Dictionary, index: int) -> void: _results[str(index)] = result @@ -32,8 +39,20 @@ func _on_cancel(result: Dictionary) -> void: _results["cancel"] = result _publish_if_complete() +func _on_zero(result: Dictionary) -> void: + _results["zero"] = result + _publish_if_complete() + +func _on_json(result: Dictionary) -> void: + _results["json"] = result + _publish_if_complete() + +func _on_redirect(result: Dictionary) -> void: + _results["redirect"] = result + _publish_if_complete() + func _publish_if_complete() -> void: - if _results.size() != 11: + if _results.size() != 14: return var success := true for index in range(8): @@ -42,6 +61,28 @@ func _publish_if_complete() -> void: success = success and _results.get("8", {}).get("error_key") == HttpClientModule.ERROR_CAPACITY success = success and _results.get("second", {}).get("json", {}).get("id") == "second" success = success and _results.get("cancel", {}).get("error_key") == HttpClientModule.ERROR_CANCELLED and _cancel_count == 1 - var evidence := {"success": success, "first_count": 9, "second": _results.get("second"), "cancel_count": _cancel_count, "capacity": _results.get("8", {}).get("error_key"), "clients": 2} + var zero: Dictionary = _results.get("zero", {}) + var json_result: Dictionary = _results.get("json", {}) + var redirect: Dictionary = _results.get("redirect", {}) + var zero_json: Dictionary = zero.get("json", {}) + success = success and zero.get("success", false) and int(zero_json.get("bytes", -1)) == 0 + success = success and str(zero_json.get("content_type", "")) == "application/json" + success = success and bool(zero_json.get("has_authorization", false)) and not bool(zero_json.get("token_in_body", true)) + success = success and json_result.get("success", false) and str(json_result.get("json", {}).get("body", "")) == "{}" + success = success and not redirect.get("success", true) and str(redirect.get("error_key", "")) == "redirect_error" + success = success and not str(zero).contains(_BEARER_TOKEN) and not str(json_result).contains(_BEARER_TOKEN) and not str(redirect).contains(_BEARER_TOKEN) + var evidence := { + "success": success, + "first_count": 9, + "second": _results.get("second"), + "cancel_count": _cancel_count, + "capacity": _results.get("8", {}).get("error_key"), + "clients": 2, + "zero_bytes": int(zero_json.get("bytes", -1)), + "zero_success": bool(zero.get("success", false)), + "json_body": str(json_result.get("json", {}).get("body", "")), + "redirect_error": str(redirect.get("error_key", "")), + "redirect_status": int(redirect.get("status_code", -1)), + } var evidence_json := JSON.stringify(evidence) JavaScriptBridge.eval("window.__gdNetworkEvidence=" + evidence_json + ";const p=document.createElement('pre');p.id='network-evidence';p.textContent='gd-network web acceptance\\n'+JSON.stringify(window.__gdNetworkEvidence,null,2);p.style='position:fixed;inset:16px;z-index:99;background:#102033;color:#d8f3ff;padding:24px;font:18px monospace;white-space:pre-wrap';document.body.appendChild(p);") diff --git a/tests/web_fixture_server.py b/tests/web_fixture_server.py index b21fe9d..9635bfc 100755 --- a/tests/web_fixture_server.py +++ b/tests/web_fixture_server.py @@ -3,11 +3,99 @@ import json import os import sys +import threading import time +SINK_PORT = 0 +SINK_HITS = "" + + +class SinkHandler(http.server.BaseHTTPRequestHandler): + def _record(self): + length_header = self.headers.get("Content-Length") + body = b"" + if length_header not in (None, ""): + body = self.rfile.read(int(length_header)) + with open(SINK_HITS, "a", encoding="utf-8") as hits: + hits.write( + "%s %s auth=%s bytes=%d\n" + % (self.command, self.path, self.headers.get("Authorization", ""), len(body)) + ) + self.send_response(204) + self.send_header("Content-Length", "0") + self.end_headers() + + def do_GET(self): + self._record() + + def do_POST(self): + self._record() + + def do_HEAD(self): + self._record() + + def log_message(self, _format, *_args): + return + + class Handler(http.server.SimpleHTTPRequestHandler): + def _read_body(self): + length_header = self.headers.get("Content-Length") + if length_header in (None, ""): + return b"" + return self.rfile.read(int(length_header)) + + def _send_json(self, payload): + encoded = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + try: + self.wfile.write(encoded) + except BrokenPipeError: + pass + + def do_POST(self): + body = self._read_body() + path = self.path.split("?", 1)[0] + authorization = self.headers.get("Authorization", "") + token_in_body = b"secret-token-not-in-body" in body + if path == "/zero": + self._send_json( + { + "id": "zero", + "bytes": len(body), + "content_type": self.headers.get("Content-Type", ""), + "has_authorization": authorization.startswith("Bearer "), + "token_in_body": token_in_body, + } + ) + return + if path == "/json-empty": + self._send_json( + { + "id": "json", + "body": body.decode("utf-8", "replace"), + "bytes": len(body), + "content_type": self.headers.get("Content-Type", ""), + "token_in_body": token_in_body, + } + ) + return + self.send_error(404) + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/redirect-cross": + self.send_response(302) + self.send_header("Location", "http://127.0.0.1:%d/must-not-follow" % SINK_PORT) + self.send_header("Content-Length", "0") + self.send_header("Cache-Control", "no-store") + self.end_headers() + return if self.path.startswith("/slow/"): time.sleep(0.35) if self.path.startswith("/slow/") or self.path.startswith("/payload/"): @@ -29,7 +117,14 @@ def log_message(self, _format, *_args): os.chdir(sys.argv[1]) +SINK_HITS = sys.argv[2] + ".sink-hits" +open(SINK_HITS, "w", encoding="utf-8").close() +sink = http.server.ThreadingHTTPServer(("127.0.0.1", 0), SinkHandler) +SINK_PORT = sink.server_port +threading.Thread(target=sink.serve_forever, daemon=True).start() server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) with open(sys.argv[2], "w", encoding="utf-8") as port_file: port_file.write(str(server.server_port)) +with open(sys.argv[2] + ".sink", "w", encoding="utf-8") as sink_file: + sink_file.write(str(SINK_PORT)) server.serve_forever() diff --git a/tests/web_integration_test.sh b/tests/web_integration_test.sh index 3ab1561..5f63cf9 100755 --- a/tests/web_integration_test.sh +++ b/tests/web_integration_test.sh @@ -7,7 +7,7 @@ GODOT="${GODOT_BIN:-godot}" fixture="$(mktemp -d)" port_file="$(mktemp)" rm -f "$port_file" -trap 'if [ -n "${server_pid:-}" ]; then kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; fi; rm -rf "$fixture"; rm -f "$port_file"' EXIT +trap 'if [ -n "${server_pid:-}" ]; then kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; fi; rm -rf "$fixture"; rm -f "$port_file" "${port_file}.sink" "${port_file}.sink-hits"' EXIT cp -R "$SCRIPT_DIR/web_fixture/." "$fixture/" mkdir -p "$fixture/addons/@aviorstudio_gd-network" "$fixture/dist" @@ -21,5 +21,11 @@ for _ in $(seq 1 100); do done test -s "$port_file" export GD_NETWORK_WEB_URL="http://127.0.0.1:$(<"$port_file")/index.html" +export GD_NETWORK_SINK_PORT="$(<"${port_file}.sink")" npx playwright test "$SCRIPT_DIR/web_transport.spec.js" --reporter=line -echo "PASS web_integration_test clients=2 requests=11 capacity=typed cancel_callbacks=1" +if [ -s "${port_file}.sink-hits" ]; then + echo "Cross-origin redirect sink received a followed request" >&2 + cat "${port_file}.sink-hits" >&2 + exit 1 +fi +echo "PASS web_integration_test clients=2 requests=14 capacity=typed cancel_callbacks=1 zero_body=0 redirect_followed=0" diff --git a/tests/web_transport.spec.js b/tests/web_transport.spec.js index 416b47d..f9b9f91 100644 --- a/tests/web_transport.spec.js +++ b/tests/web_transport.spec.js @@ -1,6 +1,16 @@ const { test, expect } = require('@playwright/test'); test('isolates bounded web requests and cancellation', async ({ page }) => { + const seen = []; + page.on('request', (request) => { + seen.push({ + url: request.url(), + method: request.method(), + headers: request.headers(), + postData: request.postData(), + redirectedFrom: request.redirectedFrom() ? request.redirectedFrom().url() : '', + }); + }); await page.goto(process.env.GD_NETWORK_WEB_URL); await page.waitForFunction(() => window.__gdNetworkEvidence !== undefined, null, { timeout: 15000 }); const evidence = await page.evaluate(() => window.__gdNetworkEvidence); @@ -10,6 +20,42 @@ test('isolates bounded web requests and cancellation', async ({ page }) => { cancel_count: 1, capacity: 'capacity_exceeded', clients: 2, + zero_bytes: 0, + zero_success: true, + json_body: '{}', + redirect_error: 'redirect_error', }); + const token = 'secret-token-not-in-body'; + const zero = seen.filter((request) => request.method === 'POST' && request.url.endsWith('/zero')); + expect(zero).toHaveLength(1); + expect(zero[0].postData ?? '').toBe(''); + expect(zero[0].headers.authorization).toBe(`Bearer ${token}`); + expect(zero[0].headers['content-type']).toContain('application/json'); + if (zero[0].headers['content-length'] !== undefined) { + expect(zero[0].headers['content-length']).toBe('0'); + } + for (const [name, value] of Object.entries(zero[0].headers)) { + if (name !== 'authorization') { + expect(String(value)).not.toContain(token); + } + } + const jsonPost = seen.filter((request) => request.method === 'POST' && request.url.endsWith('/json-empty')); + expect(jsonPost).toHaveLength(1); + expect(jsonPost[0].postData).toBe('{}'); + expect(jsonPost[0].headers.authorization).toBe(`Bearer ${token}`); + expect(jsonPost[0].postData).not.toContain(token); + const redirect = seen.filter((request) => request.url.includes('/redirect-cross')); + expect(redirect).toHaveLength(1); + expect(redirect[0].headers.authorization).toBe(`Bearer ${token}`); + const sinkPort = process.env.GD_NETWORK_SINK_PORT; + // Chromium returns an opaque redirect and does not contact the target. + // Playwright still emits a request event for that hop; a real second fetch + // would not be redirectedFrom and would hit the sink, which the shell checks. + const followed = seen.filter((request) => request.url.includes('/must-not-follow') || (sinkPort && request.url.includes(`:${sinkPort}/`))); + for (const request of followed) { + expect(request.redirectedFrom).toContain('/redirect-cross'); + expect(request.headers.authorization).toBeUndefined(); + } + expect(JSON.stringify(evidence)).not.toContain(token); await page.screenshot({ path: 'dist/web-acceptance.png', fullPage: true }); });