feat: add PKCE OAuth boundary
Introduce provider-neutral HTTP contracts and public-client authorization-code orchestration with PKCE S256, strict HTTPS and loopback validation, constant-time state checks, secure OpenSSL entropy, and single-send token exchange semantics. Add independent protocol and adversarial suites covering RFC 7636, RFC3986 encoding, callback failures, response redaction, and no-retry behavior. Concrete networking, browser/listener, token parsing, Secret Service, and Graph integration remain separate follow-up slices.
This commit is contained in:
575
tests/oauth_security_tests.cpp
Normal file
575
tests/oauth_security_tests.cpp
Normal file
@@ -0,0 +1,575 @@
|
||||
#include "nocal/sync/oauth.hpp"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <map>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using nocal::sync::AuthorizationCallback;
|
||||
using nocal::sync::AuthorizationCallbackReceiver;
|
||||
using nocal::sync::BrowserLauncher;
|
||||
using nocal::sync::EntropySource;
|
||||
using nocal::sync::HttpMethod;
|
||||
using nocal::sync::HttpRequest;
|
||||
using nocal::sync::HttpResponse;
|
||||
using nocal::sync::HttpTransport;
|
||||
using nocal::sync::OAuthClientConfig;
|
||||
using nocal::sync::OAuthError;
|
||||
using nocal::sync::authorize_with_pkce;
|
||||
|
||||
void require(bool condition, std::string_view message) {
|
||||
if (!condition) {
|
||||
throw std::runtime_error(std::string{message});
|
||||
}
|
||||
}
|
||||
|
||||
template <typename Function>
|
||||
void require_throws(Function&& function, std::string_view message) {
|
||||
try {
|
||||
function();
|
||||
} catch (const std::exception&) {
|
||||
return;
|
||||
}
|
||||
throw std::runtime_error(std::string{message});
|
||||
}
|
||||
|
||||
template <typename Function>
|
||||
void require_oauth_error(
|
||||
Function&& function, OAuthError::Kind kind, std::string_view message) {
|
||||
try {
|
||||
function();
|
||||
} catch (const OAuthError& error) {
|
||||
require(error.kind() == kind, std::string{message}.append(": wrong OAuth error kind"));
|
||||
return;
|
||||
} catch (const std::exception&) {
|
||||
throw std::runtime_error(std::string{message}.append(": wrong exception type"));
|
||||
}
|
||||
throw std::runtime_error(std::string{message}.append(": no exception"));
|
||||
}
|
||||
|
||||
[[nodiscard]] int hexadecimal_value(char value) {
|
||||
if (value >= '0' && value <= '9') {
|
||||
return value - '0';
|
||||
}
|
||||
if (value >= 'a' && value <= 'f') {
|
||||
return value - 'a' + 10;
|
||||
}
|
||||
if (value >= 'A' && value <= 'F') {
|
||||
return value - 'A' + 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string percent_decode(std::string_view input, bool plus_is_space) {
|
||||
std::string output;
|
||||
output.reserve(input.size());
|
||||
for (std::size_t index = 0; index < input.size(); ++index) {
|
||||
if (input[index] == '%' && index + 2 < input.size()) {
|
||||
const int high = hexadecimal_value(input[index + 1]);
|
||||
const int low = hexadecimal_value(input[index + 2]);
|
||||
require(high >= 0 && low >= 0, "request contains malformed percent encoding");
|
||||
output.push_back(static_cast<char>((high << 4) | low));
|
||||
index += 2;
|
||||
} else if (input[index] == '+' && plus_is_space) {
|
||||
output.push_back(' ');
|
||||
} else {
|
||||
output.push_back(input[index]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
using Parameters = std::multimap<std::string, std::string>;
|
||||
|
||||
[[nodiscard]] Parameters parse_parameters(std::string_view encoded, bool plus_is_space) {
|
||||
Parameters result;
|
||||
std::size_t offset = 0;
|
||||
while (offset <= encoded.size()) {
|
||||
const std::size_t separator = encoded.find('&', offset);
|
||||
const std::string_view field = encoded.substr(
|
||||
offset, separator == std::string_view::npos ? encoded.size() - offset
|
||||
: separator - offset);
|
||||
const std::size_t equals = field.find('=');
|
||||
const std::string_view name = field.substr(0, equals);
|
||||
const std::string_view value = equals == std::string_view::npos
|
||||
? std::string_view{}
|
||||
: field.substr(equals + 1);
|
||||
result.emplace(
|
||||
percent_decode(name, plus_is_space), percent_decode(value, plus_is_space));
|
||||
if (separator == std::string_view::npos) {
|
||||
break;
|
||||
}
|
||||
offset = separator + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[[nodiscard]] Parameters query_parameters(const std::string& url) {
|
||||
const std::size_t question = url.find('?');
|
||||
require(question != std::string::npos, "authorization URL has no query");
|
||||
const std::size_t fragment = url.find('#', question + 1);
|
||||
return parse_parameters(std::string_view{url}.substr(question + 1,
|
||||
fragment == std::string::npos ? std::string::npos
|
||||
: fragment - question - 1),
|
||||
false);
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::string& only_value(
|
||||
const Parameters& parameters, std::string_view name) {
|
||||
const std::string owned_name{name};
|
||||
const auto [begin, end] = parameters.equal_range(owned_name);
|
||||
require(begin != end, std::string{"missing parameter: "}.append(name));
|
||||
const auto next = std::next(begin);
|
||||
require(next == end, std::string{"duplicate parameter: "}.append(name));
|
||||
return begin->second;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string base64url(std::span<const unsigned char> input) {
|
||||
const std::size_t encoded_size = 4 * ((input.size() + 2) / 3);
|
||||
std::string encoded(encoded_size, '\0');
|
||||
const int result = EVP_EncodeBlock(reinterpret_cast<unsigned char*>(encoded.data()),
|
||||
input.data(), static_cast<int>(input.size()));
|
||||
require(result >= 0, "OpenSSL base64 encoding failed in test");
|
||||
encoded.resize(static_cast<std::size_t>(result));
|
||||
std::replace(encoded.begin(), encoded.end(), '+', '-');
|
||||
std::replace(encoded.begin(), encoded.end(), '/', '_');
|
||||
while (!encoded.empty() && encoded.back() == '=') {
|
||||
encoded.pop_back();
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string s256_challenge(std::string_view verifier) {
|
||||
std::array<unsigned char, 32> digest{};
|
||||
unsigned int size = 0;
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new();
|
||||
require(context != nullptr, "OpenSSL digest allocation failed in test");
|
||||
struct FreeContext {
|
||||
EVP_MD_CTX* context;
|
||||
~FreeContext() { EVP_MD_CTX_free(context); }
|
||||
} free_context{context};
|
||||
require(EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1
|
||||
&& EVP_DigestUpdate(context, verifier.data(), verifier.size()) == 1
|
||||
&& EVP_DigestFinal_ex(context, digest.data(), &size) == 1
|
||||
&& size == digest.size(),
|
||||
"OpenSSL SHA-256 failed in test");
|
||||
return base64url(digest);
|
||||
}
|
||||
|
||||
class FakeEntropy final : public EntropySource {
|
||||
public:
|
||||
void fill(std::span<std::byte> output) override {
|
||||
sizes.push_back(output.size());
|
||||
const std::size_t call = sizes.size() - 1;
|
||||
for (std::size_t index = 0; index < output.size(); ++index) {
|
||||
const auto value = static_cast<unsigned char>(
|
||||
call == 0 || identical
|
||||
? (0x11U + index * 3U) & 0xffU
|
||||
: (0xd7U - index * 5U) & 0xffU);
|
||||
output[index] = static_cast<std::byte>(value);
|
||||
}
|
||||
}
|
||||
|
||||
bool identical{false};
|
||||
std::vector<std::size_t> sizes;
|
||||
};
|
||||
|
||||
class FakeBrowser final : public BrowserLauncher {
|
||||
public:
|
||||
void open(const std::string& url) override {
|
||||
urls.push_back(url);
|
||||
if (failure) {
|
||||
throw std::runtime_error("browser launch failed");
|
||||
}
|
||||
}
|
||||
|
||||
bool failure{false};
|
||||
std::vector<std::string> urls;
|
||||
};
|
||||
|
||||
class FakeReceiver final : public AuthorizationCallbackReceiver {
|
||||
public:
|
||||
[[nodiscard]] std::string redirect_uri() const override {
|
||||
if (redirect_failure) {
|
||||
throw std::runtime_error("redirect receiver failed");
|
||||
}
|
||||
return redirect;
|
||||
}
|
||||
|
||||
AuthorizationCallback receive() override {
|
||||
++receive_calls;
|
||||
if (receive_failure) {
|
||||
throw std::runtime_error("callback receiver failed");
|
||||
}
|
||||
AuthorizationCallback result = callback;
|
||||
if (copy_browser_state) {
|
||||
require(browser != nullptr && browser->urls.size() == 1,
|
||||
"test receiver could not observe authorization URL");
|
||||
result.state = only_value(query_parameters(browser->urls[0]), "state");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string redirect{"http://127.0.0.1:43127/callback"};
|
||||
AuthorizationCallback callback{"authorization-code", "", "", ""};
|
||||
FakeBrowser* browser{nullptr};
|
||||
bool copy_browser_state{true};
|
||||
bool redirect_failure{false};
|
||||
bool receive_failure{false};
|
||||
int receive_calls{0};
|
||||
};
|
||||
|
||||
class FakeTransport final : public HttpTransport {
|
||||
public:
|
||||
HttpResponse send(const HttpRequest& request) override {
|
||||
requests.push_back(request);
|
||||
if (failure) {
|
||||
throw std::runtime_error("transport failed");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
bool failure{false};
|
||||
HttpResponse response{200, {{"Content-Type", "application/json"}}, R"({"ok":true})"};
|
||||
std::vector<HttpRequest> requests;
|
||||
};
|
||||
|
||||
struct Rig {
|
||||
Rig() { receiver.browser = &browser; }
|
||||
|
||||
OAuthClientConfig config{"client id+/%?", "https://login.example.test/oauth2/authorize",
|
||||
"https://login.example.test/oauth2/token",
|
||||
{"openid", "offline_access", "Calendars.ReadWrite"}};
|
||||
FakeEntropy entropy;
|
||||
FakeBrowser browser;
|
||||
FakeReceiver receiver;
|
||||
FakeTransport transport;
|
||||
};
|
||||
|
||||
[[nodiscard]] HttpResponse authorize(Rig& rig) {
|
||||
return authorize_with_pkce(
|
||||
rig.config, rig.browser, rig.receiver, rig.transport, rig.entropy);
|
||||
}
|
||||
|
||||
void require_no_token_request(const Rig& rig, std::string_view message) {
|
||||
require(rig.transport.requests.empty(), message);
|
||||
}
|
||||
|
||||
void test_success_request_and_pkce() {
|
||||
Rig rig;
|
||||
rig.config.client_id = "client café +/雪";
|
||||
rig.receiver.callback.code = "code café +/雪?=&";
|
||||
rig.transport.response =
|
||||
{204, {{"X-Result", "accepted"}}, R"({"access_token":"opaque"})"};
|
||||
|
||||
const HttpResponse response = authorize(rig);
|
||||
require(response == rig.transport.response, "successful token response was not returned intact");
|
||||
require(rig.entropy.sizes == std::vector<std::size_t>{32, 32},
|
||||
"PKCE attempt did not request exactly two independent 32-byte entropy fills");
|
||||
require(rig.browser.urls.size() == 1, "authorization attempt did not open exactly one URL");
|
||||
require(rig.receiver.receive_calls == 1, "authorization attempt did not receive once");
|
||||
require(rig.transport.requests.size() == 1,
|
||||
"successful authorization did not send exactly one token request");
|
||||
|
||||
const Parameters authorization = query_parameters(rig.browser.urls[0]);
|
||||
require(only_value(authorization, "response_type") == "code",
|
||||
"authorization query did not request an authorization code");
|
||||
require(only_value(authorization, "client_id") == rig.config.client_id,
|
||||
"authorization query did not RFC3986-encode the client ID");
|
||||
require(only_value(authorization, "redirect_uri") == rig.receiver.redirect,
|
||||
"authorization query did not encode the loopback redirect");
|
||||
require(only_value(authorization, "code_challenge_method") == "S256",
|
||||
"authorization query did not require PKCE S256");
|
||||
require(only_value(authorization, "scope") == "openid offline_access Calendars.ReadWrite",
|
||||
"authorization query did not preserve scope order");
|
||||
require(rig.browser.urls[0].find("café") == std::string::npos
|
||||
&& rig.browser.urls[0].find("雪") == std::string::npos
|
||||
&& rig.browser.urls[0].find("%C3%A9") != std::string::npos
|
||||
&& rig.browser.urls[0].find("%E9%9B%AA") != std::string::npos,
|
||||
"authorization query did not RFC3986-encode non-ASCII UTF-8 bytes");
|
||||
|
||||
const HttpRequest& token = rig.transport.requests[0];
|
||||
require(token.method == HttpMethod::post, "token exchange was not a POST");
|
||||
require(token.url == rig.config.token_endpoint, "token exchange used the wrong endpoint");
|
||||
const Parameters form = parse_parameters(token.body, false);
|
||||
require(only_value(form, "grant_type") == "authorization_code",
|
||||
"token request has the wrong grant type");
|
||||
require(only_value(form, "code") == rig.receiver.callback.code,
|
||||
"token request did not RFC3986-encode the authorization code");
|
||||
require(only_value(form, "redirect_uri") == rig.receiver.redirect,
|
||||
"token request did not encode the redirect URI");
|
||||
require(only_value(form, "client_id") == rig.config.client_id,
|
||||
"token request did not RFC3986-encode the client ID");
|
||||
require(token.body.find("café") == std::string::npos
|
||||
&& token.body.find("雪") == std::string::npos
|
||||
&& token.body.find("%C3%A9") != std::string::npos
|
||||
&& token.body.find("%E9%9B%AA") != std::string::npos,
|
||||
"token form did not RFC3986-encode non-ASCII UTF-8 bytes");
|
||||
const std::string& state = only_value(authorization, "state");
|
||||
const std::string& verifier = only_value(form, "code_verifier");
|
||||
require(!state.empty() && !verifier.empty() && state != verifier,
|
||||
"distinct deterministic entropy was not used independently for state and verifier");
|
||||
require(only_value(authorization, "code_challenge") == s256_challenge(verifier),
|
||||
"authorization challenge is not SHA-256 of the token verifier");
|
||||
require(rig.browser.urls[0].find(verifier) == std::string::npos,
|
||||
"raw PKCE verifier was exposed to the authorization endpoint");
|
||||
require(form.count("state") == 0 && token.body.find("state=") == std::string::npos,
|
||||
"callback state was improperly sent to the token endpoint");
|
||||
require(token.url.find(state) == std::string::npos
|
||||
&& std::ranges::none_of(token.headers, [&](const auto& header) {
|
||||
return header.name.find(state) != std::string::npos
|
||||
|| header.value.find(state) != std::string::npos;
|
||||
})
|
||||
&& token.body.find(state) == std::string::npos,
|
||||
"callback state leaked elsewhere in the token request");
|
||||
require(form.count("client_secret") == 0
|
||||
&& token.body.find("client_secret") == std::string::npos,
|
||||
"public-client token request contains a client secret");
|
||||
}
|
||||
|
||||
void test_independent_identical_entropy_is_not_rejected() {
|
||||
Rig rig;
|
||||
rig.entropy.identical = true;
|
||||
require(authorize(rig).status == 200, "identical random outputs were treated as invalid");
|
||||
require(rig.entropy.sizes == std::vector<std::size_t>{32, 32},
|
||||
"identical entropy fixture did not receive two independent fills");
|
||||
const auto authorization = query_parameters(rig.browser.urls[0]);
|
||||
const auto form = parse_parameters(rig.transport.requests[0].body, false);
|
||||
require(only_value(authorization, "state") == only_value(form, "code_verifier"),
|
||||
"identical entropy fixture did not produce the expected equal encodings");
|
||||
}
|
||||
|
||||
void test_all_2xx_are_success_and_never_retry() {
|
||||
for (const int status : {200, 201, 226, 299}) {
|
||||
Rig rig;
|
||||
rig.transport.response = {status, {}, "success-body"};
|
||||
require(authorize(rig).status == status, "2xx token response was rejected");
|
||||
require(rig.transport.requests.size() == 1, "2xx token response was retried");
|
||||
}
|
||||
}
|
||||
|
||||
void test_token_rejection_and_transport_failure_never_retry_or_leak() {
|
||||
for (const int status : {0, 101, 199, 300, 400, 429, 500}) {
|
||||
Rig rig;
|
||||
const std::string sensitive = "SENSITIVE_TOKEN_BODY_" + std::to_string(status);
|
||||
rig.transport.response = {status, {}, sensitive};
|
||||
try {
|
||||
static_cast<void>(authorize(rig));
|
||||
throw std::runtime_error("non-2xx token response was accepted");
|
||||
} catch (const OAuthError& error) {
|
||||
require(error.kind() == OAuthError::Kind::token_endpoint_rejected,
|
||||
"token rejection has the wrong OAuth error kind");
|
||||
require(std::string_view{error.what()}.find(sensitive) == std::string_view::npos,
|
||||
"token response body leaked through OAuth exception");
|
||||
}
|
||||
require(rig.transport.requests.size() == 1, "token rejection was retried");
|
||||
}
|
||||
|
||||
Rig rig;
|
||||
rig.transport.failure = true;
|
||||
require_throws([&] { static_cast<void>(authorize(rig)); },
|
||||
"transport failure did not propagate");
|
||||
require(rig.transport.requests.size() == 1, "transport exception triggered a retry");
|
||||
}
|
||||
|
||||
void test_callback_failures_never_send_token_request() {
|
||||
{
|
||||
Rig rig;
|
||||
rig.receiver.copy_browser_state = false;
|
||||
rig.receiver.callback.state = "attacker-state";
|
||||
require_oauth_error([&] { static_cast<void>(authorize(rig)); },
|
||||
OAuthError::Kind::state_mismatch, "callback state mismatch was accepted");
|
||||
require_no_token_request(rig, "state mismatch sent a token request");
|
||||
}
|
||||
{
|
||||
Rig rig;
|
||||
const std::string sensitive = "SENSITIVE_AUTHORIZATION_DESCRIPTION";
|
||||
rig.receiver.callback = {"", "", "access_denied", sensitive};
|
||||
try {
|
||||
static_cast<void>(authorize(rig));
|
||||
throw std::runtime_error("authorization denial was accepted");
|
||||
} catch (const OAuthError& error) {
|
||||
require(error.kind() == OAuthError::Kind::authorization_rejected,
|
||||
"authorization denial has the wrong OAuth error kind");
|
||||
require(std::string_view{error.what()}.find(sensitive) == std::string_view::npos,
|
||||
"authorization error description leaked through exception");
|
||||
}
|
||||
require_no_token_request(rig, "authorization denial sent a token request");
|
||||
}
|
||||
{
|
||||
Rig rig;
|
||||
rig.receiver.copy_browser_state = false;
|
||||
rig.receiver.callback = {"code-without-state", "", "", ""};
|
||||
require_oauth_error([&] { static_cast<void>(authorize(rig)); },
|
||||
OAuthError::Kind::invalid_callback, "callback with empty state was accepted");
|
||||
require_no_token_request(rig, "callback with empty state sent a token request");
|
||||
}
|
||||
|
||||
const std::vector<AuthorizationCallback> malformed{
|
||||
{"", "", "", ""},
|
||||
{"code", "", "error", "description"},
|
||||
{"", "", "", "description-without-error"},
|
||||
};
|
||||
for (const auto& callback : malformed) {
|
||||
Rig rig;
|
||||
rig.receiver.callback = callback;
|
||||
require_oauth_error([&] { static_cast<void>(authorize(rig)); },
|
||||
OAuthError::Kind::invalid_callback, "malformed callback was accepted");
|
||||
require_no_token_request(rig, "malformed callback sent a token request");
|
||||
}
|
||||
}
|
||||
|
||||
void test_browser_and_receiver_failures_never_send_token_request() {
|
||||
{
|
||||
Rig rig;
|
||||
rig.browser.failure = true;
|
||||
require_throws([&] { static_cast<void>(authorize(rig)); },
|
||||
"browser failure did not propagate");
|
||||
require(rig.browser.urls.size() == 1, "browser was unexpectedly retried");
|
||||
require(rig.receiver.receive_calls == 0, "callback was awaited after browser failure");
|
||||
require_no_token_request(rig, "browser failure sent a token request");
|
||||
}
|
||||
{
|
||||
Rig rig;
|
||||
rig.receiver.redirect_failure = true;
|
||||
require_throws([&] { static_cast<void>(authorize(rig)); },
|
||||
"redirect receiver failure did not propagate");
|
||||
require(rig.browser.urls.empty(), "browser opened after redirect receiver failure");
|
||||
require_no_token_request(rig, "redirect receiver failure sent a token request");
|
||||
}
|
||||
{
|
||||
Rig rig;
|
||||
rig.receiver.receive_failure = true;
|
||||
require_throws([&] { static_cast<void>(authorize(rig)); },
|
||||
"callback receiver failure did not propagate");
|
||||
require(rig.receiver.receive_calls == 1, "callback receive was retried");
|
||||
require_no_token_request(rig, "callback receiver failure sent a token request");
|
||||
}
|
||||
}
|
||||
|
||||
void test_endpoint_validation() {
|
||||
const std::vector<std::string> invalid_endpoints{
|
||||
"http://login.example.test/oauth2/authorize",
|
||||
"login.example.test/oauth2/authorize",
|
||||
"https:///missing-authority",
|
||||
"https://user@login.example.test/authorize",
|
||||
"https://login.example.test/authorize?query=forbidden",
|
||||
"https://login.example.test/authorize#fragment",
|
||||
"https://login.example.test/path with space",
|
||||
"https://login.example.test/path\ncontrol",
|
||||
};
|
||||
for (const auto& endpoint : invalid_endpoints) {
|
||||
for (const bool authorization_endpoint : {true, false}) {
|
||||
Rig rig;
|
||||
(authorization_endpoint ? rig.config.authorization_endpoint
|
||||
: rig.config.token_endpoint) = endpoint;
|
||||
require_oauth_error([&] { static_cast<void>(authorize(rig)); },
|
||||
OAuthError::Kind::invalid_configuration,
|
||||
"invalid authorization or token endpoint was accepted");
|
||||
require(rig.browser.urls.empty(), "invalid endpoint opened the browser");
|
||||
require_no_token_request(rig, "invalid endpoint sent a token request");
|
||||
}
|
||||
}
|
||||
|
||||
Rig uppercase;
|
||||
uppercase.config.authorization_endpoint = "HTTPS://login.example.test/authorize";
|
||||
uppercase.config.token_endpoint = "HTTPS://login.example.test/token";
|
||||
require(authorize(uppercase).status == 200, "uppercase HTTPS scheme was rejected");
|
||||
}
|
||||
|
||||
void test_invalid_scope_has_zero_side_effects() {
|
||||
const std::vector<std::string> invalid_scopes{
|
||||
"", "two scopes", "tab\tscope", "line\nscope", "return\rscope", "control\x1fscope"};
|
||||
for (std::size_t index = 0; index < invalid_scopes.size(); ++index) {
|
||||
const auto& invalid = invalid_scopes[index];
|
||||
Rig rig;
|
||||
rig.config.scopes = {"openid", invalid};
|
||||
require_oauth_error([&] { static_cast<void>(authorize(rig)); },
|
||||
OAuthError::Kind::invalid_configuration,
|
||||
"unsafe OAuth scope fixture " + std::to_string(index) + " was accepted");
|
||||
require(rig.entropy.sizes.empty(), "unsafe scope consumed entropy");
|
||||
require(rig.browser.urls.empty(), "unsafe scope opened the browser");
|
||||
require(rig.receiver.receive_calls == 0, "unsafe scope awaited a callback");
|
||||
require_no_token_request(rig, "unsafe scope sent a token request");
|
||||
}
|
||||
}
|
||||
|
||||
void test_loopback_redirect_validation() {
|
||||
const std::vector<std::string> accepted{
|
||||
"http://127.0.0.1:1/callback",
|
||||
"http://127.0.0.1:65535",
|
||||
"http://localhost/callback/path",
|
||||
"HTTP://LOCALHOST:43127/callback",
|
||||
"http://[::1]:43127/callback",
|
||||
};
|
||||
for (const auto& redirect : accepted) {
|
||||
Rig rig;
|
||||
rig.receiver.redirect = redirect;
|
||||
require(authorize(rig).status == 200, "valid loopback redirect was rejected");
|
||||
require(rig.transport.requests.size() == 1,
|
||||
"valid loopback redirect did not complete one token request");
|
||||
}
|
||||
|
||||
const std::vector<std::string> rejected{
|
||||
"https://127.0.0.1:43127/callback",
|
||||
"http://example.test:43127/callback",
|
||||
"http://127.0.0.1.example.test:43127/callback",
|
||||
"http://localhost.evil.test:43127/callback",
|
||||
"http://localhost.:43127/callback",
|
||||
"http://%31%32%37.0.0.1:43127/callback",
|
||||
"http://user@127.0.0.1:43127/callback",
|
||||
"http://127.0.0.1:/callback",
|
||||
"http://127.0.0.1:0/callback",
|
||||
"http://127.0.0.1:65536/callback",
|
||||
"http://127.0.0.1:not-a-port/callback",
|
||||
"http://::1:43127/callback",
|
||||
"http://[::1]:43127/callback?query=forbidden",
|
||||
"http://[::1]:43127/callback#fragment",
|
||||
"http://localhost:43127/path with space",
|
||||
"http://localhost:43127/path\rcontrol",
|
||||
};
|
||||
for (const auto& redirect : rejected) {
|
||||
Rig rig;
|
||||
rig.receiver.redirect = redirect;
|
||||
require_oauth_error([&] { static_cast<void>(authorize(rig)); },
|
||||
OAuthError::Kind::invalid_redirect, "unsafe loopback redirect was accepted");
|
||||
require(rig.browser.urls.empty(), "unsafe redirect opened the browser");
|
||||
require_no_token_request(rig, "unsafe redirect sent a token request");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_success_request_and_pkce();
|
||||
test_independent_identical_entropy_is_not_rejected();
|
||||
test_all_2xx_are_success_and_never_retry();
|
||||
test_token_rejection_and_transport_failure_never_retry_or_leak();
|
||||
test_callback_failures_never_send_token_request();
|
||||
test_browser_and_receiver_failures_never_send_token_request();
|
||||
test_endpoint_validation();
|
||||
test_invalid_scope_has_zero_side_effects();
|
||||
test_loopback_redirect_validation();
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "OAuth security test failure: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::cout << "OAuth security tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user