From 295acbc12509ab94c380da4b67ff2990f69959e1 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 18 Jul 2026 11:33:06 +0100 Subject: [PATCH] feat: add Linux OAuth adapters Add a bounded libcurl transport with verified TLS, no redirects or retries, sanitized failures, binary-safe requests, and resolved-address enforcement for cleartext loopback traffic. Add shell-free xdg-open launching and a one-shot IPv4 loopback callback receiver with strict HTTP/query parsing, deadlines, fixed callback path, and RAII cleanup. Token parsing, Secret Service, and Graph remain follow-up phases. --- README.md | 28 +- docs/ARCHITECTURE.md | 38 +- docs/PRODUCT.md | 26 +- docs/ROADMAP.md | 25 +- include/nocal/sync/curl_http.hpp | 45 +++ include/nocal/sync/desktop_oauth.hpp | 40 ++ meson.build | 13 +- shell.nix | 2 + src/sync/curl_http.cpp | 442 ++++++++++++++++++++++ src/sync/desktop_oauth.cpp | 529 +++++++++++++++++++++++++++ tests/curl_http_tests.cpp | 418 +++++++++++++++++++++ tests/desktop_oauth_tests.cpp | 517 ++++++++++++++++++++++++++ 12 files changed, 2095 insertions(+), 28 deletions(-) create mode 100644 include/nocal/sync/curl_http.hpp create mode 100644 include/nocal/sync/desktop_oauth.hpp create mode 100644 src/sync/curl_http.cpp create mode 100644 src/sync/desktop_oauth.cpp create mode 100644 tests/curl_http_tests.cpp create mode 100644 tests/desktop_oauth_tests.cpp diff --git a/README.md b/README.md index 5d960d2..6adff74 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,23 @@ or expected. The generic HTTP boundary uses injected transports and deterministic fakes. It does not retry requests: provider code must later make retry decisions with the -operation's idempotency and server response in view. This remains foundation, -not working Office 365 integration. There is no concrete HTTP transport, -browser/loopback adapter, token JSON parser, Secret Service backend, Graph call, -or usable account setup yet. OAuth tokens and other secrets will never be -stored in SQLite; they belong in the Freedesktop Secret Service. The next -implementation sequence is concrete desktop, HTTP, and Secret Service adapters, -followed by read-only Microsoft Graph calendar discovery and delta -synchronization. +operation's idempotency and server response in view. The concrete Linux adapter +uses libcurl with TLS verification and bounded +responses and timeouts, and neither automatic redirects nor retries. Cleartext +HTTP is limited to loopback tests. Browser launch executes `xdg-open` directly, +never through a shell. + +The loopback receiver binds only `127.0.0.1`, advertises a dynamic-port +`localhost` URL at the fixed `/nocal/oauth/callback` path, and accepts one +bounded callback. Register Nocal in Entra as a Mobile and desktop public client +with `http://localhost/nocal/oauth/callback`. Microsoft documents that the port +is ignored when matching a `localhost` redirect, allowing a fresh local port; +an HTTP `127.0.0.1` redirect requires manifest editing, while IPv6 loopback +redirects are currently unsupported. + +This remains foundation, not working Office 365 integration. There is no token +JSON parser, Secret Service backend, Graph call, account UI, or usable account +setup yet. OAuth tokens and other secrets will never be stored in SQLite. The +synchronous libsecret API may block, so its concrete adapter will run off the +render thread in the next phase. Read-only Microsoft Graph calendar discovery and delta +synchronization follows that token and secret-storage work. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7eb0ad..6f51fef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -183,10 +183,40 @@ consent policy remains authoritative and may require administrator approval or block consent. A desktop binary distributed as open source is not a confidential client and must not embed or depend on a client secret. -This boundary does not yet contain a concrete HTTP transport, system-browser or -loopback-listener adapter, token JSON parsing, a Secret Service backend, Graph -requests, or usable account setup. Those concrete desktop, HTTP, and secret -storage adapters are the next slice. Read-only Microsoft Graph calendar +The generic boundary deliberately contains no concrete networking or desktop +code. The Linux adapters below supply the transport, browser launch, and +loopback listener. Token JSON parsing, Secret Service storage, Graph requests, +and usable account setup remain later slices. Read-only Microsoft Graph calendar discovery and delta synchronization follows; delta links remain opaque and are persisted only through the calendar/window transaction described above. Remote writes and conflict resolution come later. + +### Concrete Linux adapters + +The concrete adapter phase provides a libcurl transport that keeps certificate +and host verification enabled, bounds response bodies and +connection/operation timeouts, and disables automatic redirects. It implements +one request attempt only: neither the adapter nor the generic transport retries. +Cleartext HTTP is accepted solely for explicit loopback tests; provider traffic +uses HTTPS. + +The browser adapter launches `xdg-open` directly without a shell, so an +authorization URL is never interpreted as shell syntax. The callback receiver +binds IPv4 `127.0.0.1` on a dynamically assigned port, advertises that port with +the `localhost` host and fixed `/nocal/oauth/callback` path, and handles one +bounded callback before closing. Bounded header/input sizes and a deadline keep +a local peer from holding the authorization process indefinitely. + +The Entra registration must configure Nocal as a Mobile and desktop public +client with `http://localhost/nocal/oauth/callback`. Microsoft ignores the port +when matching a `localhost` redirect, which permits the runtime-selected port. +Using an HTTP redirect with the literal `127.0.0.1` host instead requires editing +the application manifest, and IPv6 loopback redirect addresses are currently +unsupported. The advertised `localhost` URL therefore remains distinct from the +IPv4-only listener binding. + +Concrete token JSON parsing, Secret Service storage, Graph requests, account UI, +and usable account setup are still absent. libsecret's synchronous calls may +block; the next adapter isolates them behind an off-render-thread boundary +rather than calling them from the TUI loop. Read-only Graph discovery and delta +follow token parsing and secret persistence. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 4be31ec..46ff0d0 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -141,13 +141,25 @@ desktop binary. The provider-neutral HTTP seam accepts an injected transport and can therefore be exercised with deterministic fakes. It intentionally owns no generic retry -policy; provider operations must decide whether a retry is safe. This phase does -not make Office 365 usable yet. It includes no concrete HTTP transport, real -browser/loopback adapter, token JSON parser, Secret Service backend, Graph call, -or usable account setup. The next product slice supplies concrete desktop, -network, and secret-storage adapters, followed by read-only Graph calendar -discovery and delta synchronization. Remote writes remain gated on the -durability and conflict paths being exercised end to end. +policy; provider operations must decide whether a retry is safe. The concrete +Linux adapter uses libcurl while preserving +TLS certificate and host verification, bounds responses and timeouts, and +follows neither redirects nor retries automatically. Cleartext HTTP exists only +for loopback tests. Browser opening invokes `xdg-open` without a shell. + +The receiver listens only on `127.0.0.1`, chooses a dynamic port, advertises a +`localhost` URL with the fixed `/nocal/oauth/callback` path, and processes one +bounded callback. The Microsoft registration is a Mobile and desktop public +client with `http://localhost/nocal/oauth/callback`. Microsoft ignores the port +when matching localhost redirects. Literal HTTP `127.0.0.1` registration needs +a manifest edit, and IPv6 loopback redirects are currently unsupported. + +These adapters still do not make Office 365 usable. They include no token JSON +parser, Secret Service backend, Graph call, or account UI. libsecret exposes +synchronous operations that may block, so the next phase isolates its adapter +off the render thread. Token parsing and secure persistence come before +read-only Graph calendar discovery and delta synchronization. Remote writes +remain gated on the durability and conflict paths being exercised end to end. ## Local file interchange diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b3771ec..b7af389 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,9 +46,21 @@ Completed HTTP/OAuth boundary: block user consent - No client secret in the open-source desktop binary +Completed concrete Linux adapters: + +- libcurl transport with verified TLS, bounded responses/timeouts, no automatic + redirects/retries, and cleartext HTTP only for loopback tests +- Shell-free `xdg-open` launcher and one-shot `127.0.0.1` callback receiver + advertising `http://localhost:/nocal/oauth/callback` +- Mobile/Desktop public-client registration at + `http://localhost/nocal/oauth/callback`; Microsoft ignores localhost ports, + while literal HTTP `127.0.0.1` needs a manifest edit and IPv6 loopback is not + supported + Next provider-boundary slices: -- Concrete HTTP, browser/loopback, token JSON, and Secret Service adapters +- Token JSON parsing and a Secret Service adapter isolated off the render thread + because synchronous libsecret calls may block - OAuth secrets in Secret Service, never SQLite - Generic `SyncProvider` contract and observable sync status @@ -65,12 +77,11 @@ Next provider-boundary slices: ETag-aware remote writes after the conflict boundary is proven - Account/calendar management UI and per-calendar ANSI identity -The cache and generic HTTP/OAuth boundaries are prerequisites, not usable -Microsoft 365 integration. The boundary phase has no concrete HTTP transport, -real browser/loopback adapter, token parser, Secret Service backend, Graph call, -or account setup. The immediate sequence is concrete desktop, HTTP, and Secret -Service adapters, then read-only Microsoft Graph discovery and delta -synchronization. +The cache, generic HTTP/OAuth boundary, and concrete Linux adapters are +prerequisites, not usable Microsoft 365 integration. There is still no token +parser, Secret Service backend, Graph call, or account UI. The immediate sequence is token parsing plus an +off-render-thread Secret Service adapter, then read-only Microsoft Graph +discovery and delta synchronization. ## 1.0 — distribution quality diff --git a/include/nocal/sync/curl_http.hpp b/include/nocal/sync/curl_http.hpp new file mode 100644 index 0000000..213dda1 --- /dev/null +++ b/include/nocal/sync/curl_http.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "nocal/sync/http.hpp" + +#include +#include +#include + +namespace nocal::sync { + +class CurlHttpError : public std::runtime_error { +public: + enum class Kind { + invalid_request, + initialization_failed, + transport_failed, + response_too_large, + }; + + CurlHttpError(Kind kind, std::string message); + + [[nodiscard]] Kind kind() const noexcept; + +private: + Kind kind_; +}; + +struct CurlHttpOptions { + long connect_timeout_milliseconds{10'000}; + long total_timeout_milliseconds{30'000}; + std::size_t maximum_response_bytes{8U * 1024U * 1024U}; + std::string user_agent{"nocal/0.1"}; +}; + +class CurlHttpTransport final : public HttpTransport { +public: + explicit CurlHttpTransport(CurlHttpOptions options = {}); + + [[nodiscard]] HttpResponse send(const HttpRequest& request) override; + +private: + CurlHttpOptions options_; +}; + +} // namespace nocal::sync diff --git a/include/nocal/sync/desktop_oauth.hpp b/include/nocal/sync/desktop_oauth.hpp new file mode 100644 index 0000000..babdce6 --- /dev/null +++ b/include/nocal/sync/desktop_oauth.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "nocal/sync/oauth.hpp" + +#include +#include +#include + +namespace nocal::sync { + +class XdgBrowserLauncher final : public BrowserLauncher { +public: + explicit XdgBrowserLauncher(std::string executable = "xdg-open"); + + void open(const std::string& url) override; + +private: + std::string executable_; +}; + +class LoopbackCallbackReceiver final : public AuthorizationCallbackReceiver { +public: + explicit LoopbackCallbackReceiver( + std::chrono::milliseconds timeout = std::chrono::minutes{5}); + ~LoopbackCallbackReceiver(); + + LoopbackCallbackReceiver(const LoopbackCallbackReceiver&) = delete; + LoopbackCallbackReceiver& operator=(const LoopbackCallbackReceiver&) = delete; + LoopbackCallbackReceiver(LoopbackCallbackReceiver&&) noexcept; + LoopbackCallbackReceiver& operator=(LoopbackCallbackReceiver&&) noexcept; + + [[nodiscard]] std::string redirect_uri() const override; + AuthorizationCallback receive() override; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace nocal::sync diff --git a/meson.build b/meson.build index 2259c9f..d847f4e 100644 --- a/meson.build +++ b/meson.build @@ -14,6 +14,7 @@ project( inc = include_directories('include') sqlite = dependency('sqlite3') libcrypto = dependency('libcrypto') +libcurl = dependency('libcurl') threads = dependency('threads') nocal_sources = files( 'src/domain/calendar_transfer.cpp', @@ -22,6 +23,8 @@ nocal_sources = files( 'src/domain/month_layout.cpp', 'src/storage/ics_store.cpp', 'src/storage/sync_cache.cpp', + 'src/sync/curl_http.cpp', + 'src/sync/desktop_oauth.cpp', 'src/sync/oauth.cpp', 'src/tui/Screen.cpp', 'src/tui/EventEditor.cpp', @@ -29,9 +32,9 @@ nocal_sources = files( 'src/tui/TuiApp.cpp', ) nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc, - dependencies: [sqlite, libcrypto]) + dependencies: [sqlite, libcrypto, libcurl]) nocal_dep = declare_dependency(include_directories: inc, link_with: nocal_lib, - dependencies: [sqlite, libcrypto]) + dependencies: [sqlite, libcrypto, libcurl]) nocal_executable = executable('nocal', 'src/main.cpp', dependencies: nocal_dep, install: true) @@ -54,6 +57,12 @@ test('oauth', oauth_tests) oauth_security_tests = executable('oauth-security-tests', 'tests/oauth_security_tests.cpp', dependencies: nocal_dep) test('oauth-security', oauth_security_tests) +curl_http_tests = executable('curl-http-tests', 'tests/curl_http_tests.cpp', + dependencies: [nocal_dep, threads]) +test('curl-http', curl_http_tests) +desktop_oauth_tests = executable('desktop-oauth-tests', + 'tests/desktop_oauth_tests.cpp', dependencies: [nocal_dep, threads]) +test('desktop-oauth', desktop_oauth_tests) tui_tests = executable('tui-tests', 'tests/tui_tests.cpp', dependencies: nocal_dep) test('tui', tui_tests) diff --git a/shell.nix b/shell.nix index ae02d3f..f781cf8 100644 --- a/shell.nix +++ b/shell.nix @@ -8,8 +8,10 @@ pkgs.mkShell { ]; buildInputs = with pkgs; [ + curl gcc openssl sqlite + xdg-utils ]; } diff --git a/src/sync/curl_http.cpp b/src/sync/curl_http.cpp new file mode 100644 index 0000000..68af9ec --- /dev/null +++ b/src/sync/curl_http.cpp @@ -0,0 +1,442 @@ +#include "nocal/sync/curl_http.hpp" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nocal::sync { +namespace { + +[[nodiscard]] bool ascii_iequals(std::string_view left, std::string_view right) { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0; index < left.size(); ++index) { + const auto lhs = static_cast(left[index]); + const auto rhs = static_cast(right[index]); + const auto lower = [](unsigned char value) { + return value >= 'A' && value <= 'Z' ? static_cast(value + ('a' - 'A')) + : value; + }; + if (lower(lhs) != lower(rhs)) { + return false; + } + } + return true; +} + +[[nodiscard]] bool unsafe_text(std::string_view value) { + return std::any_of(value.begin(), value.end(), [](unsigned char character) { + return character < 0x20U || character == 0x7fU; + }); +} + +[[nodiscard]] bool valid_port(std::string_view value) { + if (value.empty() || value.size() > 5) { + return false; + } + unsigned int port = 0; + for (const unsigned char character : value) { + if (character < '0' || character > '9') { + return false; + } + port = port * 10U + static_cast(character - '0'); + } + return port > 0U && port <= 65'535U; +} + +[[nodiscard]] bool valid_host(std::string_view host) { + if (host.empty()) { + return false; + } + if (host.front() == '[') { + if (host.size() < 3 || host.back() != ']') { + return false; + } + const std::string address(host.substr(1, host.size() - 2)); + in6_addr parsed{}; + return ::inet_pton(AF_INET6, address.c_str(), &parsed) == 1; + } + for (std::size_t index = 0; index < host.size(); ++index) { + const auto character = static_cast(host[index]); + const bool unreserved = (character >= 'A' && character <= 'Z') + || (character >= 'a' && character <= 'z') + || (character >= '0' && character <= '9') || character == '-' || character == '.' + || character == '_' || character == '~'; + const bool sub_delimiter = character == '!' || character == '$' || character == '&' + || character == '\'' || character == '(' || character == ')' || character == '*' + || character == '+' || character == ',' || character == ';' || character == '='; + if (unreserved || sub_delimiter) { + continue; + } + const auto hexadecimal = [](unsigned char value) { + return (value >= '0' && value <= '9') || (value >= 'A' && value <= 'F') + || (value >= 'a' && value <= 'f'); + }; + if (character != '%' || index + 2 >= host.size() + || !hexadecimal(static_cast(host[index + 1])) + || !hexadecimal(static_cast(host[index + 2]))) { + return false; + } + index += 2; + } + return true; +} + +struct ParsedUrl { + bool loopback{false}; + bool cleartext{false}; +}; + +[[nodiscard]] ParsedUrl parse_url(std::string_view url) { + if (url.empty() || unsafe_text(url) || url.find(' ') != std::string_view::npos + || url.find('\\') != std::string_view::npos) { + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + const std::size_t scheme_end = url.find("://"); + if (scheme_end == std::string_view::npos) { + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + const std::string_view scheme = url.substr(0, scheme_end); + const bool https = ascii_iequals(scheme, "https"); + const bool http = ascii_iequals(scheme, "http"); + if (!https && !http) { + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + + const std::size_t authority_start = scheme_end + 3; + const std::size_t authority_end = url.find_first_of("/?#", authority_start); + const std::string_view authority = url.substr(authority_start, + authority_end == std::string_view::npos ? std::string_view::npos + : authority_end - authority_start); + if (authority.empty() || authority.find('@') != std::string_view::npos) { + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + + std::string_view host; + if (authority.front() == '[') { + const std::size_t close = authority.find(']'); + if (close == std::string_view::npos) { + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + host = authority.substr(0, close + 1); + const std::string_view remainder = authority.substr(close + 1); + if (!remainder.empty() + && (remainder.front() != ':' || !valid_port(remainder.substr(1)))) { + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + } else { + const std::size_t colon = authority.rfind(':'); + if (colon == std::string_view::npos) { + host = authority; + } else { + if (authority.find(':') != colon || !valid_port(authority.substr(colon + 1))) { + throw CurlHttpError( + CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + host = authority.substr(0, colon); + } + } + if (!valid_host(host)) { + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + + const bool loopback = ascii_iequals(host, "localhost") || host == "127.0.0.1" + || host == "[::1]"; + if (http && !loopback) { + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request URL"); + } + return {loopback, http}; +} + +[[nodiscard]] bool valid_header_name(std::string_view name) { + if (name.empty()) { + return false; + } + return std::all_of(name.begin(), name.end(), [](unsigned char character) { + return (character >= 'A' && character <= 'Z') + || (character >= 'a' && character <= 'z') + || (character >= '0' && character <= '9') || character == '!' || character == '#' + || character == '$' || character == '%' || character == '&' || character == '\'' + || character == '*' || character == '+' || character == '-' || character == '.' + || character == '^' || character == '_' || character == '`' || character == '|' + || character == '~'; + }); +} + +void validate_headers(const std::vector& headers) { + for (const HttpHeader& header : headers) { + if (!valid_header_name(header.name) + || header.value.find_first_of("\r\n\0", 0, 3) != std::string::npos) { + throw CurlHttpError( + CurlHttpError::Kind::invalid_request, "invalid HTTP request header"); + } + } +} + +void initialize_curl() { + static std::once_flag once; + std::call_once(once, [] { + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + throw CurlHttpError( + CurlHttpError::Kind::initialization_failed, "HTTP transport initialization failed"); + } + }); +} + +struct EasyCleanup { + void operator()(CURL* handle) const noexcept { curl_easy_cleanup(handle); } +}; + +struct HeaderList { + ~HeaderList() { curl_slist_free_all(value); } + curl_slist* value{nullptr}; +}; + +struct ResponseContext { + std::size_t maximum_bytes{0}; + std::size_t received_bytes{0}; + bool too_large{false}; + bool callback_failed{false}; + std::string body; + std::vector headers; +}; + +[[nodiscard]] bool socket_address_is_loopback(const curl_sockaddr& address) noexcept { + if (address.family == AF_INET + && address.addrlen >= static_cast(sizeof(sockaddr_in))) { + const auto* ipv4 = reinterpret_cast(&address.addr); + return (ntohl(ipv4->sin_addr.s_addr) & 0xff00'0000U) == 0x7f00'0000U; + } + if (address.family == AF_INET6 + && address.addrlen >= static_cast(sizeof(sockaddr_in6))) { + const auto* ipv6 = reinterpret_cast(&address.addr); + return IN6_IS_ADDR_LOOPBACK(&ipv6->sin6_addr) != 0; + } + return false; +} + +curl_socket_t open_socket( + void* user_data, curlsocktype purpose, struct curl_sockaddr* address) noexcept { + if (address == nullptr || purpose != CURLSOCKTYPE_IPCXN) { + return CURL_SOCKET_BAD; + } + const bool enforce_loopback = *static_cast(user_data); + if (enforce_loopback && !socket_address_is_loopback(*address)) { + return CURL_SOCKET_BAD; + } + return ::socket( + address->family, address->socktype | SOCK_CLOEXEC, address->protocol); +} + +[[nodiscard]] bool add_received_bytes(ResponseContext& context, std::size_t size) noexcept { + if (size > context.maximum_bytes - context.received_bytes) { + context.too_large = true; + return false; + } + context.received_bytes += size; + return true; +} + +std::size_t receive_body(char* data, std::size_t size, std::size_t count, void* user_data) noexcept { + auto& context = *static_cast(user_data); + if (count != 0 && size > std::numeric_limits::max() / count) { + context.too_large = true; + return 0; + } + const std::size_t bytes = size * count; + if (!add_received_bytes(context, bytes)) { + return 0; + } + try { + context.body.append(data, bytes); + } catch (...) { + context.callback_failed = true; + return 0; + } + return bytes; +} + +void capture_header(ResponseContext& context, std::string_view line) { + while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) { + line.remove_suffix(1); + } + if (line.empty()) { + return; + } + if (line.size() >= 5 && ascii_iequals(line.substr(0, 5), "HTTP/")) { + context.headers.clear(); + return; + } + const std::size_t colon = line.find(':'); + if (colon == std::string_view::npos || colon == 0) { + return; + } + std::string_view value = line.substr(colon + 1); + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1); + } + while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) { + value.remove_suffix(1); + } + context.headers.push_back({std::string(line.substr(0, colon)), std::string(value)}); +} + +std::size_t receive_header( + char* data, std::size_t size, std::size_t count, void* user_data) noexcept { + auto& context = *static_cast(user_data); + if (count != 0 && size > std::numeric_limits::max() / count) { + context.too_large = true; + return 0; + } + const std::size_t bytes = size * count; + if (!add_received_bytes(context, bytes)) { + return 0; + } + try { + capture_header(context, std::string_view(data, bytes)); + } catch (...) { + context.callback_failed = true; + return 0; + } + return bytes; +} + +template +void set_option(CURL* handle, CURLoption option, Value value) { + if (curl_easy_setopt(handle, option, value) != CURLE_OK) { + throw CurlHttpError( + CurlHttpError::Kind::initialization_failed, "HTTP transport setup failed"); + } +} + +[[nodiscard]] const char* method_name(HttpMethod method) { + switch (method) { + case HttpMethod::get: return "GET"; + case HttpMethod::post: return "POST"; + case HttpMethod::put: return "PUT"; + case HttpMethod::patch: return "PATCH"; + case HttpMethod::delete_: return "DELETE"; + } + throw CurlHttpError(CurlHttpError::Kind::invalid_request, "invalid HTTP request method"); +} + +} // namespace + +CurlHttpError::CurlHttpError(Kind kind, std::string message) + : std::runtime_error(std::move(message)), kind_(kind) {} + +CurlHttpError::Kind CurlHttpError::kind() const noexcept { return kind_; } + +CurlHttpTransport::CurlHttpTransport(CurlHttpOptions options) : options_(std::move(options)) { + if (options_.connect_timeout_milliseconds <= 0 || options_.total_timeout_milliseconds <= 0 + || options_.maximum_response_bytes == 0 || unsafe_text(options_.user_agent)) { + throw CurlHttpError( + CurlHttpError::Kind::invalid_request, "invalid HTTP transport configuration"); + } + initialize_curl(); +} + +HttpResponse CurlHttpTransport::send(const HttpRequest& request) { + const ParsedUrl parsed_url = parse_url(request.url); + validate_headers(request.headers); + + std::unique_ptr handle(curl_easy_init()); + if (!handle) { + throw CurlHttpError( + CurlHttpError::Kind::initialization_failed, "HTTP transport initialization failed"); + } + + ResponseContext response; + response.maximum_bytes = options_.maximum_response_bytes; + HeaderList request_headers; + for (const HttpHeader& header : request.headers) { + std::string encoded = header.name; + if (header.value.empty()) { + encoded.push_back(';'); + } else { + encoded += ": "; + encoded += header.value; + } + curl_slist* appended = curl_slist_append(request_headers.value, encoded.c_str()); + if (appended == nullptr) { + throw CurlHttpError( + CurlHttpError::Kind::initialization_failed, "HTTP transport setup failed"); + } + request_headers.value = appended; + } + + set_option(handle.get(), CURLOPT_URL, request.url.c_str()); + set_option(handle.get(), CURLOPT_PROTOCOLS_STR, "http,https"); + set_option(handle.get(), CURLOPT_REDIR_PROTOCOLS_STR, "http,https"); + set_option(handle.get(), CURLOPT_FOLLOWLOCATION, 0L); + set_option(handle.get(), CURLOPT_MAXREDIRS, 0L); + set_option(handle.get(), CURLOPT_SSL_VERIFYPEER, 1L); + set_option(handle.get(), CURLOPT_SSL_VERIFYHOST, 2L); + set_option(handle.get(), CURLOPT_NOSIGNAL, 1L); + set_option(handle.get(), CURLOPT_NETRC, static_cast(CURL_NETRC_IGNORED)); + set_option(handle.get(), CURLOPT_COOKIEFILE, static_cast(nullptr)); + set_option(handle.get(), CURLOPT_COOKIEJAR, static_cast(nullptr)); + set_option(handle.get(), CURLOPT_CONNECTTIMEOUT_MS, options_.connect_timeout_milliseconds); + set_option(handle.get(), CURLOPT_TIMEOUT_MS, options_.total_timeout_milliseconds); + set_option(handle.get(), CURLOPT_USERAGENT, options_.user_agent.c_str()); + set_option(handle.get(), CURLOPT_CUSTOMREQUEST, method_name(request.method)); + set_option(handle.get(), CURLOPT_HTTPHEADER, request_headers.value); + set_option(handle.get(), CURLOPT_WRITEFUNCTION, &receive_body); + set_option(handle.get(), CURLOPT_WRITEDATA, &response); + set_option(handle.get(), CURLOPT_HEADERFUNCTION, &receive_header); + set_option(handle.get(), CURLOPT_HEADERDATA, &response); + const bool enforce_loopback_socket = parsed_url.cleartext; + set_option(handle.get(), CURLOPT_OPENSOCKETFUNCTION, &open_socket); + set_option(handle.get(), CURLOPT_OPENSOCKETDATA, &enforce_loopback_socket); +#ifdef CURLOPT_SUPPRESS_CONNECT_HEADERS + set_option(handle.get(), CURLOPT_SUPPRESS_CONNECT_HEADERS, 1L); +#endif + if (parsed_url.loopback) { + set_option(handle.get(), CURLOPT_NOPROXY, "*"); + } + if (!request.body.empty()) { + if (request.body.size() > static_cast(std::numeric_limits::max())) { + throw CurlHttpError( + CurlHttpError::Kind::invalid_request, "HTTP request body is too large"); + } + set_option(handle.get(), CURLOPT_POSTFIELDS, request.body.data()); + set_option(handle.get(), CURLOPT_POSTFIELDSIZE_LARGE, + static_cast(request.body.size())); + } + + const CURLcode result = curl_easy_perform(handle.get()); + if (result != CURLE_OK) { + if (response.too_large) { + throw CurlHttpError( + CurlHttpError::Kind::response_too_large, "HTTP response exceeded size limit"); + } + throw CurlHttpError( + CurlHttpError::Kind::transport_failed, "HTTP transport request failed"); + } + if (response.callback_failed) { + throw CurlHttpError( + CurlHttpError::Kind::transport_failed, "HTTP response processing failed"); + } + + long status = 0; + if (curl_easy_getinfo(handle.get(), CURLINFO_RESPONSE_CODE, &status) != CURLE_OK) { + throw CurlHttpError( + CurlHttpError::Kind::transport_failed, "HTTP response status unavailable"); + } + return {static_cast(status), std::move(response.headers), std::move(response.body)}; +} + +} // namespace nocal::sync diff --git a/src/sync/desktop_oauth.cpp b/src/sync/desktop_oauth.cpp new file mode 100644 index 0000000..c450fa1 --- /dev/null +++ b/src/sync/desktop_oauth.cpp @@ -0,0 +1,529 @@ +#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 +#include + +extern char** environ; + +namespace nocal::sync { +namespace { + +constexpr std::string_view callback_path = "/nocal/oauth/callback"; +constexpr std::size_t maximum_header_size = 16U * 1024U; + +class Descriptor { +public: + Descriptor() = default; + explicit Descriptor(int value) : value_(value) {} + ~Descriptor() { reset(); } + + 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) { + reset(std::exchange(other.value_, -1)); + } + return *this; + } + + [[nodiscard]] int get() const noexcept { return value_; } + [[nodiscard]] explicit operator bool() const noexcept { return value_ >= 0; } + + void reset(int value = -1) noexcept { + if (value_ >= 0) { + static_cast(::close(value_)); + } + value_ = value; + } + +private: + int value_{-1}; +}; + +[[nodiscard]] bool contains_nul(std::string_view value) { + return value.find('\0') != std::string_view::npos; +} + +[[nodiscard]] bool contains_ascii_control(std::string_view value) { + return std::ranges::any_of(value, [](unsigned char character) { + return character < 0x20U || character == 0x7fU; + }); +} + +[[noreturn]] void throw_system_error(std::string_view operation, int error = errno) { + throw std::runtime_error( + std::string{operation} + " failed: " + std::string{std::strerror(error)}); +} + +void set_close_on_exec(int descriptor) { + const int flags = ::fcntl(descriptor, F_GETFD); + if (flags < 0 || ::fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC) < 0) { + throw_system_error("setting close-on-exec"); + } +} + +[[nodiscard]] std::chrono::steady_clock::time_point make_deadline( + std::chrono::milliseconds timeout) { + using Clock = std::chrono::steady_clock; + const auto maximum = + std::chrono::duration_cast(Clock::duration::max() / 2); + if (timeout >= maximum) { + return Clock::time_point::max(); + } + return Clock::now() + std::chrono::duration_cast(timeout); +} + +[[nodiscard]] int remaining_poll_timeout(std::chrono::steady_clock::time_point deadline) { + using Clock = std::chrono::steady_clock; + if (deadline == Clock::time_point::max()) { + return INT_MAX; + } + const auto now = Clock::now(); + if (now >= deadline) { + return 0; + } + const auto remaining = deadline - now; + auto milliseconds = std::chrono::duration_cast(remaining); + if (std::chrono::duration_cast(milliseconds) < remaining) { + milliseconds += std::chrono::milliseconds{1}; + } + return milliseconds.count() > INT_MAX ? INT_MAX : static_cast(milliseconds.count()); +} + +void wait_until_readable(int descriptor, std::chrono::steady_clock::time_point deadline, + std::string_view timeout_message) { + while (true) { + pollfd pending{descriptor, POLLIN, 0}; + const int result = ::poll(&pending, 1, remaining_poll_timeout(deadline)); + if (result > 0) { + if ((pending.revents & POLLIN) != 0) { + return; + } + throw std::runtime_error("OAuth loopback socket closed unexpectedly"); + } + if (result == 0) { + throw std::runtime_error(std::string{timeout_message}); + } + if (errno != EINTR) { + throw_system_error("waiting for OAuth loopback socket"); + } + } +} + +[[nodiscard]] Descriptor accept_client( + int listener, std::chrono::steady_clock::time_point deadline) { + while (true) { + wait_until_readable(listener, deadline, "OAuth loopback callback timed out"); + const int client = ::accept(listener, nullptr, nullptr); + if (client >= 0) { + Descriptor result{client}; + set_close_on_exec(client); + return result; + } + if (errno != EINTR) { + throw_system_error("accepting OAuth loopback callback"); + } + } +} + +[[nodiscard]] std::string read_header( + int client, std::chrono::steady_clock::time_point deadline) { + std::string input; + input.reserve(maximum_header_size); + std::array buffer{}; + while (true) { + const std::size_t terminator = input.find("\r\n\r\n"); + if (terminator != std::string::npos) { + const std::size_t header_size = terminator + 4; + if (header_size > maximum_header_size || input.size() != header_size) { + throw std::runtime_error("invalid OAuth loopback HTTP request"); + } + return input; + } + if (input.size() >= maximum_header_size) { + throw std::runtime_error("OAuth loopback HTTP header is too large"); + } + wait_until_readable(client, deadline, "OAuth loopback callback timed out"); + const std::size_t remaining = maximum_header_size - input.size(); + const std::size_t count = std::min(buffer.size(), remaining); + const ssize_t received = ::recv(client, buffer.data(), count, 0); + if (received > 0) { + input.append(buffer.data(), static_cast(received)); + continue; + } + if (received == 0) { + throw std::runtime_error("incomplete OAuth loopback HTTP request"); + } + if (errno != EINTR) { + throw_system_error("reading OAuth loopback callback"); + } + } +} + +[[nodiscard]] bool ascii_iequals(std::string_view left, std::string_view right) { + if (left.size() != right.size()) { + return false; + } + for (std::size_t index = 0; index < left.size(); ++index) { + const auto lhs = static_cast(left[index]); + const auto rhs = static_cast(right[index]); + const auto ascii_lower = [](unsigned char character) { + return character >= 'A' && character <= 'Z' + ? static_cast(character + ('a' - 'A')) + : character; + }; + if (ascii_lower(lhs) != ascii_lower(rhs)) { + return false; + } + } + return true; +} + +[[nodiscard]] std::string_view trim_spaces(std::string_view value) { + while (!value.empty() && value.front() == ' ') { + value.remove_prefix(1); + } + while (!value.empty() && value.back() == ' ') { + value.remove_suffix(1); + } + return value; +} + +[[nodiscard]] bool valid_header_name(std::string_view name) { + if (name.empty()) { + return false; + } + constexpr std::string_view punctuation = "!#$%&'*+-.^_`|~"; + return std::ranges::all_of(name, [&](unsigned char character) { + const bool alphanumeric = (character >= 'A' && character <= 'Z') + || (character >= 'a' && character <= 'z') + || (character >= '0' && character <= '9'); + return alphanumeric + || punctuation.find(static_cast(character)) != std::string_view::npos; + }); +} + +[[nodiscard]] int hex_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 decode_query_component(std::string_view encoded) { + std::string decoded; + decoded.reserve(encoded.size()); + for (std::size_t index = 0; index < encoded.size(); ++index) { + unsigned char value = static_cast(encoded[index]); + if (value == '%') { + if (index + 2 >= encoded.size()) { + throw std::runtime_error("invalid OAuth callback percent encoding"); + } + const int high = hex_value(encoded[index + 1]); + const int low = hex_value(encoded[index + 2]); + if (high < 0 || low < 0) { + throw std::runtime_error("invalid OAuth callback percent encoding"); + } + value = static_cast((high << 4) | low); + index += 2; + } else if (value == '+') { + value = ' '; + } + if (value == 0U || value < 0x20U || value == 0x7fU) { + throw std::runtime_error("invalid OAuth callback query control character"); + } + decoded.push_back(static_cast(value)); + } + return decoded; +} + +struct ParsedRequest { + AuthorizationCallback callback; +}; + +[[nodiscard]] ParsedRequest parse_request( + std::string_view request, std::string_view expected_host) { + const std::size_t request_line_end = request.find("\r\n"); + if (request_line_end == std::string_view::npos || contains_nul(request) + || contains_ascii_control(request.substr(0, request_line_end))) { + throw std::runtime_error("invalid OAuth loopback request line"); + } + const std::string_view request_line = request.substr(0, request_line_end); + const std::size_t first_space = request_line.find(' '); + const std::size_t second_space = first_space == std::string_view::npos + ? std::string_view::npos + : request_line.find(' ', first_space + 1); + if (first_space == std::string_view::npos || second_space == std::string_view::npos + || request_line.find(' ', second_space + 1) != std::string_view::npos + || request_line.substr(0, first_space) != "GET" + || request_line.substr(second_space + 1) != "HTTP/1.1") { + throw std::runtime_error("invalid OAuth loopback request line"); + } + const std::string_view target = + request_line.substr(first_space + 1, second_space - first_space - 1); + const std::size_t question = target.find('?'); + if (question == std::string_view::npos || target.substr(0, question) != callback_path + || target.find('#', question + 1) != std::string_view::npos) { + throw std::runtime_error("invalid OAuth loopback callback path"); + } + + bool saw_host = false; + bool saw_content_length = false; + std::size_t offset = request_line_end + 2; + while (offset < request.size()) { + const std::size_t end = request.find("\r\n", offset); + if (end == std::string_view::npos) { + throw std::runtime_error("invalid OAuth loopback HTTP header"); + } + const std::string_view line = request.substr(offset, end - offset); + offset = end + 2; + if (line.empty()) { + break; + } + if (line.front() == ' ' || line.front() == '\t') { + throw std::runtime_error("invalid OAuth loopback folded header"); + } + const std::size_t colon = line.find(':'); + if (colon == std::string_view::npos || !valid_header_name(line.substr(0, colon))) { + throw std::runtime_error("invalid OAuth loopback HTTP header"); + } + const std::string_view name = line.substr(0, colon); + const std::string_view raw_value = line.substr(colon + 1); + if (contains_nul(raw_value) || std::ranges::any_of(raw_value, [](unsigned char value) { + return value < 0x20U || value == 0x7fU; + })) { + throw std::runtime_error("invalid OAuth loopback HTTP header value"); + } + const std::string_view value = trim_spaces(raw_value); + if (ascii_iequals(name, "Host")) { + if (saw_host || !ascii_iequals(value, expected_host)) { + throw std::runtime_error("invalid OAuth loopback Host header"); + } + saw_host = true; + } else if (ascii_iequals(name, "Transfer-Encoding")) { + throw std::runtime_error("OAuth loopback request body is not allowed"); + } else if (ascii_iequals(name, "Content-Length")) { + if (saw_content_length || value != "0") { + throw std::runtime_error("OAuth loopback request body is not allowed"); + } + saw_content_length = true; + } + } + if (!saw_host) { + throw std::runtime_error("OAuth loopback Host header is required"); + } + + ParsedRequest result; + bool saw_code = false; + bool saw_state = false; + bool saw_error = false; + bool saw_error_description = false; + std::string_view query = target.substr(question + 1); + if (query.empty()) { + throw std::runtime_error("OAuth callback query is empty"); + } + while (true) { + const std::size_t ampersand = query.find('&'); + const std::string_view field = query.substr(0, ampersand); + const std::size_t equals = field.find('='); + if (field.empty() || equals == std::string_view::npos || equals == 0) { + throw std::runtime_error("invalid OAuth callback query field"); + } + const std::string name = decode_query_component(field.substr(0, equals)); + std::string value = decode_query_component(field.substr(equals + 1)); + auto assign_known = [&](std::string_view known, bool& seen, std::string& destination) { + if (name != known) { + return false; + } + if (seen) { + throw std::runtime_error("duplicate OAuth callback query field"); + } + seen = true; + destination = std::move(value); + return true; + }; + static_cast(assign_known("code", saw_code, result.callback.code) + || assign_known("state", saw_state, result.callback.state) + || assign_known("error", saw_error, result.callback.error) + || assign_known("error_description", saw_error_description, + result.callback.error_description)); + + if (ampersand == std::string_view::npos) { + break; + } + query.remove_prefix(ampersand + 1); + } + return result; +} + +void send_response(int client, bool success) noexcept { + try { + static constexpr std::string_view success_body = "Nocal" + "

