Crafter.Network/tests/ShouldFollowRedirectsHTTP1/main.cpp
Jorijn van der Graaf 7ee938c98e
feat(http1): ClientHTTP1::Fetch follows redirects by the RFC, on a URL type
One request to an http(s) URL with redirects followed by RFC 9110 §15.4:
301, 302, 303, 307 and 308 for every method; a 303 makes the request a
GET without its body and content headers, a 301 or 302 does so for a POST
alone, a 307 or 308 keeps the method and the body. The Location is
resolved against the sending URL by RFC 3986, a hop between http and
https is allowed, Authorization, Proxy-Authorization and Cookie are
dropped on a change of origin, and each hop is a client of its own, since
one ClientHTTP1 is one origin. A redirect without a usable Location, to
another scheme, back to a URL already visited or past maxRedirects comes
back as the response, with `stop` saying why.

The new :URL partition is the URL type behind it, exported: Parse,
Resolve (RFC 3986 §5.2; its §5.4 examples are the test), ToString, and
the pieces a request is built from.

This replaces the FetchHTTP1 free function, which followed redirects the
way Python's urllib did (its repeat limit, its quoting of the Location, a
POST turned back on a 307) for a port that no longer needs the fidelity.

Breaking: FetchHTTP1, FetchOptionsHTTP1 and FetchResultHTTP1 are gone;
use ClientHTTP1::Fetch, FetchOptions and FetchResult, whose `url` is a
URL and whose `limited` became `stop`.
2026-10-31 20:39:43 +01:00

183 lines
10 KiB
C++

//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 <cstdlib>
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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> Routes() {
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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/<n> redirects to /hop/<n+1>.
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<std::string, std::function<HTTPResponse(const HTTPRequest&)>> 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<int>(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;
}