From 2870a46f4e9f8ac8f40df6e5767f966153005c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20L=C3=B8nner=C3=B8d=20Madsen?= Date: Wed, 16 Sep 2026 11:43:58 +0200 Subject: [PATCH] feat(juce): let an integration append its own client info segment ActivationConfig gains a `clientInfo` field, appended to the User-Agent after the module's own `moonbase-juce/` segment rather than replacing it, so a framework built on the module (HISE, a wrapper, a white-label host) can identify itself: moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0 The SDK now sanitises `client_info` when it builds the header: control characters become spaces, whitespace runs collapse, and the segment is capped at 256 characters. Both shipped transports splice headers into a single line, so a CR/LF in a caller-supplied value could otherwise inject a header. The emptiness check runs after sanitising, so a segment that sanitises away leaves no trailing space. --- docs/core-sdk.md | 18 +++++++ docs/juce-module.md | 31 +++++++++++ include/moonbase/client.hpp | 41 ++++++++++++++- include/moonbase/types.hpp | 6 +++ modules/moonbase_licensing/README.md | 3 ++ .../juce/ActivationConfig.h | 47 ++++++++++++++--- .../moonbase_licensing/moonbase/client.hpp | 41 ++++++++++++++- modules/moonbase_licensing/moonbase/types.hpp | 6 +++ tests/client_tests.cpp | 52 +++++++++++++++++++ tests/inventory_tests.cpp | 1 + tests/juce/controller_tests.cpp | 50 ++++++++++++++++++ 11 files changed, 285 insertions(+), 11 deletions(-) diff --git a/docs/core-sdk.md b/docs/core-sdk.md index 4103158..65152e6 100644 --- a/docs/core-sdk.md +++ b/docs/core-sdk.md @@ -89,11 +89,29 @@ options.endpoint = "https://demo.moonbase.sh"; options.product_id = "demo-app"; options.public_key = public_key_pem; options.account_id = "tenant-id"; // optional issuer check +options.client_info = "my-framework/1.2.0"; // optional, see below options.http_connect_timeout = std::chrono::seconds(10); options.http_request_timeout = std::chrono::seconds(30); moonbase::licensing licensing(options); +``` + +`client_info` identifies a higher-level integration built on top of the SDK (the +JUCE module sets `moonbase-juce/ (JUCE …; OS)`, for example). It is +appended to the `User-Agent` after `moonbase-cpp/`, so requests report +every layer, outermost last: +``` +User-Agent: moonbase-cpp/4.3.1 my-framework/1.2.0 +``` + +Use product tokens (`Name/Version`, with an optional `(comment)`) and keep it +ASCII. If your code sits on top of another integration that already set it, +append a segment rather than replacing the string. Control characters are +stripped and the value is capped at 256 characters when the header is built, so +a stray newline can never inject a header. + +```cpp auto request = licensing.request_activation(); std::cout << "Open: " << request.browser_url << "\n"; diff --git a/docs/juce-module.md b/docs/juce-module.md index 9c83f29..3f85f5f 100644 --- a/docs/juce-module.md +++ b/docs/juce-module.md @@ -499,3 +499,34 @@ The collected map flows into `moonbase::licensing_options::metadata` and is sent SDK's requests. When you don't set `config.applicationVersion`, it auto-fills from `JucePlugin_VersionString` in a plugin build (or the running app's version otherwise), so telemetry reports a version without extra wiring. + +### Identifying an integration built on the module + +Every request carries a layered `User-Agent`: the SDK, then this module, then anything a +higher-level integration adds. Out of the box: + +``` +User-Agent: moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) +``` + +A framework, wrapper or white-label host that embeds the module can add its own segment +with `config.clientInfo`: + +```cpp +config.clientInfo << " HISE/4.1.0"; // append, don't assign +``` + +``` +User-Agent: moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0 +``` + +Your segment is appended *after* the module's own, never in place of it, so support and +analytics still see which module and SDK version ran underneath. Append with `<<` rather +than assigning, so a stack of layers (framework, then a plugin built on it) each keeps its +mark. + +Use product tokens (`Name/Version`, with an optional `(comment)`), keep it ASCII, and keep +it short: control characters are stripped and the whole segment is capped at 256 characters +before it reaches the header. Unlike the analytics capture above, this is sent on every +request and is not gated by `config.analytics.enabled`; it identifies the software, not the +machine or the user. diff --git a/include/moonbase/client.hpp b/include/moonbase/client.hpp index 5098ee5..1ba4158 100644 --- a/include/moonbase/client.hpp +++ b/include/moonbase/client.hpp @@ -29,6 +29,38 @@ inline std::string version_string() #endif } +// Every transport we ship assembles headers into a single line, so a CR or LF in +// a caller-supplied client_info would inject a header and an embedded NUL would +// silently truncate one. Control characters become spaces, runs of whitespace +// collapse, and the result is trimmed and capped, so the User-Agent stays a +// well-formed, bounded token list however many integration layers appended to it. +inline std::string sanitize_client_info(const std::string& value) +{ + // Generous for a stack of "Name/Version (comment)" segments, and well under + // the per-line header limits proxies enforce: an oversized User-Agent fails + // as an opaque 400/431 that is undebuggable from the field. + constexpr std::string::size_type max_length = 256; + + std::string result; + for (const char character : value) { + const auto byte = static_cast(character); + if (byte < 0x20 || byte == 0x7F || byte == ' ') { + if (!result.empty() && result.back() != ' ') { + result.push_back(' '); // leading runs drop, interior runs collapse + } + } else { + result.push_back(character); + } + if (result.size() >= max_length) { + break; + } + } + while (!result.empty() && result.back() == ' ') { + result.pop_back(); + } + return result; +} + inline std::string request_path(const licensing_options& options) { return trim_trailing_slashes(options.endpoint) + @@ -74,8 +106,13 @@ inline std::map default_headers(const licensing_option const std::string& content_type = {}) { std::string user_agent = "moonbase-cpp/" + version_string(); - if (options.client_info && !options.client_info->empty()) { - user_agent += " " + *options.client_info; + if (options.client_info) { + // Sanitise before the emptiness check: a segment that is only whitespace + // or control characters must not leave a dangling separator behind. + const auto client_info = sanitize_client_info(*options.client_info); + if (!client_info.empty()) { + user_agent += " " + client_info; + } } std::map headers{ {"Accept", "application/json, application/jwt, text/plain"}, diff --git a/include/moonbase/types.hpp b/include/moonbase/types.hpp index 4a30358..8cd9943 100644 --- a/include/moonbase/types.hpp +++ b/include/moonbase/types.hpp @@ -127,6 +127,12 @@ struct licensing_options { // Identifies a higher-level integration built on top of the SDK (e.g. the // JUCE module). Appended to the User-Agent after "moonbase-cpp/" so // the server can tell which client made the request. + // + // Each layer appends its own space-separated segment rather than replacing + // what is already there, so the header reads outermost-last: + // "moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0". + // Control characters are stripped and the segment is capped before it reaches + // the header (see detail::sanitize_client_info). std::optional client_info; std::map metadata; std::chrono::milliseconds http_connect_timeout{std::chrono::seconds{10}}; diff --git a/modules/moonbase_licensing/README.md b/modules/moonbase_licensing/README.md index 45a070a..52e89eb 100644 --- a/modules/moonbase_licensing/README.md +++ b/modules/moonbase_licensing/README.md @@ -156,6 +156,9 @@ richer gating, and `onActivationChanged` fires whenever it changes. - **Telemetry** — `config.analytics.enabled = true` attaches JUCE system/host metadata (OS, CPU, DAW host, plugin format, …) to activation requests; add your own via `config.metadata` / `config.onCollectMetadata`. +- **Building on top of the module.** A framework or wrapper that embeds it identifies + itself with `config.clientInfo << " HISE/4.1.0"`, which is appended to the `User-Agent` + after the module's own `moonbase-juce/` segment rather than replacing it. See [`docs/juce-module.md`](../../docs/juce-module.md) for the full guide and [`examples/juce-native/`](../../examples/juce-native/) for a runnable sample app. diff --git a/modules/moonbase_licensing/juce/ActivationConfig.h b/modules/moonbase_licensing/juce/ActivationConfig.h index 6e572eb..f8b7828 100644 --- a/modules/moonbase_licensing/juce/ActivationConfig.h +++ b/modules/moonbase_licensing/juce/ActivationConfig.h @@ -61,6 +61,20 @@ struct ActivationConfig juce::String accountId; // optional issuer pin juce::String applicationVersion; + // An extra User-Agent segment identifying the layer built on top of this + // module: a framework, a wrapper, a white-label host, e.g. "HISE/4.1.0". + // Appended after the module's own "moonbase-juce/ (...)" segment, + // never in place of it, so support and analytics still see which module + // version ran underneath. Sent on every request (not gated by analytics). + // + // Append rather than assign, so a stack of layers each keeps its mark: + // config.clientInfo << " MyWrapper/2.0"; + // + // Use product tokens ("Name/Version", optional "(comment)") and keep it + // ASCII. Control characters are stripped and the segment is capped before it + // reaches the header. Read once, when the controller is constructed. + juce::String clientInfo; + //== Validation / network tuning ========================================== // How long a license stays valid offline since its last successful online // validation before it is treated as stale (and the app locks). Default 7 days. @@ -306,6 +320,29 @@ struct ActivationConfig return juce::File::getSpecialLocation(juce::File::tempDirectory); } + // What the module reports as the client in the User-Agent: its own segment + // (module version + JUCE version + OS, for support and analytics), then the + // consumer's clientInfo when set. The base client prefixes + // "moonbase-cpp/", so the wire value reads outermost-last: + // + // moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0 + // + // Trim-and-skip only: the SDK strips control characters and caps the length + // when it builds the header, so the character policy lives in one place. + [[nodiscard]] juce::String resolvedClientInfo() const + { + juce::String resolved; + resolved << "moonbase-juce/" << MOONBASE_LICENSING_VERSION + << " (" << juce::SystemStats::getJUCEVersion() + << "; " << juce::SystemStats::getOperatingSystemName() << ")"; + + const auto consumer = clientInfo.trim(); + if (consumer.isNotEmpty()) + resolved << " " << consumer; + + return resolved; + } + // The resolver the controller will use. [[nodiscard]] std::shared_ptr resolvedDeviceIdResolver() const { @@ -383,13 +420,9 @@ struct ActivationConfig options.application_version = JucePlugin_VersionString; // a plugin has no JUCEApplication to read it from #endif - // Identify this client as the JUCE module (appended to the base client's - // User-Agent), with the JUCE version + OS for support/analytics. - juce::String clientInfo; - clientInfo << "moonbase-juce/" << MOONBASE_LICENSING_VERSION - << " (" << juce::SystemStats::getJUCEVersion() - << "; " << juce::SystemStats::getOperatingSystemName() << ")"; - options.client_info = clientInfo.toStdString(); + // Identify this client as the JUCE module, plus whatever a higher-level + // integration appended (see resolvedClientInfo()). + options.client_info = resolvedClientInfo().toStdString(); options.online_validation_grace_period = onlineGracePeriod; options.online_validation_min_interval = onlineCheckInterval; diff --git a/modules/moonbase_licensing/moonbase/client.hpp b/modules/moonbase_licensing/moonbase/client.hpp index 5098ee5..1ba4158 100644 --- a/modules/moonbase_licensing/moonbase/client.hpp +++ b/modules/moonbase_licensing/moonbase/client.hpp @@ -29,6 +29,38 @@ inline std::string version_string() #endif } +// Every transport we ship assembles headers into a single line, so a CR or LF in +// a caller-supplied client_info would inject a header and an embedded NUL would +// silently truncate one. Control characters become spaces, runs of whitespace +// collapse, and the result is trimmed and capped, so the User-Agent stays a +// well-formed, bounded token list however many integration layers appended to it. +inline std::string sanitize_client_info(const std::string& value) +{ + // Generous for a stack of "Name/Version (comment)" segments, and well under + // the per-line header limits proxies enforce: an oversized User-Agent fails + // as an opaque 400/431 that is undebuggable from the field. + constexpr std::string::size_type max_length = 256; + + std::string result; + for (const char character : value) { + const auto byte = static_cast(character); + if (byte < 0x20 || byte == 0x7F || byte == ' ') { + if (!result.empty() && result.back() != ' ') { + result.push_back(' '); // leading runs drop, interior runs collapse + } + } else { + result.push_back(character); + } + if (result.size() >= max_length) { + break; + } + } + while (!result.empty() && result.back() == ' ') { + result.pop_back(); + } + return result; +} + inline std::string request_path(const licensing_options& options) { return trim_trailing_slashes(options.endpoint) + @@ -74,8 +106,13 @@ inline std::map default_headers(const licensing_option const std::string& content_type = {}) { std::string user_agent = "moonbase-cpp/" + version_string(); - if (options.client_info && !options.client_info->empty()) { - user_agent += " " + *options.client_info; + if (options.client_info) { + // Sanitise before the emptiness check: a segment that is only whitespace + // or control characters must not leave a dangling separator behind. + const auto client_info = sanitize_client_info(*options.client_info); + if (!client_info.empty()) { + user_agent += " " + client_info; + } } std::map headers{ {"Accept", "application/json, application/jwt, text/plain"}, diff --git a/modules/moonbase_licensing/moonbase/types.hpp b/modules/moonbase_licensing/moonbase/types.hpp index 4a30358..8cd9943 100644 --- a/modules/moonbase_licensing/moonbase/types.hpp +++ b/modules/moonbase_licensing/moonbase/types.hpp @@ -127,6 +127,12 @@ struct licensing_options { // Identifies a higher-level integration built on top of the SDK (e.g. the // JUCE module). Appended to the User-Agent after "moonbase-cpp/" so // the server can tell which client made the request. + // + // Each layer appends its own space-separated segment rather than replacing + // what is already there, so the header reads outermost-last: + // "moonbase-cpp/4.3.1 moonbase-juce/4.3.1 (JUCE v8.0.4; macOS 15.2) HISE/4.1.0". + // Control characters are stripped and the segment is capped before it reaches + // the header (see detail::sanitize_client_info). std::optional client_info; std::map metadata; std::chrono::milliseconds http_connect_timeout{std::chrono::seconds{10}}; diff --git a/tests/client_tests.cpp b/tests/client_tests.cpp index a8208f2..cd1180f 100644 --- a/tests/client_tests.cpp +++ b/tests/client_tests.cpp @@ -95,6 +95,58 @@ TEST_CASE("request_activation posts device information and parses response") CHECK(response.method == activation_method::online); } +TEST_CASE("client_info is appended to the User-Agent, sanitised and capped") +{ + licensing_options options; + + SUBCASE("layers read outermost-last after the base segment") + { + options.client_info = "moonbase-juce/9.9 (JUCE v8; TestOS) HISE/4.1.0"; + const auto ua = detail::default_headers(options).at("User-Agent"); + CHECK(ua.find("moonbase-cpp/") == 0); + CHECK(ua.find("moonbase-juce/9.9") < ua.find("HISE/4.1.0")); + } + + SUBCASE("CR/LF cannot inject a second header") + { + options.client_info = "HISE/4.1.0\r\nX-Injected: 1"; + const auto ua = detail::default_headers(options).at("User-Agent"); + CHECK(ua.find('\r') == std::string::npos); + CHECK(ua.find('\n') == std::string::npos); + CHECK(ua.find("HISE/4.1.0 X-Injected: 1") != std::string::npos); + } + + SUBCASE("an embedded NUL cannot truncate the header") + { + options.client_info = std::string("HISE/4.1.0\0hidden", 17); + CHECK(detail::default_headers(options).at("User-Agent").find("HISE/4.1.0 hidden") != + std::string::npos); + } + + SUBCASE("a segment that sanitises away leaves no trailing space") + { + options.client_info = " \r\n\t "; + CHECK(detail::default_headers(options).at("User-Agent") == + "moonbase-cpp/" + detail::version_string()); + } + + SUBCASE("whitespace runs collapse to a single separator") + { + options.client_info = " HISE/4.1.0 MyWrapper/2.0 "; + CHECK(detail::default_headers(options).at("User-Agent").find("HISE/4.1.0 MyWrapper/2.0") != + std::string::npos); + } + + SUBCASE("an oversized segment is capped rather than dropped") + { + options.client_info = std::string(1000, 'x'); + const auto ua = detail::default_headers(options).at("User-Agent"); + CHECK(ua.find("moonbase-cpp/") == 0); + CHECK(ua.size() < 320); // base segment + the 256-char cap + CHECK(ua.find("xxx") != std::string::npos); + } +} + TEST_CASE("request_activation asks for an offline license") { client_fixture fixture({ diff --git a/tests/inventory_tests.cpp b/tests/inventory_tests.cpp index d4cd3b4..08d045b 100644 --- a/tests/inventory_tests.cpp +++ b/tests/inventory_tests.cpp @@ -52,6 +52,7 @@ TEST_CASE("get_release queries the product endpoint with the license token") CHECK(request.url.find("includeManifests=false") != std::string::npos); CHECK(request.headers.at("Authorization") == "LicenseToken the-token"); CHECK(request.headers.at("x-mb-client") == "moonbase-cpp"); + CHECK(request.headers.at("User-Agent").find("moonbase-juce/9.9") != std::string::npos); CHECK(request.connect_timeout == std::chrono::milliseconds{1234}); CHECK(request.request_timeout == std::chrono::milliseconds{5678}); } diff --git a/tests/juce/controller_tests.cpp b/tests/juce/controller_tests.cpp index 2f4d79f..d28c73f 100644 --- a/tests/juce/controller_tests.cpp +++ b/tests/juce/controller_tests.cpp @@ -918,6 +918,56 @@ TEST_CASE("the JUCE module identifies itself via client_info (User-Agent)") CHECK_FALSE(opts.client_info->empty()); } +TEST_CASE("a consumer's clientInfo is appended after the module's own segment") +{ + ActivationConfig config; + config.endpoint = "https://demo.moonbase.sh"; + config.productId = "demo-app"; + + // Unset: the module's own segment only, which ends with the "(JUCE …; OS)" comment. + CHECK(config.resolvedClientInfo().startsWith("moonbase-juce/")); + CHECK(config.resolvedClientInfo().endsWith(")")); + + // Whitespace-only reads as unset, so no dangling separator. + config.clientInfo = " "; + CHECK(config.resolvedClientInfo().endsWith(")")); + + // Set: appended last, and that is exactly what reaches the SDK options. + config.clientInfo = "HISE/4.1.0"; + const auto resolved = config.resolvedClientInfo(); + CHECK(resolved.startsWith("moonbase-juce/")); + CHECK(resolved.endsWith(" HISE/4.1.0")); + CHECK(config.toLicensingOptions().client_info == resolved.toStdString()); + + // Layers append rather than assign, so each one keeps its mark. + config.clientInfo << " MyWrapper/2.0"; + CHECK(config.resolvedClientInfo().endsWith(" HISE/4.1.0 MyWrapper/2.0")); +} + +TEST_CASE("clientInfo reaches the wire and cannot inject a header") +{ + controller_fixture fx; + fx.config.clientInfo = "HISE/4.1.0\r\nX-Injected: 1"; + fx.seedStored(fx.token(default_claims())); + + ActivationController controller(fx.config, fx.makeLicensing()); + controller.start(); + REQUIRE(pumpUntil([&] { return controller.screen() == Screen::Details; })); + REQUIRE(fx.transport->requests.empty()); // start() was within the throttle window + + fx.transport->responses.push_back(moonbase::http_response{200, {}, fx.token(default_claims())}); + bool done = false; + controller.refreshLicense(true, [&](bool) { done = true; }); + REQUIRE(pumpUntil([&] { return done; })); + + REQUIRE(fx.transport->requests.size() == 1); + const auto ua = fx.transport->requests.front().headers.at("User-Agent"); + CHECK(ua.find("moonbase-cpp/") == 0); + CHECK(ua.find("moonbase-juce/") < ua.find("HISE/4.1.0")); // module segment first + CHECK(ua.find('\r') == std::string::npos); + CHECK(ua.find('\n') == std::string::npos); +} + TEST_CASE("analytics capture is off by default and easy to switch on") { ActivationConfig config;