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:
28
README.md
28
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
45
include/nocal/sync/curl_http.hpp
Normal file
45
include/nocal/sync/curl_http.hpp
Normal 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
|
||||||
40
include/nocal/sync/desktop_oauth.hpp
Normal file
40
include/nocal/sync/desktop_oauth.hpp
Normal 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
|
||||||
13
meson.build
13
meson.build
@@ -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)
|
||||||
|
|||||||
@@ -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
442
src/sync/curl_http.cpp
Normal 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
529
src/sync/desktop_oauth.cpp
Normal 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
418
tests/curl_http_tests.cpp
Normal 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;
|
||||||
|
}
|
||||||
517
tests/desktop_oauth_tests.cpp
Normal file
517
tests/desktop_oauth_tests.cpp
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user