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.
This commit is contained in:
2026-07-18 11:33:06 +01:00
parent dd6e02cb55
commit 295acbc125
12 changed files with 2095 additions and 28 deletions

View File

@@ -132,11 +132,23 @@ or expected.
The generic HTTP boundary uses injected transports and deterministic fakes. It The generic HTTP boundary uses injected transports and deterministic fakes. It
does not retry requests: provider code must later make retry decisions with the does not retry requests: provider code must later make retry decisions with the
operation's idempotency and server response in view. This remains foundation, operation's idempotency and server response in view. The concrete Linux adapter
not working Office 365 integration. There is no concrete HTTP transport, uses libcurl with TLS verification and bounded
browser/loopback adapter, token JSON parser, Secret Service backend, Graph call, responses and timeouts, and neither automatic redirects nor retries. Cleartext
or usable account setup yet. OAuth tokens and other secrets will never be HTTP is limited to loopback tests. Browser launch executes `xdg-open` directly,
stored in SQLite; they belong in the Freedesktop Secret Service. The next never through a shell.
implementation sequence is concrete desktop, HTTP, and Secret Service adapters,
followed by read-only Microsoft Graph calendar discovery and delta The loopback receiver binds only `127.0.0.1`, advertises a dynamic-port
synchronization. `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.

View File

@@ -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 block consent. A desktop binary distributed as open source is not a confidential
client and must not embed or depend on a client secret. client and must not embed or depend on a client secret.
This boundary does not yet contain a concrete HTTP transport, system-browser or The generic boundary deliberately contains no concrete networking or desktop
loopback-listener adapter, token JSON parsing, a Secret Service backend, Graph code. The Linux adapters below supply the transport, browser launch, and
requests, or usable account setup. Those concrete desktop, HTTP, and secret loopback listener. Token JSON parsing, Secret Service storage, Graph requests,
storage adapters are the next slice. Read-only Microsoft Graph calendar and usable account setup remain later slices. Read-only Microsoft Graph calendar
discovery and delta synchronization follows; delta links remain opaque and are discovery and delta synchronization follows; delta links remain opaque and are
persisted only through the calendar/window transaction described above. Remote persisted only through the calendar/window transaction described above. Remote
writes and conflict resolution come later. 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.

View File

@@ -141,13 +141,25 @@ desktop binary.
The provider-neutral HTTP seam accepts an injected transport and can therefore The provider-neutral HTTP seam accepts an injected transport and can therefore
be exercised with deterministic fakes. It intentionally owns no generic retry be exercised with deterministic fakes. It intentionally owns no generic retry
policy; provider operations must decide whether a retry is safe. This phase does policy; provider operations must decide whether a retry is safe. The concrete
not make Office 365 usable yet. It includes no concrete HTTP transport, real Linux adapter uses libcurl while preserving
browser/loopback adapter, token JSON parser, Secret Service backend, Graph call, TLS certificate and host verification, bounds responses and timeouts, and
or usable account setup. The next product slice supplies concrete desktop, follows neither redirects nor retries automatically. Cleartext HTTP exists only
network, and secret-storage adapters, followed by read-only Graph calendar for loopback tests. Browser opening invokes `xdg-open` without a shell.
discovery and delta synchronization. Remote writes remain gated on the
durability and conflict paths being exercised end to end. 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 ## Local file interchange

View File

@@ -46,9 +46,21 @@ Completed HTTP/OAuth boundary:
block user consent block user consent
- No client secret in the open-source desktop binary - 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:<dynamic-port>/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: 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 - OAuth secrets in Secret Service, never SQLite
- Generic `SyncProvider` contract and observable sync status - 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 ETag-aware remote writes after the conflict boundary is proven
- Account/calendar management UI and per-calendar ANSI identity - Account/calendar management UI and per-calendar ANSI identity
The cache and generic HTTP/OAuth boundaries are prerequisites, not usable The cache, generic HTTP/OAuth boundary, and concrete Linux adapters are
Microsoft 365 integration. The boundary phase has no concrete HTTP transport, prerequisites, not usable Microsoft 365 integration. There is still no token
real browser/loopback adapter, token parser, Secret Service backend, Graph call, parser, Secret Service backend, Graph call, or account UI. The immediate sequence is token parsing plus an
or account setup. The immediate sequence is concrete desktop, HTTP, and Secret off-render-thread Secret Service adapter, then read-only Microsoft Graph
Service adapters, then read-only Microsoft Graph discovery and delta discovery and delta synchronization.
synchronization.
## 1.0 — distribution quality ## 1.0 — distribution quality

View File

@@ -0,0 +1,45 @@
#pragma once
#include "nocal/sync/http.hpp"
#include <cstddef>
#include <stdexcept>
#include <string>
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

View File

@@ -0,0 +1,40 @@
#pragma once
#include "nocal/sync/oauth.hpp"
#include <chrono>
#include <memory>
#include <string>
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> impl_;
};
} // namespace nocal::sync

View File

@@ -14,6 +14,7 @@ project(
inc = include_directories('include') inc = include_directories('include')
sqlite = dependency('sqlite3') sqlite = dependency('sqlite3')
libcrypto = dependency('libcrypto') libcrypto = dependency('libcrypto')
libcurl = dependency('libcurl')
threads = dependency('threads') threads = dependency('threads')
nocal_sources = files( nocal_sources = files(
'src/domain/calendar_transfer.cpp', 'src/domain/calendar_transfer.cpp',
@@ -22,6 +23,8 @@ nocal_sources = files(
'src/domain/month_layout.cpp', 'src/domain/month_layout.cpp',
'src/storage/ics_store.cpp', 'src/storage/ics_store.cpp',
'src/storage/sync_cache.cpp', 'src/storage/sync_cache.cpp',
'src/sync/curl_http.cpp',
'src/sync/desktop_oauth.cpp',
'src/sync/oauth.cpp', 'src/sync/oauth.cpp',
'src/tui/Screen.cpp', 'src/tui/Screen.cpp',
'src/tui/EventEditor.cpp', 'src/tui/EventEditor.cpp',
@@ -29,9 +32,9 @@ nocal_sources = files(
'src/tui/TuiApp.cpp', 'src/tui/TuiApp.cpp',
) )
nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc, 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, 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', nocal_executable = executable('nocal', 'src/main.cpp',
dependencies: nocal_dep, install: true) dependencies: nocal_dep, install: true)
@@ -54,6 +57,12 @@ test('oauth', oauth_tests)
oauth_security_tests = executable('oauth-security-tests', oauth_security_tests = executable('oauth-security-tests',
'tests/oauth_security_tests.cpp', dependencies: nocal_dep) 'tests/oauth_security_tests.cpp', dependencies: nocal_dep)
test('oauth-security', oauth_security_tests) 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', tui_tests = executable('tui-tests', 'tests/tui_tests.cpp',
dependencies: nocal_dep) dependencies: nocal_dep)
test('tui', tui_tests) test('tui', tui_tests)

View File

@@ -8,8 +8,10 @@ pkgs.mkShell {
]; ];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
curl
gcc gcc
openssl openssl
sqlite sqlite
xdg-utils
]; ];
} }

442
src/sync/curl_http.cpp Normal file
View File

@@ -0,0 +1,442 @@
#include "nocal/sync/curl_http.hpp"
#include <curl/curl.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <algorithm>
#include <cstddef>
#include <limits>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
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<unsigned char>(left[index]);
const auto rhs = static_cast<unsigned char>(right[index]);
const auto lower = [](unsigned char value) {
return value >= 'A' && value <= 'Z' ? static_cast<unsigned char>(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<unsigned int>(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<unsigned char>(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<unsigned char>(host[index + 1]))
|| !hexadecimal(static_cast<unsigned char>(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<HttpHeader>& 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<HttpHeader> headers;
};
[[nodiscard]] bool socket_address_is_loopback(const curl_sockaddr& address) noexcept {
if (address.family == AF_INET
&& address.addrlen >= static_cast<int>(sizeof(sockaddr_in))) {
const auto* ipv4 = reinterpret_cast<const sockaddr_in*>(&address.addr);
return (ntohl(ipv4->sin_addr.s_addr) & 0xff00'0000U) == 0x7f00'0000U;
}
if (address.family == AF_INET6
&& address.addrlen >= static_cast<int>(sizeof(sockaddr_in6))) {
const auto* ipv6 = reinterpret_cast<const sockaddr_in6*>(&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<const bool*>(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<ResponseContext*>(user_data);
if (count != 0 && size > std::numeric_limits<std::size_t>::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<ResponseContext*>(user_data);
if (count != 0 && size > std::numeric_limits<std::size_t>::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 <typename Value>
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<CURL, EasyCleanup> 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<long>(CURL_NETRC_IGNORED));
set_option(handle.get(), CURLOPT_COOKIEFILE, static_cast<const char*>(nullptr));
set_option(handle.get(), CURLOPT_COOKIEJAR, static_cast<const char*>(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::size_t>(std::numeric_limits<curl_off_t>::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<curl_off_t>(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<int>(status), std::move(response.headers), std::move(response.body)};
}
} // namespace nocal::sync

529
src/sync/desktop_oauth.cpp Normal file
View File

@@ -0,0 +1,529 @@
#include "nocal/sync/desktop_oauth.hpp"
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <climits>
#include <cstddef>
#include <cstring>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <poll.h>
#include <spawn.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
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<void>(::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<std::chrono::milliseconds>(Clock::duration::max() / 2);
if (timeout >= maximum) {
return Clock::time_point::max();
}
return Clock::now() + std::chrono::duration_cast<Clock::duration>(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<std::chrono::milliseconds>(remaining);
if (std::chrono::duration_cast<Clock::duration>(milliseconds) < remaining) {
milliseconds += std::chrono::milliseconds{1};
}
return milliseconds.count() > INT_MAX ? INT_MAX : static_cast<int>(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<char, 4096> 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<std::size_t>(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<unsigned char>(left[index]);
const auto rhs = static_cast<unsigned char>(right[index]);
const auto ascii_lower = [](unsigned char character) {
return character >= 'A' && character <= 'Z'
? static_cast<unsigned char>(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<char>(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<unsigned char>(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<unsigned char>((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<char>(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<void>(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 = "<!doctype html><title>Nocal</title>"
"<p>Authorization received. You may close this window.</p>";
static constexpr std::string_view failure_body = "<!doctype html><title>Nocal</title>"
"<p>Invalid authorization callback.</p>";
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<std::size_t>(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<char*, 3> arguments{executable_.data(), const_cast<char*>(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<const sockaddr*>(&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<sockaddr*>(&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<Impl>(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

418
tests/curl_http_tests.cpp Normal file
View File

@@ -0,0 +1,418 @@
#include "nocal/sync/curl_http.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstddef>
#include <cstring>
#include <exception>
#include <functional>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <string>
#include <string_view>
#include <thread>
#include <utility>
#include <vector>
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<std::size_t>(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<char>(character + ('a' - 'A'))
: static_cast<char>(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<std::size_t>(lower[offset] - '0');
++offset;
}
return length;
}
[[nodiscard]] std::string receive_request(int descriptor) {
std::string request;
std::array<char, 4096> 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<std::size_t>(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<void(int)>;
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<const sockaddr*>(&address), sizeof(address)) == 0,
"unable to bind test server socket");
socklen_t size = sizeof(address);
require(::getsockname(listener_, reinterpret_cast<sockaddr*>(&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 <typename Action>
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<int>(error.kind())) + " instead of "
+ std::to_string(static_cast<int>(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<CurlHttpOptions> 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<std::string> 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<HttpHeader> 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<std::string> 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<std::thread> 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;
}

View File

@@ -0,0 +1,517 @@
#include "nocal/sync/desktop_oauth.hpp"
#include <array>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <functional>
#include <iostream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
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 <typename Function>
void require_throws(Function&& function, std::string_view message) {
try {
function();
} catch (const std::exception&) {
return;
}
throw std::runtime_error(std::string{message});
}
class TempDirectory {
public:
TempDirectory() {
std::string pattern =
(std::filesystem::temp_directory_path() / "nocal-desktop-oauth-tests.XXXXXX")
.string();
std::vector<char> 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<void>(::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<void>(::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<bool>(input), "could not read browser helper output");
return {std::istreambuf_iterator<char>{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<unsigned int>(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<std::uint16_t>(port));
require(::connect(client.get(), reinterpret_cast<const sockaddr*>(&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<std::uint16_t>(port));
return ::connect(client.get(), reinterpret_cast<const sockaddr*>(&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<std::uint16_t>(port));
return ::connect(client.get(), reinterpret_cast<const sockaddr*>(&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<std::size_t>(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<char, 1024> buffer{};
while (true) {
const ssize_t received = ::recv(descriptor, buffer.data(), buffer.size(), 0);
if (received > 0) {
result.append(buffer.data(), static_cast<std::size_t>(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<void>(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<std::string(unsigned int)>& 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<void>(timed.receive()); },
"loopback receiver did not time out");
require(!can_connect_ipv4(timed_port), "timed-out receiver left listener open");
require_throws([&] { static_cast<void>(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<void>(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<void>(valid_exchange(used, "code=x&state=y"));
require_throws([&] { static_cast<void>(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<void>(source.redirect_uri()); },
"moved-from receiver remained usable");
static_cast<void>(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<void>(assignment_source.redirect_uri()); },
"move-assigned source remained usable");
static_cast<void>(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;
}