//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // ClientHTTP1::Fetch follows redirects by RFC 9110 §15.4: // - a relative Location is resolved against the URL that sent it (RFC // 3986, `..` included), and a redirect may move from http:// to https://; // - 301, 302, 303, 307 and 308 are followed for every method: 303 turns // the request into a GET without its body, 301 and 302 do so for a POST // alone, 307 and 308 keep the method and the body; // - a redirect without a Location, or to another scheme, comes back as the // response, with `stop` saying why; // - a chain stops at a URL it has already been sent to, or after // maxRedirects, returning that redirect; // - Authorization and Cookie are not carried to another origin; // - a URL that is not http(s), has no host or has a bad port is refused. // // Two listeners: plaintext on 8130, TLS on 8131 with a self-signed certificate // the client is handed as its trust anchor. The cross-origin case goes from // 127.0.0.1 to localhost, the same listener under another name. #include import Crafter.Network; import std; using namespace Crafter; namespace { int failures = 0; void Check(bool ok, std::string_view what) { std::println("{} {}", ok ? "PASS" : "FAIL", what); if (!ok) ++failures; } HTTPResponse Redirect(std::string status, std::string location) { return CreateResponseHTTP(std::move(status), {{"location", std::move(location)}}, std::string()); } std::unordered_map> Routes() { std::unordered_map> routes; routes["/start"] = [](const HTTPRequest&) { return Redirect("302", "next"); }; routes["/next"] = [](const HTTPRequest&) { return Redirect("301", "/final?x=1"); }; routes["/a/b/start"] = [](const HTTPRequest&) { return Redirect("302", "../c/d"); }; routes["/to-https"] = [](const HTTPRequest&) { return Redirect("307", "https://localhost:8131/secure"); }; routes["/loop"] = [](const HTTPRequest&) { return Redirect("302", "/loop"); }; routes["/nowhere"] = [](const HTTPRequest&) { return CreateResponseHTTP("302", std::string("no location")); }; routes["/elsewhere"] = [](const HTTPRequest&) { return Redirect("302", "ftp://127.0.0.1/file"); }; routes["/form"] = [](const HTTPRequest&) { return Redirect("303", "/method"); }; routes["/found"] = [](const HTTPRequest&) { return Redirect("302", "/method"); }; routes["/keep"] = [](const HTTPRequest&) { return Redirect("307", "/method"); }; routes["/method"] = [](const HTTPRequest& request) { return CreateResponseHTTP("200", request.method + " body=" + request.body + " type=" + (request.headers.contains("content-type") ? request.headers.at("content-type") : "none")); }; routes["/auth-away"] = [](const HTTPRequest&) { return Redirect("302", "http://localhost:8130/show-auth"); }; routes["/auth-home"] = [](const HTTPRequest&) { return Redirect("302", "/show-auth"); }; routes["/show-auth"] = [](const HTTPRequest& request) { return CreateResponseHTTP("200", std::string("authorization=") + (request.headers.contains("authorization") ? "yes" : "no") + " cookie=" + (request.headers.contains("cookie") ? "yes" : "no") + " accept=" + (request.headers.contains("accept") ? "yes" : "no")); }; return routes; } // Everything else echoes its target, and /hop/ redirects to /hop/. HTTPResponse Fallback(const HTTPRequest& request) { if (request.path.starts_with("/hop/")) { const std::int32_t next = std::stoi(request.path.substr(5)) + 1; return Redirect("302", std::format("/hop/{}", next)); } return CreateResponseHTTP("200", request.path); } bool Refused(std::string_view url) { try { ClientHTTP1::Fetch(url); } catch (const std::invalid_argument&) { return true; } catch (const std::exception&) { } return false; } } // namespace int main() { try { ListenerAsyncHTTP1 plain(8130, Routes(), Fallback); std::unordered_map> secureRoutes; secureRoutes["/secure"] = [](const HTTPRequest& request) { return CreateResponseHTTP("200", "secure over " + request.scheme); }; ListenerAsyncHTTP1 secure(8131, secureRoutes, TLSServerCredentials { .selfSigned = true }); { const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/start"); Check(result.response.status == "200" && result.response.body == "/final?x=1", std::format("a relative chain arrives at /final?x=1 (got {} '{}')", result.response.status, result.response.body)); Check(result.url.ToString() == "http://127.0.0.1:8130/final?x=1", std::format("the URL that answered is reported ({})", result.url.ToString())); Check(result.redirects == 2 && result.stop == FetchStop::None, std::format("two redirects, the chain ended on a final response (got {})", result.redirects)); } { const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/a/b/start"); Check(result.response.body == "/a/c/d", std::format("../c/d from /a/b/start resolves to /a/c/d (got '{}')", result.response.body)); } { FetchOptions options; options.credentials.caPem = GetSelfSignedCertificatePem().certificate; const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/to-https", options); Check(result.response.status == "200" && result.response.body == "secure over https", std::format("a redirect from http:// to https:// is followed (got {} '{}')", result.response.status, result.response.body)); Check(result.url.ToString() == "https://localhost:8131/secure" && result.url.Secure(), std::format("and lands on the https:// URL ({})", result.url.ToString())); } { const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/loop"); Check(result.stop == FetchStop::Loop && result.response.status == "302" && result.redirects == 0, std::format("a redirect back to a URL already visited stops the chain at once (stop {}, {} redirects)", static_cast(result.stop), result.redirects)); } { const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/hop/0"); Check(result.stop == FetchStop::Limit && result.redirects == 10 && result.url.ToString() == "http://127.0.0.1:8130/hop/10", std::format("an endless chain stops after maxRedirects, 10 ({} redirects, at {})", result.redirects, result.url.ToString())); FetchOptions two; two.maxRedirects = 2; const FetchResult shorter = ClientHTTP1::Fetch("http://127.0.0.1:8130/hop/0", two); Check(shorter.stop == FetchStop::Limit && shorter.redirects == 2, "maxRedirects is the option's"); } { const FetchResult result = ClientHTTP1::Fetch("http://127.0.0.1:8130/nowhere"); Check(result.stop == FetchStop::NoLocation && result.response.status == "302" && result.redirects == 0, "a redirect without a Location comes back as the response"); const FetchResult other = ClientHTTP1::Fetch("http://127.0.0.1:8130/elsewhere"); Check(other.stop == FetchStop::OtherScheme && other.response.status == "302", "a redirect to another scheme comes back as the response"); } { FetchOptions post; post.method = "POST"; post.body = "payload"; post.headers["content-type"] = "text/plain"; const FetchResult seeOther = ClientHTTP1::Fetch("http://127.0.0.1:8130/form", post); Check(seeOther.response.body == "GET body= type=none", std::format("a POST answered with 303 is followed as a GET without its body or content headers (got '{}')", seeOther.response.body)); const FetchResult found = ClientHTTP1::Fetch("http://127.0.0.1:8130/found", post); Check(found.response.body == "GET body= type=none", std::format("a POST answered with 302 is followed as a GET (got '{}')", found.response.body)); const FetchResult kept = ClientHTTP1::Fetch("http://127.0.0.1:8130/keep", post); Check(kept.response.body == "POST body=payload type=text/plain" && kept.redirects == 1, std::format("a POST answered with 307 is followed with its method, body and content headers (got '{}')", kept.response.body)); FetchOptions put = post; put.method = "PUT"; const FetchResult putFound = ClientHTTP1::Fetch("http://127.0.0.1:8130/found", put); Check(putFound.response.body == "PUT body=payload type=text/plain", std::format("a PUT answered with 302 keeps its method and body (got '{}')", putFound.response.body)); } { FetchOptions options; options.headers["authorization"] = "Bearer secret"; options.headers["cookie"] = "a=b"; options.headers["accept"] = "text/plain"; const FetchResult away = ClientHTTP1::Fetch("http://127.0.0.1:8130/auth-away", options); Check(away.response.body == "authorization=no cookie=no accept=yes", std::format("Authorization and Cookie are dropped on a redirect to another origin, other headers kept (got '{}')", away.response.body)); const FetchResult home = ClientHTTP1::Fetch("http://127.0.0.1:8130/auth-home", options); Check(home.response.body == "authorization=yes cookie=yes accept=yes", std::format("and kept on a redirect within the origin (got '{}')", home.response.body)); } Check(Refused("ftp://127.0.0.1/file"), "an ftp:// URL is refused"); Check(Refused("http:///path"), "a URL without a host is refused"); Check(Refused("http://127.0.0.1:99999/"), "a port above 65535 is refused"); Check(Refused("http://127.0.0.1:8x/"), "a port that is not a number is refused"); } catch (const std::exception& e) { Check(false, std::format("unexpected exception: {}", e.what())); } std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; }