blob: 61bc7b18536ad44270d9135fec994882073d9186 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "base/strings/stringprintf.h"
#include "content/browser/loader/keep_alive_request_browsertest_util.h"
#include "content/browser/loader/keep_alive_url_loader_service.h"
#include "content/browser/web_contents/web_contents_impl.h"
#include "content/public/test/back_forward_cache_util.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/keep_alive_url_loader_utils.h"
#include "net/test/embedded_test_server/controllable_http_response.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/common/features.h"
#include "url/gurl.h"
namespace content {
class SendBeaconBrowserTestBase : public KeepAliveRequestBrowserTestBase {
protected:
virtual std::string beacon_payload_type() const = 0;
const FeaturesType& GetEnabledFeatures() override {
static const FeaturesType enabled_features =
GetDefaultEnabledBackForwardCacheFeaturesForTesting(
{{blink::features::kKeepAliveInBrowserMigration, {}}});
return enabled_features;
}
GURL GetBeaconPageURL(
const GURL& beacon_url,
bool with_non_cors_safelisted_content,
std::optional<int> delay_iframe_removal_ms = std::nullopt) const {
std::vector<std::string> queries = {
"/send-beacon-in-iframe.html?url=" + EncodeURL(beacon_url),
"&payload_type=" + beacon_payload_type()};
if (with_non_cors_safelisted_content) {
// Setting the payload's content type to `application/octet-stream`, as
// only `application/x-www-form-urlencoded`, `multipart/form-data`, and
// `text/plain` MIME types are allowed for CORS-safelisted `content-type`
// request header.
// https://fetch.spec.whatwg.org/#cors-safelisted-request-header
queries.push_back("&payload_content_type=application/octet-stream");
}
if (delay_iframe_removal_ms.has_value()) {
queries.push_back(base::StringPrintf("&delay_iframe_removal_ms=%d",
delay_iframe_removal_ms.value()));
}
return server()->GetURL(kPrimaryHost, base::StrCat(queries));
}
// Navigates to a page that calls `navigator.sendBeacon(beacon_url)` from a
// programmatically created iframe. The iframe will then be removed after the
// JS call after an optional `delay_iframe_removal_ms` interval.
// `request_handler` must handle the final URL of the sendBeacon request.
void LoadPageWithIframeAndSendBeacon(
const GURL& beacon_url,
net::test_server::ControllableHttpResponse* request_handler,
const std::string& response,
int expect_total_redirects,
std::optional<int> delay_iframe_removal_ms = std::nullopt) {
// Navigate to the page that calls sendBeacon with `beacon_url` from an
// appended iframe.
ASSERT_TRUE(NavigateToURL(
web_contents(),
GetBeaconPageURL(beacon_url,
/*with_non_cors_safelisted_content=*/false,
delay_iframe_removal_ms)));
ASSERT_EQ(loader_service()->NumLoadersForTesting(), 1u);
// All redirects, if exist, should be processed in browser first.
loaders_observer().WaitForTotalOnReceiveRedirectProcessed(
expect_total_redirects);
// After in-browser processing, the loader should remain alive to support
// forwarding stored redirects/response to renderer. But it may or may not
// connect to a renderer.
EXPECT_EQ(loader_service()->NumLoadersForTesting(), 1u);
// Ensure the sendBeacon request is sent.
request_handler->WaitForRequest();
// Send back final response to terminate in-browser request handling.
request_handler->Send(response);
request_handler->Done();
// After in-browser redirect/response processing, the in-browser logic may
// or may not forward redirect/response to renderer process, depending on
// whether the renderer is still alive.
loaders_observer().WaitForTotalOnReceiveResponse(1);
// OnComplete may not be called if the renderer dies too early in before
// receiving response.
// The loader should all be gone.
EXPECT_EQ(loader_service()->NumLoadersForTesting(), 0u);
}
};
class SendBeaconBrowserTest
: public SendBeaconBrowserTestBase,
public ::testing::WithParamInterface<std::string> {
protected:
std::string beacon_payload_type() const override { return GetParam(); }
};
INSTANTIATE_TEST_SUITE_P(
All,
SendBeaconBrowserTest,
::testing::Values("string", "arraybuffer", "form", "blob"),
[](const testing::TestParamInfo<SendBeaconBrowserTest::ParamType>& info) {
return info.param;
});
// Tests navigator.sendBeacon() with a cross-origin & CORS-safelisted request
// that causes a redirect chain of 4 URLs.
//
// The JS call happens in an iframe that is removed right after the sendBeacon()
// call, so the chain of redirects & response handling must survive the iframe
// unload.
// TODO(crbug.com/412499381): Re-enable this test.
IN_PROC_BROWSER_TEST_P(SendBeaconBrowserTest,
DISABLED_MultipleRedirectsRequestWithIframeRemoval) {
const auto beacon_endpoint =
base::StringPrintf("%s?id=%s", kKeepAliveEndpoint, kBeaconId);
auto request_handler =
std::move(RegisterRequestHandlers({beacon_endpoint})[0]);
ASSERT_TRUE(server()->Start());
// Set up a cross-origin (kSecondaryHost) URL with CORS-safelisted
// payload that causes multiple redirects.
const auto target_url = server()->GetURL(kSecondaryHost, beacon_endpoint);
const auto beacon_url = GetCrossOriginMultipleRedirectsURL(target_url);
LoadPageWithIframeAndSendBeacon(beacon_url, request_handler.get(),
k200TextResponse,
/*expect_total_redirects=*/3);
}
// Tests navigator.sendBeacon() with a cross-origin & CORS-safelisted request
// that causes a redirect chain of 4 URLs.
//
// Unlike the `MultipleRedirectsRequestWithIframeRemoval` test case above, the
// request here is fired within an iframe that will be removed shortly
// (delayed by 0ms, roughly in the JS next event cycle).
// This is to mimic the following scenario:
//
// 1. The server returns a redirect.
// 2. In the browser process KeepAliveURLLoader::OnReceiveRedirect(),
// forwarding_client_ is not null (as renderer/iframe still exists), so it
// calls forwarding_client_->OnReceiveRedirect() IPC to forward to renderer.
// 3. The renderer process is somehow shut down before its
// URLLoaderClient::OnReceiveRedirect() is finished, so the redirect chain is
// incompleted.
// 4. KeepAliveURLLoader::OnRendererConnectionError() is triggered, and only
// aware of forwarding_client_'s disconnection. It should take over redirect
// chain handling.
//
// Without delaying iframe removal, renderer disconnection may happen in between
// (2) and (3).
// TODO(crbug.com/412499381): Re-enable this test.
IN_PROC_BROWSER_TEST_P(
SendBeaconBrowserTest,
DISABLED_MultipleRedirectsRequestWithDelayedIframeRemoval) {
const auto beacon_endpoint =
base::StringPrintf("%s?id=%s", kKeepAliveEndpoint, kBeaconId);
auto request_handler =
std::move(RegisterRequestHandlers({beacon_endpoint})[0]);
ASSERT_TRUE(server()->Start());
// Set up a cross-origin (kSecondaryHost) URL with CORS-safelisted
// payload that causes multiple redirects.
const auto target_url = server()->GetURL(kSecondaryHost, beacon_endpoint);
const auto beacon_url = GetCrossOriginMultipleRedirectsURL(target_url);
LoadPageWithIframeAndSendBeacon(beacon_url, request_handler.get(),
k200TextResponse,
/*expect_total_redirects=*/3,
/*delay_iframe_removal_ms=*/0);
}
// Tests navigator.sendBeacon() with a cross-origin & CORS-safelisted request
// that redirects from url1 to url2. The redirect is handled by a server
// endpoint (/no-cors-server-redirect-307) which does not support CORS.
// As navigator.sendBeacon() marks its request with `no-cors`, the redirect
// should succeed.
// TODO(crbug.com/412499381): Re-enable this test.
IN_PROC_BROWSER_TEST_P(SendBeaconBrowserTest,
DISABLED_CrossOriginAndCORSSafelistedRedirectRequest) {
const auto beacon_endpoint =
base::StringPrintf("%s?id=%s", kKeepAliveEndpoint, kBeaconId);
auto request_handler =
std::move(RegisterRequestHandlers({beacon_endpoint})[0]);
ASSERT_TRUE(server()->Start());
// Set up a cross-origin (kSecondaryHost) redirect with CORS-safelisted
// payload according to the following redirect chain:
// navigator.sendBeacon(
// "http://b.test:<port>/no-cors-server-redirect-307?...",
// <CORS-safelisted payload>)
// --> http://b.test:<port>/beacon?id=beacon01
const auto target_url = server()->GetURL(kSecondaryHost, beacon_endpoint);
const auto beacon_url = server()->GetURL(
kSecondaryHost, base::StringPrintf("/no-cors-server-redirect-307?%s",
EncodeURL(target_url).c_str()));
LoadPageWithIframeAndSendBeacon(beacon_url, request_handler.get(),
k200TextResponse,
/*expect_total_redirects=*/1);
}
class SendBeaconBlobBrowserTest : public SendBeaconBrowserTestBase {
protected:
std::string beacon_payload_type() const override { return "blob"; }
};
// Tests navigator.sendBeacon() with a cross-origin & non-CORS-safelisted
// request that redirects from url1 to url2. The redirect is handled by a server
// endpoint (/no-cors-server-redirect-307) which does not support CORS.
// As navigator.sendBeacon() marks its request with `no-cors`, the redirect
// should fail.
IN_PROC_BROWSER_TEST_F(SendBeaconBlobBrowserTest,
CrossOriginAndNonCORSSafelistedRedirectRequest) {
const auto beacon_endpoint =
base::StringPrintf("%s?id=%s", kKeepAliveEndpoint, kBeaconId);
auto request_handler =
std::move(RegisterRequestHandlers({beacon_endpoint})[0]);
ASSERT_TRUE(server()->Start());
// Set up a cross-origin (kSecondaryHost) redirect with non-CORS-safelisted
// payload according to the following redirect chain:
// navigator.sendBeacon(
// "http://b.test:<port>/no-cors-server-redirect-307?...",
// <non-CORS-safelisted payload>) => should fail here
// --> http://b.test:<port>/beacon?id=beacon01
const auto target_url = server()->GetURL(kSecondaryHost, beacon_endpoint);
const auto beacon_url = server()->GetURL(
kSecondaryHost, base::StringPrintf("/no-cors-server-redirect-307?%s",
EncodeURL(target_url).c_str()));
// Navigate to the page that calls sendBeacon with `beacon_url` from an
// appended iframe, which will be removed shortly after calling sendBeacon().
ASSERT_TRUE(NavigateToURL(
web_contents(),
GetBeaconPageURL(beacon_url, /*with_non_cors_safelisted_content=*/true)));
// The redirect is rejected in-browser during redirect (with
// non-CORS-safelisted payload) handling because /no-cors-server-redirect-xxx
// doesn't support CORS. Thus, KeepAliveURLLoader::OnReceiveRedirect() is not
// called but KeepAliveURLLoader::OnComplete().
// Note that renderer can be gone at any point before or after the first URL
// is loaded. So OnComplete() may or may not be forwarded.
loaders_observer().WaitForTotalOnComplete({net::ERR_FAILED});
EXPECT_FALSE(request_handler->has_received_request());
// After in-browser processing, the loader should all be gone.
EXPECT_EQ(loader_service()->NumDisconnectedLoadersForTesting(), 0u);
EXPECT_EQ(loader_service()->NumLoadersForTesting(), 0u);
ExpectFetchKeepAliveHistogram(
FetchKeepAliveRequestMetricType::kBeacon,
ExpectedTotalRequests(/*browser=*/1, /*renderer=*/1),
ExpectedStartedRequests(/*browser=*/1, /*renderer=*/1),
ExpectedSucceededRequests(/*browser=*/0, /*renderer=*/0),
ExpectedFailedRequests(/*browser=*/1, /*renderer=*/1));
}
} // namespace content