Authorization received. You may close this window.

"; + static constexpr std::string_view failure_body = "Nocal" + "

Invalid authorization callback.

"; + const std::string_view body = success ? success_body : failure_body; + std::string response = + success ? "HTTP/1.1 200 OK\r\n" : "HTTP/1.1 400 Bad Request\r\n"; + response += "Content-Type: text/html; charset=utf-8\r\n"; + response += "Cache-Control: no-store\r\nPragma: no-cache\r\nConnection: close\r\n"; + response += "Content-Length: " + std::to_string(body.size()) + "\r\n\r\n"; + response += body; + std::size_t offset = 0; + while (offset < response.size()) { + const ssize_t sent = + ::send(client, response.data() + offset, response.size() - offset, MSG_NOSIGNAL); + if (sent > 0) { + offset += static_cast(sent); + } else if (sent < 0 && errno == EINTR) { + continue; + } else { + return; + } + } + } catch (...) { + return; + } +} + +} // namespace + +XdgBrowserLauncher::XdgBrowserLauncher(std::string executable) + : executable_(std::move(executable)) { + if (executable_.empty() || contains_nul(executable_)) { + throw std::invalid_argument("browser executable must be a nonempty program name"); + } +} + +void XdgBrowserLauncher::open(const std::string& url) { + if (contains_ascii_control(url)) { + throw std::invalid_argument("browser URL contains an ASCII control character"); + } + std::array arguments{executable_.data(), const_cast(url.c_str()), nullptr}; + pid_t child = -1; + const int spawn_result = + ::posix_spawnp(&child, executable_.c_str(), nullptr, nullptr, arguments.data(), environ); + if (spawn_result != 0) { + throw_system_error("launching browser", spawn_result); + } + + int status = 0; + while (::waitpid(child, &status, 0) < 0) { + if (errno != EINTR) { + throw_system_error("waiting for browser"); + } + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + throw std::runtime_error("browser process did not exit successfully"); + } +} + +struct LoopbackCallbackReceiver::Impl { + explicit Impl(std::chrono::milliseconds requested_timeout) : timeout(requested_timeout) { + if (timeout <= std::chrono::milliseconds::zero()) { + throw std::invalid_argument("OAuth loopback timeout must be positive"); + } + Descriptor created{::socket(AF_INET, SOCK_STREAM, 0)}; + if (!created) { + throw_system_error("creating OAuth loopback listener"); + } + set_close_on_exec(created.get()); + const int enabled = 1; + if (::setsockopt(created.get(), SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)) != 0) { + throw_system_error("configuring OAuth loopback listener"); + } + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + if (::bind(created.get(), reinterpret_cast(&address), sizeof(address)) + != 0) { + throw_system_error("binding OAuth loopback listener"); + } + if (::listen(created.get(), 1) != 0) { + throw_system_error("listening for OAuth loopback callback"); + } + socklen_t address_size = sizeof(address); + if (::getsockname(created.get(), reinterpret_cast(&address), &address_size) + != 0) { + throw_system_error("reading OAuth loopback listener address"); + } + port = ntohs(address.sin_port); + listener = std::move(created); + } + + Descriptor listener; + std::chrono::milliseconds timeout; + unsigned int port{0}; + bool consumed{false}; +}; + +LoopbackCallbackReceiver::LoopbackCallbackReceiver(std::chrono::milliseconds timeout) + : impl_(std::make_unique(timeout)) {} + +LoopbackCallbackReceiver::~LoopbackCallbackReceiver() = default; +LoopbackCallbackReceiver::LoopbackCallbackReceiver(LoopbackCallbackReceiver&&) noexcept = default; +LoopbackCallbackReceiver& LoopbackCallbackReceiver::operator=( + LoopbackCallbackReceiver&&) noexcept = default; + +std::string LoopbackCallbackReceiver::redirect_uri() const { + if (!impl_) { + throw std::logic_error("OAuth loopback receiver was moved from"); + } + return "http://localhost:" + std::to_string(impl_->port) + std::string{callback_path}; +} + +AuthorizationCallback LoopbackCallbackReceiver::receive() { + if (!impl_) { + throw std::logic_error("OAuth loopback receiver was moved from"); + } + if (impl_->consumed || !impl_->listener) { + throw std::logic_error("OAuth loopback receiver is single-use"); + } + impl_->consumed = true; + Descriptor listener = std::move(impl_->listener); + const auto deadline = make_deadline(impl_->timeout); + Descriptor client = accept_client(listener.get(), deadline); + listener.reset(); + try { + const std::string request = read_header(client.get(), deadline); + const std::string expected_host = "localhost:" + std::to_string(impl_->port); + ParsedRequest parsed = parse_request(request, expected_host); + send_response(client.get(), true); + return std::move(parsed.callback); + } catch (...) { + send_response(client.get(), false); + throw; + } +} + +} // namespace nocal::sync diff --git a/tests/curl_http_tests.cpp b/tests/curl_http_tests.cpp new file mode 100644 index 0000000..3a47275 --- /dev/null +++ b/tests/curl_http_tests.cpp @@ -0,0 +1,418 @@ +#include "nocal/sync/curl_http.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using nocal::sync::CurlHttpError; +using nocal::sync::CurlHttpOptions; +using nocal::sync::CurlHttpTransport; +using nocal::sync::HttpHeader; +using nocal::sync::HttpMethod; +using nocal::sync::HttpRequest; +using nocal::sync::HttpResponse; + +void require(bool condition, std::string_view message) { + if (!condition) { + throw std::runtime_error(std::string(message)); + } +} + +void close_socket(int descriptor) { + if (descriptor >= 0) { + ::close(descriptor); + } +} + +void send_all(int descriptor, std::string_view data) { + std::size_t sent = 0; + while (sent < data.size()) { + const ssize_t count = ::send( + descriptor, data.data() + sent, data.size() - sent, MSG_NOSIGNAL); + if (count <= 0) { + return; + } + sent += static_cast(count); + } +} + +[[nodiscard]] std::size_t content_length(std::string_view headers) { + std::string lower(headers); + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char character) { + return character >= 'A' && character <= 'Z' + ? static_cast(character + ('a' - 'A')) + : static_cast(character); + }); + const std::string_view name = "\r\ncontent-length:"; + const std::size_t position = lower.find(name); + if (position == std::string::npos) { + return 0; + } + std::size_t offset = position + name.size(); + while (offset < lower.size() && (lower[offset] == ' ' || lower[offset] == '\t')) { + ++offset; + } + std::size_t length = 0; + while (offset < lower.size() && lower[offset] >= '0' && lower[offset] <= '9') { + length = length * 10U + static_cast(lower[offset] - '0'); + ++offset; + } + return length; +} + +[[nodiscard]] std::string receive_request(int descriptor) { + std::string request; + std::array buffer{}; + std::size_t expected = std::string::npos; + while (true) { + const ssize_t count = ::recv(descriptor, buffer.data(), buffer.size(), 0); + if (count <= 0) { + break; + } + request.append(buffer.data(), static_cast(count)); + const std::size_t end = request.find("\r\n\r\n"); + if (end != std::string::npos && expected == std::string::npos) { + expected = end + 4 + content_length(std::string_view(request).substr(0, end + 2)); + } + if (expected != std::string::npos && request.size() >= expected) { + break; + } + } + return request; +} + +class OneShotServer { +public: + using Handler = std::function; + + explicit OneShotServer(Handler handler) { + listener_ = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + require(listener_ >= 0, "unable to create test server socket"); + const int enabled = 1; + require(::setsockopt(listener_, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)) == 0, + "unable to configure test server socket"); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + require(::bind(listener_, reinterpret_cast(&address), sizeof(address)) == 0, + "unable to bind test server socket"); + socklen_t size = sizeof(address); + require(::getsockname(listener_, reinterpret_cast(&address), &size) == 0, + "unable to inspect test server socket"); + port_ = ntohs(address.sin_port); + require(::listen(listener_, 4) == 0, "unable to listen on test server socket"); + thread_ = std::thread([this, handler = std::move(handler)] { + try { + pollfd descriptor{listener_, POLLIN, 0}; + const int ready = ::poll(&descriptor, 1, 2'000); + if (ready > 0) { + const int connection = ::accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC); + if (connection >= 0) { + ++connections_; + handler(connection); + close_socket(connection); + } + } + } catch (...) { + error_ = std::current_exception(); + } + }); + } + + ~OneShotServer() { + if (thread_.joinable()) { + thread_.join(); + } + close_socket(listener_); + } + + OneShotServer(const OneShotServer&) = delete; + OneShotServer& operator=(const OneShotServer&) = delete; + + [[nodiscard]] std::string url(std::string_view path = "/") const { + return "http://127.0.0.1:" + std::to_string(port_) + std::string(path); + } + + [[nodiscard]] std::string localhost_url(std::string_view path = "/") const { + return "http://localhost:" + std::to_string(port_) + std::string(path); + } + + void finish() { + if (thread_.joinable()) { + thread_.join(); + } + if (error_) { + std::rethrow_exception(error_); + } + } + + [[nodiscard]] int connections() const noexcept { return connections_; } + +private: + int listener_{-1}; + unsigned short port_{0}; + std::thread thread_; + std::exception_ptr error_; + int connections_{0}; +}; + +template +CurlHttpError expect_curl_error(Action&& action, CurlHttpError::Kind kind) { + try { + action(); + } catch (const CurlHttpError& error) { + if (error.kind() != kind) { + throw std::runtime_error("curl error had kind " + + std::to_string(static_cast(error.kind())) + " instead of " + + std::to_string(static_cast(kind))); + } + return error; + } + throw std::runtime_error("expected CurlHttpError was not thrown"); +} + +[[nodiscard]] std::string method_text(HttpMethod method) { + switch (method) { + case HttpMethod::get: return "GET"; + case HttpMethod::post: return "POST"; + case HttpMethod::put: return "PUT"; + case HttpMethod::patch: return "PATCH"; + case HttpMethod::delete_: return "DELETE"; + } + throw std::runtime_error("unknown test method"); +} + +void test_methods_headers_binary_body_status_and_response_headers() { + for (const HttpMethod method : {HttpMethod::get, HttpMethod::post, HttpMethod::put, + HttpMethod::patch, HttpMethod::delete_}) { + std::string received; + OneShotServer server([&](int connection) { + received = receive_request(connection); + send_all(connection, + "HTTP/1.1 207 Multi-Status\r\nX-Order: first\r\n" + "X-Order: second\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok!"); + }); + CurlHttpTransport transport; + const std::string body{"A\0B", 3}; + const HttpResponse response = transport.send( + {method, server.url("/method"), {{"X-Test", "value"}}, body}); + server.finish(); + + require(server.connections() == 1, "transport opened more than one connection"); + require(received.starts_with(method_text(method) + " /method HTTP/1.1\r\n"), + "transport sent the wrong HTTP method"); + require(received.find("\r\nX-Test: value\r\n") != std::string::npos, + "transport did not preserve request header"); + require(received.size() >= body.size() + && received.compare(received.size() - body.size(), body.size(), body) == 0, + "transport did not preserve binary request body"); + require(response.status == 207 && response.body == "ok!", + "transport did not preserve response status/body"); + require(std::count(response.headers.begin(), response.headers.end(), + HttpHeader{"X-Order", "first"}) + == 1 + && std::count(response.headers.begin(), response.headers.end(), + HttpHeader{"X-Order", "second"}) + == 1, + "transport did not preserve ordinary duplicate response headers"); + } +} + +void test_redirect_is_not_followed() { + OneShotServer server([](int connection) { + (void)receive_request(connection); + send_all(connection, + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/never\r\n" + "Content-Length: 0\r\nConnection: close\r\n\r\n"); + }); + CurlHttpTransport transport; + const HttpResponse response = transport.send({HttpMethod::get, server.url(), {}, {}}); + server.finish(); + require(response.status == 302 && server.connections() == 1, + "transport followed or retried a redirect"); +} + +void test_localhost_resolution_remains_loopback() { + OneShotServer server([](int connection) { + (void)receive_request(connection); + send_all(connection, + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); + }); + CurlHttpTransport transport; + const HttpResponse response = + transport.send({HttpMethod::get, server.localhost_url(), {}, {}}); + server.finish(); + require(response.status == 200 && response.body == "ok" && server.connections() == 1, + "localhost did not resolve through the loopback-only socket policy"); +} + +void test_cumulative_response_limit() { + OneShotServer server([](int connection) { + (void)receive_request(connection); + send_all(connection, + "HTTP/1.1 200 OK\r\nContent-Length: 256\r\nConnection: close\r\n\r\n" + + std::string(256, 'x')); + }); + CurlHttpOptions options; + options.maximum_response_bytes = 100; + CurlHttpTransport transport(options); + expect_curl_error( + [&] { (void)transport.send({HttpMethod::get, server.url(), {}, {}}); }, + CurlHttpError::Kind::response_too_large); + server.finish(); + require(server.connections() == 1, "oversize response caused a retry"); +} + +void test_timeout_is_single_and_sanitized() { + OneShotServer server([](int connection) { + (void)receive_request(connection); + std::this_thread::sleep_for(std::chrono::milliseconds(150)); + send_all(connection, + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }); + CurlHttpOptions options; + options.connect_timeout_milliseconds = 40; + options.total_timeout_milliseconds = 40; + CurlHttpTransport transport(options); + const CurlHttpError error = expect_curl_error( + [&] { + (void)transport.send({HttpMethod::get, server.url("/?SECRET_QUERY"), + {{"X-Secret", "SECRET_HEADER"}}, "SECRET_BODY"}); + }, + CurlHttpError::Kind::transport_failed); + server.finish(); + const std::string message = error.what(); + require(message.find("SECRET") == std::string::npos + && message.find("127.0.0.1") == std::string::npos, + "transport error leaked request details"); + require(server.connections() == 1, "timed-out request was retried"); +} + +void test_invalid_options() { + std::vector invalid; + CurlHttpOptions value; + value.connect_timeout_milliseconds = 0; + invalid.push_back(value); + value = {}; + value.total_timeout_milliseconds = -1; + invalid.push_back(value); + value = {}; + value.maximum_response_bytes = 0; + invalid.push_back(value); + const std::vector invalid_agents = {std::string("bad\ragent"), + std::string("bad\nagent"), std::string("bad\0agent", 9), std::string("bad\x7f")}; + for (const std::string& agent : invalid_agents) { + value = {}; + value.user_agent = agent; + invalid.push_back(value); + } + for (const CurlHttpOptions& options : invalid) { + expect_curl_error( + [&] { CurlHttpTransport transport(options); }, CurlHttpError::Kind::invalid_request); + } +} + +void test_invalid_headers_and_urls_are_rejected_before_network() { + CurlHttpTransport transport; + const std::vector invalid_headers = {{"", "value"}, {"Bad Name", "value"}, + {"Bad:Name", "value"}, {"X-Test", "line\rbreak"}, {"X-Test", "line\nbreak"}, + {"X-Test", std::string("nul\0break", 9)}}; + for (std::size_t index = 0; index < invalid_headers.size(); ++index) { + try { + expect_curl_error( + [&] { + (void)transport.send({HttpMethod::get, "http://127.0.0.1:1/", + {invalid_headers[index]}, "SECRET_BODY"}); + }, + CurlHttpError::Kind::invalid_request); + } catch (const std::exception& error) { + throw std::runtime_error( + "invalid header case " + std::to_string(index) + ": " + error.what()); + } + } + + const std::vector invalid_urls = {"", "/relative", "ftp://example.test/", + "http://example.test/", "http://localhost.evil/", "http://user@localhost/", + "http://::1/", "http://[::1]evil/", "https:///missing", "http://localhost:0/", + "http://localhost:65536/", "http://localhost/a path", "https://[not-ipv6]/", + "https://bad%host/", + std::string("http://localhost/a\nb")}; + for (std::size_t index = 0; index < invalid_urls.size(); ++index) { + try { + const CurlHttpError error = expect_curl_error( + [&] { + (void)transport.send( + {HttpMethod::get, invalid_urls[index], {}, "SECRET_BODY"}); + }, + CurlHttpError::Kind::invalid_request); + require(std::string(error.what()).find("SECRET") == std::string::npos, + "invalid-request error leaked request data"); + } catch (const std::exception& error) { + throw std::runtime_error( + "invalid URL case " + std::to_string(index) + ": " + error.what()); + } + } +} + +void test_parallel_construction() { + std::mutex mutex; + std::exception_ptr error; + std::vector threads; + for (int index = 0; index < 8; ++index) { + threads.emplace_back([&] { + try { + CurlHttpTransport transport; + } catch (...) { + std::scoped_lock lock(mutex); + error = std::current_exception(); + } + }); + } + for (std::thread& thread : threads) { + thread.join(); + } + if (error) { + std::rethrow_exception(error); + } +} + +} // namespace + +int main() { + try { + test_methods_headers_binary_body_status_and_response_headers(); + test_redirect_is_not_followed(); + test_localhost_resolution_remains_loopback(); + test_cumulative_response_limit(); + test_timeout_is_single_and_sanitized(); + test_invalid_options(); + test_invalid_headers_and_urls_are_rejected_before_network(); + test_parallel_construction(); + } catch (const std::exception& error) { + std::cerr << "curl HTTP tests failed: " << error.what() << '\n'; + return 1; + } + std::cout << "curl HTTP tests passed\n"; + return 0; +} diff --git a/tests/desktop_oauth_tests.cpp b/tests/desktop_oauth_tests.cpp new file mode 100644 index 0000000..feab293 --- /dev/null +++ b/tests/desktop_oauth_tests.cpp @@ -0,0 +1,517 @@ +#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; +}