#include "nocal/sync/desktop_oauth.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { using namespace std::chrono_literals; using nocal::sync::AuthorizationCallback; using nocal::sync::LoopbackCallbackReceiver; using nocal::sync::XdgBrowserLauncher; void require(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string{message}); } } template void require_throws(Function&& function, std::string_view message) { try { function(); } catch (const std::exception&) { return; } throw std::runtime_error(std::string{message}); } class TempDirectory { public: TempDirectory() { std::string pattern = (std::filesystem::temp_directory_path() / "nocal-desktop-oauth-tests.XXXXXX") .string(); std::vector writable(pattern.begin(), pattern.end()); writable.push_back('\0'); const char* created = ::mkdtemp(writable.data()); if (created == nullptr) { throw std::runtime_error("mkdtemp failed"); } path_ = created; } TempDirectory(const TempDirectory&) = delete; TempDirectory& operator=(const TempDirectory&) = delete; ~TempDirectory() { std::error_code ignored; std::filesystem::remove_all(path_, ignored); } [[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; } private: std::filesystem::path path_; }; class Descriptor { public: explicit Descriptor(int value = -1) : value_(value) {} ~Descriptor() { if (value_ >= 0) { static_cast(::close(value_)); } } Descriptor(const Descriptor&) = delete; Descriptor& operator=(const Descriptor&) = delete; Descriptor(Descriptor&& other) noexcept : value_(std::exchange(other.value_, -1)) {} Descriptor& operator=(Descriptor&& other) noexcept { if (this != &other) { if (value_ >= 0) { static_cast(::close(value_)); } value_ = std::exchange(other.value_, -1); } return *this; } [[nodiscard]] int get() const noexcept { return value_; } private: int value_; }; [[nodiscard]] std::string read_file(const std::filesystem::path& path) { std::ifstream input(path, std::ios::binary); require(static_cast(input), "could not read browser helper output"); return {std::istreambuf_iterator{input}, {}}; } [[nodiscard]] std::string self_executable() { return std::filesystem::read_symlink("/proc/self/exe").string(); } void set_helper_output(const std::filesystem::path& path) { require(::setenv("NOCAL_DESKTOP_OAUTH_HELPER_OUTPUT", path.c_str(), 1) == 0, "could not configure browser helper output"); } int run_browser_helper(int argc, char** argv) { const char* output_path = std::getenv("NOCAL_DESKTOP_OAUTH_HELPER_OUTPUT"); if (output_path == nullptr) { return 91; } std::ofstream output(output_path, std::ios::binary | std::ios::trunc); output << argc << '\n'; for (int index = 0; index < argc; ++index) { output << argv[index] << '\n'; } if (!output) { return 92; } return argc == 2 && std::string_view{argv[1]}.starts_with("browser-helper:exit=7") ? 7 : 0; } void test_browser_literal_argv_and_failures(const std::filesystem::path& root) { const auto helper_output = root / "browser-argv.txt"; const auto injection_target = root / "must-not-exist"; set_helper_output(helper_output); XdgBrowserLauncher launcher{self_executable()}; const std::string url = "browser-helper:exit=0; literal argument; touch " + injection_target.string() + "; $(touch " + injection_target.string() + ")"; launcher.open(url); const std::string report = read_file(helper_output); require(report.starts_with("2\n"), "browser helper received more than one URL argument"); require(report.ends_with(url + "\n"), "browser helper did not receive the URL literally"); require(!std::filesystem::exists(injection_target), "browser URL was interpreted by a shell"); const std::string sensitive = "SENSITIVE_BROWSER_URL"; try { launcher.open("browser-helper:exit=7;" + sensitive); throw std::runtime_error("nonzero browser exit was accepted"); } catch (const std::exception& error) { require(std::string_view{error.what()}.find(sensitive) == std::string_view::npos, "browser URL leaked through an exception"); } require_throws([] { XdgBrowserLauncher invalid{""}; }, "empty browser executable was accepted"); require_throws( [] { XdgBrowserLauncher invalid{std::string{"browser\0name", 12}}; }, "browser executable containing NUL was accepted"); require_throws( [] { XdgBrowserLauncher missing{"nocal-browser-executable-that-does-not-exist"}; missing.open("https://example.test"); }, "missing browser executable was accepted"); for (const std::string& invalid_url : { std::string{"https://example.test/\0secret", 28}, std::string{"https://example.test/\nsecret"}, std::string{"https://example.test/\rsecret"}, std::string{"https://example.test/\x7fsecret"}, }) { require_throws([&] { launcher.open(invalid_url); }, "browser URL containing a control character was accepted"); } } [[nodiscard]] unsigned int port_from_uri(std::string_view uri) { constexpr std::string_view prefix = "http://localhost:"; constexpr std::string_view suffix = "/nocal/oauth/callback"; require(uri.starts_with(prefix) && uri.ends_with(suffix), "loopback redirect URI has the wrong shape"); const std::string_view encoded = uri.substr(prefix.size(), uri.size() - prefix.size() - suffix.size()); require(!encoded.empty(), "loopback redirect URI has no port"); unsigned int port = 0; for (const char character : encoded) { require(character >= '0' && character <= '9', "loopback redirect port is not numeric"); port = port * 10U + static_cast(character - '0'); } require(port > 0 && port <= 65'535, "loopback redirect port is out of range"); return port; } [[nodiscard]] Descriptor connect_ipv4(unsigned int port) { Descriptor client{::socket(AF_INET, SOCK_STREAM, 0)}; require(client.get() >= 0, "could not create loopback test socket"); sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); address.sin_port = htons(static_cast(port)); require(::connect(client.get(), reinterpret_cast(&address), sizeof(address)) == 0, "could not connect to loopback receiver"); return client; } [[nodiscard]] bool can_connect_ipv4(unsigned int port) { Descriptor client{::socket(AF_INET, SOCK_STREAM, 0)}; if (client.get() < 0) { return false; } sockaddr_in address{}; address.sin_family = AF_INET; address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); address.sin_port = htons(static_cast(port)); return ::connect(client.get(), reinterpret_cast(&address), sizeof(address)) == 0; } [[nodiscard]] bool can_connect_ipv6(unsigned int port) { Descriptor client{::socket(AF_INET6, SOCK_STREAM, 0)}; if (client.get() < 0) { return false; } sockaddr_in6 address{}; address.sin6_family = AF_INET6; address.sin6_addr = in6addr_loopback; address.sin6_port = htons(static_cast(port)); return ::connect(client.get(), reinterpret_cast(&address), sizeof(address)) == 0; } void send_bytes(int descriptor, std::string_view bytes) { std::size_t offset = 0; while (offset < bytes.size()) { const ssize_t sent = ::send(descriptor, bytes.data() + offset, bytes.size() - offset, MSG_NOSIGNAL); if (sent < 0 && errno == EINTR) { continue; } require(sent > 0, "failed to send loopback test request"); offset += static_cast(sent); } } void send_all(int descriptor, std::string_view bytes) { send_bytes(descriptor, bytes); require(::shutdown(descriptor, SHUT_WR) == 0, "failed to finish loopback test request"); } [[nodiscard]] std::string read_response(int descriptor) { std::string result; std::array buffer{}; while (true) { const ssize_t received = ::recv(descriptor, buffer.data(), buffer.size(), 0); if (received > 0) { result.append(buffer.data(), static_cast(received)); } else if (received == 0) { return result; } else if (errno == ECONNRESET && !result.empty()) { return result; } else if (errno != EINTR) { throw std::runtime_error("failed to read loopback HTTP response"); } } } [[nodiscard]] std::string request_for(unsigned int port, std::string_view target, std::string_view method = "GET", std::string_view extra_headers = {}) { return std::string{method} + " " + std::string{target} + " HTTP/1.1\r\nHost: localhost:" + std::to_string(port) + "\r\n" + std::string{extra_headers} + "\r\n"; } struct ExchangeResult { AuthorizationCallback callback; std::string response; }; [[nodiscard]] ExchangeResult valid_exchange( LoopbackCallbackReceiver& receiver, std::string_view query) { const unsigned int port = port_from_uri(receiver.redirect_uri()); Descriptor client = connect_ipv4(port); send_all(client.get(), request_for(port, std::string{"/nocal/oauth/callback?"} + std::string{query})); AuthorizationCallback callback = receiver.receive(); return {std::move(callback), read_response(client.get())}; } [[nodiscard]] std::string invalid_exchange( LoopbackCallbackReceiver& receiver, const std::string& request) { const unsigned int port = port_from_uri(receiver.redirect_uri()); Descriptor client = connect_ipv4(port); send_all(client.get(), request); require_throws([&] { static_cast(receiver.receive()); }, "invalid loopback request was accepted"); return read_response(client.get()); } void require_success_response(std::string_view response) { require(response.starts_with("HTTP/1.1 200 OK\r\n"), "valid callback did not receive HTTP 200"); require(response.find("Cache-Control: no-store") != std::string_view::npos, "valid callback response is cacheable"); } void require_failure_response(std::string_view response) { require(response.starts_with("HTTP/1.1 400 Bad Request\r\n"), "invalid callback did not receive HTTP 400"); require(response.find("Cache-Control: no-store") != std::string_view::npos, "invalid callback response is cacheable"); require(response.find("SENSITIVE_CALLBACK") == std::string_view::npos, "invalid callback response reflected request data"); } void test_redirect_and_parsed_callbacks() { LoopbackCallbackReceiver success{1s}; const unsigned int port = port_from_uri(success.redirect_uri()); require(!can_connect_ipv6(port), "loopback receiver unexpectedly listens on IPv6"); const auto accepted = valid_exchange(success, "code=abc%2Bdef&state=state+with+spaces&session_state=ignored"); require(accepted.callback.code == "abc+def" && accepted.callback.state == "state with spaces", "successful callback query was not decoded correctly"); require(accepted.callback.error.empty() && accepted.callback.error_description.empty(), "unknown callback field contaminated known fields"); require_success_response(accepted.response); LoopbackCallbackReceiver denied{1s}; const auto rejected = valid_exchange(denied, "error=access_denied&error_description=User+cancelled&state=state-2&unknown=value"); require(rejected.callback.code.empty() && rejected.callback.error == "access_denied" && rejected.callback.error_description == "User cancelled" && rejected.callback.state == "state-2", "authorization error callback was not decoded correctly"); require_success_response(rejected.response); LoopbackCallbackReceiver uppercase_host{1s}; const unsigned int uppercase_port = port_from_uri(uppercase_host.redirect_uri()); Descriptor uppercase_client = connect_ipv4(uppercase_port); send_all(uppercase_client.get(), "GET /nocal/oauth/callback?code=upper&state=case HTTP/1.1\r\nhOsT: LOCALHOST:" + std::to_string(uppercase_port) + "\r\n\r\n"); const auto uppercase_callback = uppercase_host.receive(); require(uppercase_callback.code == "upper" && uppercase_callback.state == "case", "ASCII-case-insensitive localhost Host was rejected"); require_success_response(read_response(uppercase_client.get())); } void test_invalid_http_matrix() { auto run = [](const std::function& build) { LoopbackCallbackReceiver receiver{1s}; const unsigned int port = port_from_uri(receiver.redirect_uri()); const std::string response = invalid_exchange(receiver, build(port)); require_failure_response(response); require(!can_connect_ipv4(port), "invalid callback left listener open"); }; run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=x&state=y", "POST"); }); run([](unsigned int port) { return request_for(port, "/wrong?code=x&state=y"); }); run([](unsigned int port) { return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.0\r\nHost: localhost:" + std::to_string(port) + "\r\n\r\n"; }); run([](unsigned int) { return std::string{"GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\n\r\n"}; }); run([](unsigned int port) { return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\nHost: 127.0.0.1:" + std::to_string(port) + "\r\n\r\n"; }); run([](unsigned int port) { return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\nHost: localhost.:" + std::to_string(port) + "\r\n\r\n"; }); run([](unsigned int port) { return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\nHost: localhost.evil:" + std::to_string(port) + "\r\n\r\n"; }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET", "Host: localhost:" + std::to_string(port) + "\r\n"); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET", "Broken\r\n"); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET", std::string{"X-"} + "\xC3\xA9" + ": value\r\n"); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET", "Transfer-Encoding: chunked\r\n"); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET", "Content-Length: 1\r\n") + "x"; }); run([](unsigned int port) { return request_for( port, "/nocal/oauth/callback?code=SENSITIVE_CALLBACK&state=y&state=z"); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=%GG&state=y"); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=%00&state=y"); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=%1F&state=y"); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code&state=y"); }); run([](unsigned int port) { std::string target = "/nocal/oauth/callback?code=x&state=y"; target.push_back('\0'); return request_for(port, target); }); run([](unsigned int port) { return request_for(port, "/nocal/oauth/callback?code=x&state=y", "GET", "X-Large: " + std::string(17U * 1024U, 'a') + "\r\n"); }); run([](unsigned int port) { return "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\nHost: localhost:" + std::to_string(port) + "\n\n"; }); } void test_timeout_single_use_and_cleanup() { require_throws([] { LoopbackCallbackReceiver invalid{0ms}; }, "zero loopback timeout was accepted"); require_throws([] { LoopbackCallbackReceiver invalid{-1ms}; }, "negative loopback timeout was accepted"); LoopbackCallbackReceiver timed{20ms}; const unsigned int timed_port = port_from_uri(timed.redirect_uri()); require_throws([&] { static_cast(timed.receive()); }, "loopback receiver did not time out"); require(!can_connect_ipv4(timed_port), "timed-out receiver left listener open"); require_throws([&] { static_cast(timed.receive()); }, "timed-out receiver was reusable"); LoopbackCallbackReceiver partial{20ms}; const unsigned int partial_port = port_from_uri(partial.redirect_uri()); Descriptor partial_client = connect_ipv4(partial_port); send_bytes(partial_client.get(), "GET /nocal/oauth/callback?code=x&state=y HTTP/1.1\r\nHost: localhost:"); require_throws([&] { static_cast(partial.receive()); }, "partial callback did not obey the total deadline"); require_failure_response(read_response(partial_client.get())); require(!can_connect_ipv4(partial_port), "partial timeout left listener open"); LoopbackCallbackReceiver used{1s}; const unsigned int used_port = port_from_uri(used.redirect_uri()); static_cast(valid_exchange(used, "code=x&state=y")); require_throws([&] { static_cast(used.receive()); }, "completed receiver was reusable"); require(!can_connect_ipv4(used_port), "completed receiver left listener open"); unsigned int destroyed_port = 0; { LoopbackCallbackReceiver destroyed{1s}; destroyed_port = port_from_uri(destroyed.redirect_uri()); } require(!can_connect_ipv4(destroyed_port), "destroyed receiver left listener open"); } void test_move_safety_and_cleanup() { LoopbackCallbackReceiver source{1s}; const std::string uri = source.redirect_uri(); LoopbackCallbackReceiver moved{std::move(source)}; require(moved.redirect_uri() == uri, "move construction changed redirect URI"); require_throws([&] { static_cast(source.redirect_uri()); }, "moved-from receiver remained usable"); static_cast(valid_exchange(moved, "code=moved&state=state")); LoopbackCallbackReceiver assignment_source{1s}; const std::string assigned_uri = assignment_source.redirect_uri(); LoopbackCallbackReceiver assignment_target{1s}; const unsigned int replaced_port = port_from_uri(assignment_target.redirect_uri()); assignment_target = std::move(assignment_source); require(assignment_target.redirect_uri() == assigned_uri, "move assignment changed redirect URI"); require(!can_connect_ipv4(replaced_port), "move assignment did not close the replaced listener"); require_throws([&] { static_cast(assignment_source.redirect_uri()); }, "move-assigned source remained usable"); static_cast(valid_exchange(assignment_target, "error=denied&state=state")); } } // namespace int main(int argc, char** argv) { if (argc >= 2 && std::string_view{argv[1]}.starts_with("browser-helper:")) { return run_browser_helper(argc, argv); } try { TempDirectory temporary; test_browser_literal_argv_and_failures(temporary.path()); test_redirect_and_parsed_callbacks(); test_invalid_http_matrix(); test_timeout_single_use_and_cleanup(); test_move_safety_and_cleanup(); } catch (const std::exception& error) { std::cerr << "desktop OAuth test failure: " << error.what() << '\n'; return 1; } std::cout << "desktop OAuth tests passed\n"; return 0; }