From a799e4d6a5ed15bedc4c18a0ce51d89b641e3d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 19 Sep 2026 18:12:28 +0200 Subject: [PATCH] Enforce worker redirect hop policy --- .../worker-options-load-policy-701-81-227.md | 10 +- .../worker-redirect-hop-policy-704-81-227.md | 56 +++++++ .../native/webscene_native_engine.h | 31 +++- .../native/webscene_native_engine_worker.inc | 74 ++++++++- .../native/webscene_v8_runtime.h | 10 ++ .../native/webscene_v8_runtime_workers.inc | 157 +++++++++++++++++- 6 files changed, 328 insertions(+), 10 deletions(-) create mode 100644 docs/validation/worker-redirect-hop-policy-704-81-227.md diff --git a/docs/validation/worker-options-load-policy-701-81-227.md b/docs/validation/worker-options-load-policy-701-81-227.md index 73b90a6e..da51b675 100644 --- a/docs/validation/worker-options-load-policy-701-81-227.md +++ b/docs/validation/worker-options-load-policy-701-81-227.md @@ -50,8 +50,8 @@ measurement was executed for this change. Those gates remain acceptance work under #81 and #227. MessagePort reachability remains owned by #288, and general nested-document navigation and security remain owned by #267. -The host resource API exposes the final response rather than each intermediate -redirect response. It receives the worker fetch mode, origin, and credentials -and therefore remains responsible for enforcing every redirect hop; the worker -runtime independently validates the returned final URL and response. A future -resource-contract revision would be needed for engine-side, per-hop CORS audit. +Follow-up #704 extends the admitted-resource response with a bounded redirect +ledger. Worker loads now require that ledger when the final URL differs and +validate each hop before publishing the script. The provider remains +responsible for performing transport and reporting its credential-forwarding +decision truthfully. diff --git a/docs/validation/worker-redirect-hop-policy-704-81-227.md b/docs/validation/worker-redirect-hop-policy-704-81-227.md new file mode 100644 index 00000000..b41ead34 --- /dev/null +++ b/docs/validation/worker-redirect-hop-policy-704-81-227.md @@ -0,0 +1,56 @@ +# Worker redirect-hop policy (#704, #81, #227) + +## Admitted-resource contract + +The product-neutral resource response can now carry an ordered redirect ledger. +Each entry contains only the source URL, destination URL, redirect status, +credential-forwarding decision, admitted CORS origin, and CORS-credentials +decision. It carries no cookies, authorization values, bodies, or general +response headers. + +The public `webscene_resource_response_v4` adds the ledger as an optional tail. +Existing callbacks remain valid because the engine zero-initializes that tail; +providers compiled against the extended declaration check `struct_size` before +writing it. The native adapter accepts at most 20 hops, 8 KiB per URL/origin +field, and 64 KiB for all response headers and redirect metadata combined. +Unknown flags, unsupported redirect statuses, invalid pointers, control bytes, +and oversized metadata fail the resource load. + +## Worker enforcement + +A Worker response whose final URL differs from its requested URL must provide a +complete ledger. The first source must equal the request URL, every destination +must equal the next source, and the final destination must equal the response's +final URL. Only HTTP(S) URLs and 301, 302, 303, 307, and 308 statuses are +accepted. Repeated destinations, more than 20 hops, malformed URLs, and HTTPS +to HTTP downgrades fail closed. + +Classic worker loads continue to require the creator origin at every source and +destination. Module CORS loads validate each cross-origin redirect response's +admitted origin and require its credentials decision for `include`. A wildcard +origin is rejected for `include`. A provider may not report forwarded +credentials for `omit`, or for a cross-origin destination under `same-origin`. +The existing final-response status, origin, and CORS checks still run after the +ledger check. + +The redirect ledger is validated after the asynchronous host callback and +before response cookies, source bytes, the final dependency base, compilation, +or an error can be published. Worker generation checks on both sides of the +callback continue to suppress late completion after termination, navigation, +or engine teardown. Terminal failure remains exactly once through #701's +bounded error and slot-retirement path. + +All existing limits remain unchanged: 64 live Workers, 256 retained stopped +wrappers, 256 messages and 16 MiB per message direction, and 16 bounded error +strings. Redirect metadata adds fixed count and byte caps without adding a +queue or retained background task. + +## Deferred acceptance + +No build, native suite, redirect/CORS WPT, failure-injection run, credential +review, lifecycle stress, performance, memory, package, or cross-platform CI +was executed under the implementation-first policy. Those gates remain under +#81 and #227. The provider still owns transport and must truthfully describe +each followed hop; a future callback-style handshake would be required for the +engine to authorize a destination before the provider performs network I/O. +MessagePort reachability #288 and general iframe lifecycle #267 are unchanged. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index da451488..a54d8ea1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -1299,10 +1299,33 @@ typedef struct webscene_resource_header_v4 { size_t value_length; } webscene_resource_header_v4; +enum { + WEBSCENE_RESOURCE_REDIRECT_CREDENTIALS_FORWARDED_V4 = 1U << 0U, + WEBSCENE_RESOURCE_REDIRECT_CORS_CREDENTIALS_V4 = 1U << 1U +}; + +/* One followed network redirect. Buffers are borrowed for the callback only. + * CREDENTIALS_FORWARDED describes the request to destination_url. + * cors_allow_origin and CORS_CREDENTIALS describe the response received from + * source_url. Response headers and credentials are not copied into the + * redirect ledger. */ +typedef struct webscene_resource_redirect_hop_v4 { + uint32_t struct_size; + uint32_t status; + uint32_t flags; + const char* source_url; + size_t source_url_length; + const char* destination_url; + size_t destination_url_length; + const char* cors_allow_origin; + size_t cors_allow_origin_length; +} webscene_resource_redirect_hop_v4; + /* Response metadata is borrowed only for the callback invocation. The engine - * copies accepted fields before returning to the host. Header count and total - * bytes are bounded; Set-Cookie is consumed by the cookie jar and is never - * exposed through the JavaScript Headers object. */ + * copies accepted fields before returning to the host. Header, redirect-hop, + * and total metadata counts are bounded; Set-Cookie is consumed by the cookie + * jar and is never exposed through the JavaScript Headers object. A callback + * must check struct_size before writing the optional redirect_hops tail. */ typedef struct webscene_resource_response_v4 { uint32_t struct_size; uint32_t status; @@ -1312,6 +1335,8 @@ typedef struct webscene_resource_response_v4 { size_t final_url_length; const webscene_resource_header_v4* headers; size_t header_count; + const webscene_resource_redirect_hop_v4* redirect_hops; + size_t redirect_hop_count; } webscene_resource_response_v4; typedef struct webscene_resource_request_context_v4 { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc index 5ccb7513..7834ba43 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc @@ -238,16 +238,21 @@ constexpr size_t maximum_response_headers = 64U; constexpr size_t maximum_response_metadata_bytes = 64U * 1024U; constexpr size_t maximum_response_url_bytes = 8U * 1024U; + constexpr size_t maximum_response_redirect_hops = 20U; if (native_response.status < 100U || native_response.status > 599U || native_response.status_text_length > 1024U || native_response.final_url_length > maximum_response_url_bytes || native_response.header_count > maximum_response_headers + || native_response.redirect_hop_count + > maximum_response_redirect_hops || (native_response.status_text_length != 0U && native_response.status_text == nullptr) || (native_response.final_url_length != 0U && native_response.final_url == nullptr) || (native_response.header_count != 0U - && native_response.headers == nullptr)) { + && native_response.headers == nullptr) + || (native_response.redirect_hop_count != 0U + && native_response.redirect_hops == nullptr)) { return failed("invalid-response-metadata"); } response.status = native_response.status; @@ -294,6 +299,73 @@ response.cacheable = false; } } + response.redirect_hops.reserve( + native_response.redirect_hop_count); + constexpr uint32_t known_redirect_flags = + WEBSCENE_RESOURCE_REDIRECT_CREDENTIALS_FORWARDED_V4 + | WEBSCENE_RESOURCE_REDIRECT_CORS_CREDENTIALS_V4; + for (size_t index = 0; + index < native_response.redirect_hop_count; + ++index) { + const auto& hop = native_response.redirect_hops[index]; + if (hop.struct_size + < sizeof(webscene_resource_redirect_hop_v4) + || (hop.status != 301U && hop.status != 302U + && hop.status != 303U && hop.status != 307U + && hop.status != 308U) + || (hop.flags & ~known_redirect_flags) != 0U + || hop.source_url == nullptr + || hop.source_url_length == 0U + || hop.source_url_length + > maximum_response_url_bytes + || hop.destination_url == nullptr + || hop.destination_url_length == 0U + || hop.destination_url_length + > maximum_response_url_bytes + || hop.cors_allow_origin_length + > maximum_response_url_bytes + || (hop.cors_allow_origin_length != 0U + && hop.cors_allow_origin == nullptr)) { + return failed("invalid-redirect-metadata"); + } + metadata_bytes += hop.source_url_length + + hop.destination_url_length + + hop.cors_allow_origin_length; + if (metadata_bytes > maximum_response_metadata_bytes) { + return failed("response-metadata-too-large"); + } + webscene_native::v8_dom_runtime::resource_redirect_hop copied; + copied.source_url.assign( + hop.source_url, hop.source_url_length); + copied.destination_url.assign( + hop.destination_url, hop.destination_url_length); + copied.cors_allow_origin.assign( + hop.cors_allow_origin == nullptr + ? "" + : hop.cors_allow_origin, + hop.cors_allow_origin_length); + const auto invalid_url = [](const std::string& value) { + return value.find_first_of(" \t\r\n") + != std::string::npos + || value.find('\0') != std::string::npos; + }; + if (invalid_url(copied.source_url) + || invalid_url(copied.destination_url) + || copied.cors_allow_origin.find_first_of("\r\n") + != std::string::npos + || copied.cors_allow_origin.find('\0') + != std::string::npos) { + return failed("invalid-redirect-metadata"); + } + copied.status = hop.status; + copied.credentials_forwarded = (hop.flags + & WEBSCENE_RESOURCE_REDIRECT_CREDENTIALS_FORWARDED_V4) + != 0U; + copied.cors_credentials_allowed = (hop.flags + & WEBSCENE_RESOURCE_REDIRECT_CORS_CREDENTIALS_V4) + != 0U; + response.redirect_hops.push_back(std::move(copied)); + } } else { response.final_url = url; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h index 67e52acc..e07cd0db 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -315,6 +315,15 @@ class v8_dom_runtime final { uint32_t preferred_color_scheme{WEBSCENE_PREFERRED_COLOR_SCHEME_LIGHT}; }; + struct resource_redirect_hop final { + std::string source_url; + std::string destination_url; + std::string cors_allow_origin; + uint32_t status{}; + bool credentials_forwarded{}; + bool cors_credentials_allowed{}; + }; + struct resource_response final { std::string content; std::string entity_tag; @@ -326,6 +335,7 @@ class v8_dom_runtime final { std::string status_text{"OK"}; std::string final_url; std::vector> headers; + std::vector redirect_hops; bool has_http_metadata{false}; }; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_workers.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_workers.inc index 5f409ddb..30232d81 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_workers.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_workers.inc @@ -47,6 +47,11 @@ static constexpr size_t maximum_worker_queue_bytes = 16U * 1024U * 1024U; static constexpr size_t maximum_worker_errors = 16U; static constexpr size_t maximum_worker_error_length = 4096U; + static constexpr size_t maximum_worker_redirect_hops = 20U; + static constexpr size_t maximum_worker_redirect_url_bytes = 8U * 1024U; + static constexpr size_t maximum_worker_redirect_metadata_bytes = 64U * 1024U; + static constexpr size_t maximum_worker_response_headers = 64U; + static constexpr size_t maximum_worker_status_text_bytes = 1024U; bool worker_generation_is_current() const { @@ -56,6 +61,141 @@ && worker_owner->generation == worker_owner_generation; } + bool validate_worker_redirect_hops( + const std::string& requested_url, + const std::string& final_url, + uint32_t mode, + uint32_t credentials, + const std::string& origin, + const resource_response& response, + std::string& error) const + { + if (response.headers.size() > maximum_worker_response_headers + || response.status_text.size() > maximum_worker_status_text_bytes + || final_url.size() > maximum_worker_redirect_url_bytes) { + error = "Worker response metadata is invalid"; + return false; + } + size_t metadata_bytes = response.status_text.size() + final_url.size(); + for (const auto& [name, value] : response.headers) { + if (name.empty() || name.find('\0') != std::string::npos + || name.find_first_of("\r\n") != std::string::npos + || value.find('\0') != std::string::npos + || value.find_first_of("\r\n") != std::string::npos + || name.size() + > maximum_worker_redirect_metadata_bytes - metadata_bytes + || value.size() > maximum_worker_redirect_metadata_bytes + - metadata_bytes - name.size()) { + error = "Worker response metadata is invalid"; + return false; + } + metadata_bytes += name.size() + value.size(); + } + if (response.redirect_hops.empty()) { + if (final_url != requested_url) { + error = "Worker redirect chain metadata is required"; + return false; + } + return true; + } + if (!response.has_http_metadata) { + error = "Worker redirect metadata requires an HTTP response"; + return false; + } + if (response.redirect_hops.size() > maximum_worker_redirect_hops) { + error = "Worker redirect limit exceeded"; + return false; + } + const auto valid_network_url = [](const std::string& url) { + const auto scheme = resource_scheme(url); + return (scheme == "http" || scheme == "https") + && !resource_origin(url).empty() + && std::none_of( + url.begin(), url.end(), [](unsigned char value) { + return value <= 0x20U || value == 0x7fU; + }); + }; + const auto redirect_status = [](uint32_t status) { + return status == 301U || status == 302U || status == 303U + || status == 307U || status == 308U; + }; + std::vector visited{requested_url}; + auto expected_source = std::string_view(requested_url); + for (const auto& hop : response.redirect_hops) { + if (hop.source_url.size() > maximum_worker_redirect_url_bytes + || hop.destination_url.size() > maximum_worker_redirect_url_bytes + || hop.cors_allow_origin.size() > maximum_worker_redirect_url_bytes) { + error = "Worker redirect metadata is invalid"; + return false; + } + const auto hop_bytes = hop.source_url.size() + + hop.destination_url.size() + + hop.cors_allow_origin.size(); + if (hop_bytes > maximum_worker_redirect_metadata_bytes + - metadata_bytes) { + error = "Worker redirect metadata is invalid"; + return false; + } + metadata_bytes += hop_bytes; + if (hop.source_url != expected_source + || !redirect_status(hop.status) + || !valid_network_url(hop.source_url) + || !valid_network_url(hop.destination_url)) { + error = "Worker redirect metadata is invalid"; + return false; + } + if (std::find( + visited.begin(), + visited.end(), + std::string_view(hop.destination_url)) + != visited.end()) { + error = "Worker redirect loop rejected"; + return false; + } + if (resource_scheme(hop.source_url) == "https" + && resource_scheme(hop.destination_url) != "https") { + error = "Worker redirect downgrade rejected"; + return false; + } + const auto source_origin = resource_origin(hop.source_url); + const auto destination_origin = resource_origin( + hop.destination_url); + if (mode == WEBSCENE_FETCH_MODE_SAME_ORIGIN + && (source_origin != origin + || destination_origin != origin)) { + error = "Worker redirect violates same-origin policy"; + return false; + } + if (hop.credentials_forwarded + && (credentials == WEBSCENE_FETCH_CREDENTIALS_OMIT + || (credentials == WEBSCENE_FETCH_CREDENTIALS_SAME_ORIGIN + && destination_origin != origin))) { + error = "Worker redirect forwarded disallowed credentials"; + return false; + } + if (mode == WEBSCENE_FETCH_MODE_CORS + && source_origin != origin) { + const auto cors_allowed = hop.cors_allow_origin == origin + || (hop.cors_allow_origin == "*" + && credentials + != WEBSCENE_FETCH_CREDENTIALS_INCLUDE); + if (!cors_allowed + || (credentials == WEBSCENE_FETCH_CREDENTIALS_INCLUDE + && !hop.cors_credentials_allowed)) { + error = "Worker redirect response blocked by CORS"; + return false; + } + } + visited.push_back(hop.destination_url); + expected_source = hop.destination_url; + } + if (expected_source != final_url) { + error = "Worker redirect chain is incomplete"; + return false; + } + return true; + } + bool load_worker_script_resource( const std::string& value, const std::string& base, @@ -75,6 +215,11 @@ const auto credentials = worker_script_fetch_policy_active ? worker_script_fetch_credentials : WEBSCENE_FETCH_CREDENTIALS_SAME_ORIGIN; + if (resource_scheme(origin) == "https" + && resource_scheme(requested_url) == "http") { + error = "Worker script security downgrade rejected"; + return false; + } if (mode == WEBSCENE_FETCH_MODE_SAME_ORIGIN && resource_origin(requested_url) != origin) { error = "Worker script violates same-origin policy: " + requested_url; @@ -138,7 +283,6 @@ error = "Unable to load worker script: " + requested_url; return false; } - accept_response_cookies(requested_url, request_context, response); resolved_name = response.final_url.empty() ? requested_url : response.final_url; @@ -147,6 +291,16 @@ error = "Worker script response has an unsuccessful status"; return false; } + if (!validate_worker_redirect_hops( + requested_url, + resolved_name, + mode, + credentials, + origin, + response, + error)) { + return false; + } const auto final_origin = resource_origin(resolved_name); if (mode == WEBSCENE_FETCH_MODE_SAME_ORIGIN @@ -178,6 +332,7 @@ return false; } } + accept_response_cookies(requested_url, request_context, response); content = std::move(response.content); return true; }