Skip to content

Commit 913553a

Browse files
authored
Merge pull request #1404 from mickem/claude/nscp-issue-460-check-39fdt1
Settings: send query parameters on http(s) settings urls
2 parents a26d28d + 025a14d commit 913553a

10 files changed

Lines changed: 604 additions & 21 deletions

File tree

docs/docs/concepts/settings.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,64 @@ Adding a script:
144144
scripts/myscript.bat = http://www.myserver.com/myscript.bat
145145
```
146146

147+
#### Query parameters
148+
149+
The url may carry a query string, which is passed on to the server unchanged.
150+
This lets a script generate the configuration per host instead of serving a static file:
151+
152+
```ini
153+
[settings]
154+
1 = http://nsclient.mydom.local/nsclient/nsclient.php?RootFolder=myhost/&Filename=nsclient.ini
155+
2 = ini://${shared-path}/nsclient.ini
156+
```
157+
158+
Each distinct query gets its own file in the cache folder, so several urls pointing at the same
159+
script with different parameters do not overwrite each other's cached configuration. An existing
160+
cache file written by an older version is moved to the new name on first start, so a host that
161+
cannot reach its settings server during the upgrade still boots off its cached configuration.
162+
163+
Characters that are not legal in a url query - a space, most notably - are percent-encoded before
164+
the request is sent. Anything already written as `%XX` is left as it is, so a query you encoded
165+
yourself is not encoded twice.
166+
167+
#### Host name placeholders
168+
169+
The url may contain the same host name placeholders the submit clients (NRDP, Graphite, Syslog and
170+
friends) accept, so a single `boot.ini` can be rolled out to an entire fleet and each agent asks for
171+
its own configuration:
172+
173+
```ini
174+
[settings]
175+
1 = http://cfgsrv/nsclient.php?host=${hostname}
176+
```
177+
178+
| Placeholder | Expands to |
179+
|---|---|
180+
| `${hostname}` | the system host name as reported, e.g. `srv01.example.com` |
181+
| `${host}` | the part before the first `.`, e.g. `srv01` |
182+
| `${domain}` | the part after the first `.`, e.g. `example.com` |
183+
184+
Each of the three also has a `_lc` and a `_uc` variant (`${hostname_lc}`, `${host_uc}`, ...) that
185+
lower- or upper-cases the result.
186+
187+
Placeholders are expanded before the url is parsed, so they may appear anywhere in it - in the
188+
query, in the path (`http://cfgsrv/hosts/${host}/nsclient.ini`) or even in the host name. They are
189+
expanded before percent-encoding, so a host name containing a character that needs escaping is
190+
escaped rather than corrupting the request. The cache file name is derived from the expanded url,
191+
so each host caches its own configuration.
192+
193+
> **New in 0.17:** `${hostname}`, `${hostname_lc}` and `${hostname_uc}`. The other placeholders
194+
> already existed for the submit clients; this makes them available in settings urls too.
195+
196+
If the query carries a credential (`?token=...`), note that it is still sent in clear text unless
197+
the url is `https://`. NSClient++ keeps query parameters out of its own log and out of the settings
198+
url it prints (`nscp settings --show`): both render a settings url as scheme, host and path only.
199+
Anything else that handles the url - a proxy, the settings server's own access log - is of course
200+
outside the agent's control.
201+
202+
> **Changed in 0.17:** query parameters used to be silently dropped from the request, so the
203+
> server only ever saw the bare path.
204+
147205
#### Using TLS
148206

149207
You likely want to use TLS when using http settings.

include/net/net.hpp

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,44 @@ struct string_traits {
1414
static std::string protocol_suffix() { return "://"; }
1515
static std::string port_prefix() { return ":"; }
1616
};
17+
18+
// Percent-encode whatever is not legal in a url query, so the result can be put
19+
// on an HTTP request line verbatim. RFC 3986 allows query = *( pchar / "/" /
20+
// "?" ), i.e. unreserved / sub-delims / ":" / "@" / "/" / "?" / pct-encoded.
21+
// Everything else either makes the request line unparseable (a space turns
22+
// "GET /a?b=c d HTTP/1.0" into a malformed three-token line) or, for a stray CR
23+
// or LF, splits one request into two. The query only reaches the wire since
24+
// issue #460, so this guards a door that was previously closed by accident.
25+
//
26+
// An operator may well have written the query already encoded, so an existing
27+
// "%XX" pair is passed through untouched rather than turned into "%25XX". A '%'
28+
// that does not introduce a valid pair is not an escape and is encoded.
29+
inline std::string encode_query(const std::string &query) {
30+
static const std::string sub_delims = "-._~!$&'()*+,;=:@/?";
31+
static const char hex[] = "0123456789ABCDEF";
32+
const auto is_hex = [](const char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); };
33+
34+
std::string out;
35+
out.reserve(query.size());
36+
for (std::string::size_type i = 0; i < query.size(); ++i) {
37+
const char c = query[i];
38+
if (c == '%' && i + 2 < query.size() && is_hex(query[i + 1]) && is_hex(query[i + 2])) {
39+
out.append(query, i, 3);
40+
i += 2;
41+
continue;
42+
}
43+
const auto uc = static_cast<unsigned char>(c);
44+
if ((uc >= 'A' && uc <= 'Z') || (uc >= 'a' && uc <= 'z') || (uc >= '0' && uc <= '9') || sub_delims.find(c) != std::string::npos) {
45+
out.push_back(c);
46+
continue;
47+
}
48+
out.push_back('%');
49+
out.push_back(hex[(uc >> 4) & 0xf]);
50+
out.push_back(hex[uc & 0xf]);
51+
}
52+
return out;
53+
}
54+
1755
struct url {
1856
std::string protocol;
1957
std::string host;
@@ -22,14 +60,38 @@ struct url {
2260
unsigned int port;
2361
url() : port(0) {}
2462

25-
std::string to_string() const {
63+
// The full url, query string included. Prefer to_log_safe_string() for
64+
// anything that ends up in a log or an error message.
65+
std::string to_string() const { return get_baseurl() + get_request_path(); }
66+
67+
// The url without the query string. A settings url is free to carry
68+
// credentials in its parameters (".../cfg.php?token=..."), and the settings
69+
// layer logs the url it is fetching on every boot - at warning level when TLS
70+
// verification is off or the CA bundle is missing. Identifying the source
71+
// does not need the parameters, so they are left out rather than written to
72+
// disk in clear text.
73+
std::string to_log_safe_string() const { return get_baseurl() + get_path(); }
74+
75+
// Scheme and authority: "http://host:8080".
76+
std::string get_baseurl() const {
2677
std::stringstream ss;
2778
ss << protocol << string_traits::protocol_suffix() << host;
2879
if (port != 0) ss << string_traits::port_prefix() << port;
29-
ss << path;
3080
return ss.str();
3181
}
3282

83+
// The document path, without the query string.
84+
std::string get_path() const { return path; }
85+
86+
// The resource as it has to appear on the HTTP request line: everything
87+
// after the authority, query string included. `path` on its own stops at
88+
// the '?', so a caller that hands it straight to a downloader silently
89+
// drops every parameter the user wrote (issue #460).
90+
std::string get_request_path() const {
91+
if (query.empty()) return path;
92+
return path + "?" + encode_query(query);
93+
}
94+
3395
unsigned int get_port() const { return port; }
3496
unsigned int get_port(unsigned int default_port) const {
3597
if (port == 0) return default_port;

include/net/net_test.cpp

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// SPDX-FileCopyrightText: 2004-2026 Michael Medin
2+
// SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-only
3+
4+
#include <gtest/gtest.h>
5+
6+
#include <net/net.hpp>
7+
8+
TEST(net_url, parse_splits_protocol_host_port_and_path) {
9+
const net::url u = net::parse("http://example.com:8080/dir/file.ini");
10+
EXPECT_EQ(u.protocol, "http");
11+
EXPECT_EQ(u.host, "example.com");
12+
EXPECT_EQ(u.port, 8080u);
13+
EXPECT_EQ(u.path, "/dir/file.ini");
14+
EXPECT_TRUE(u.query.empty());
15+
}
16+
17+
TEST(net_url, parse_extracts_the_query_string) {
18+
const net::url u = net::parse("http://nsclient.mydom.local/nsclient/nsclient.php?RootFolder=myhost/&Filename=nsclient.ini");
19+
EXPECT_EQ(u.protocol, "http");
20+
EXPECT_EQ(u.host, "nsclient.mydom.local");
21+
// The path stops at the '?' ...
22+
EXPECT_EQ(u.path, "/nsclient/nsclient.php");
23+
// ... and the parameters land in query, '&' and '/' included.
24+
EXPECT_EQ(u.query, "RootFolder=myhost/&Filename=nsclient.ini");
25+
}
26+
27+
TEST(net_url, get_request_path_reassembles_path_and_query) {
28+
// What a downloader has to put on the request line (issue #460).
29+
const net::url u = net::parse("https://host/cfg.php?a=1&b=2");
30+
EXPECT_EQ(u.get_request_path(), "/cfg.php?a=1&b=2");
31+
}
32+
33+
TEST(net_url, get_request_path_without_a_query_is_just_the_path) {
34+
const net::url u = net::parse("https://host/settings.ini");
35+
EXPECT_EQ(u.get_request_path(), "/settings.ini");
36+
// No trailing '?' when there is nothing to pass.
37+
EXPECT_EQ(u.get_request_path().find('?'), std::string::npos);
38+
}
39+
40+
TEST(net_url, get_request_path_keeps_an_empty_query_marker_out) {
41+
// "?" with nothing after it parses to an empty query and must not be
42+
// re-emitted, otherwise every plain url would grow a stray '?'.
43+
const net::url u = net::parse("http://host/f.ini?");
44+
EXPECT_EQ(u.path, "/f.ini");
45+
EXPECT_TRUE(u.query.empty());
46+
EXPECT_EQ(u.get_request_path(), "/f.ini");
47+
}
48+
49+
TEST(net_url, to_string_round_trips_a_url_with_parameters) {
50+
const std::string raw = "http://example.com:8080/cfg.php?host=a&mode=b";
51+
EXPECT_EQ(net::parse(raw).to_string(), raw);
52+
}
53+
54+
TEST(net_url, to_string_includes_port_only_when_set) {
55+
EXPECT_EQ(net::parse("http://example.com/x.ini").to_string(), "http://example.com/x.ini");
56+
EXPECT_EQ(net::parse("http://example.com:81/x.ini").to_string(), "http://example.com:81/x.ini");
57+
}
58+
59+
TEST(net_url, ini_paths_keep_their_windows_drive_letter) {
60+
// "ini://C:/foo" must not read "C" as a host and ":/foo" as a port; the
61+
// parser skips port handling for the ini and registry protocols.
62+
const net::url u = net::parse("ini://${shared-path}/nsclient.ini");
63+
EXPECT_EQ(u.protocol, "ini");
64+
EXPECT_TRUE(u.query.empty());
65+
EXPECT_EQ(u.get_request_path(), u.path);
66+
}
67+
68+
// --- request-line safety ----------------------------------------------------
69+
70+
TEST(net_url, encode_query_leaves_legal_characters_alone) {
71+
// Everything RFC 3986 permits in a query has to survive verbatim, or the
72+
// parameters stop meaning what the operator wrote.
73+
const std::string legal = "RootFolder=myhost/&Filename=nsclient.ini";
74+
EXPECT_EQ(net::encode_query(legal), legal);
75+
EXPECT_EQ(net::encode_query("a=1&b=2;c=3,d=4+5:6@7?8*9!$'()~-._"), "a=1&b=2;c=3,d=4+5:6@7?8*9!$'()~-._");
76+
}
77+
78+
TEST(net_url, encode_query_escapes_a_space) {
79+
EXPECT_EQ(net::encode_query("Folder=my host"), "Folder=my%20host");
80+
}
81+
82+
TEST(net_url, encode_query_escapes_crlf) {
83+
// The interesting one: unescaped, this splits the request line in two.
84+
EXPECT_EQ(net::encode_query("a=1\r\nX-Evil: yes"), "a=1%0D%0AX-Evil:%20yes");
85+
}
86+
87+
TEST(net_url, encode_query_does_not_double_encode) {
88+
// A query written already-encoded must not turn "%20" into "%2520".
89+
EXPECT_EQ(net::encode_query("Folder=my%20host"), "Folder=my%20host");
90+
EXPECT_EQ(net::encode_query("a=%2F%2f"), "a=%2F%2f");
91+
}
92+
93+
TEST(net_url, encode_query_escapes_a_stray_percent) {
94+
// A '%' that introduces no valid pair is not an escape.
95+
EXPECT_EQ(net::encode_query("a=100%"), "a=100%25");
96+
EXPECT_EQ(net::encode_query("a=%zz"), "a=%25zz");
97+
EXPECT_EQ(net::encode_query("a=%2"), "a=%252");
98+
}
99+
100+
TEST(net_url, encode_query_escapes_high_bytes_and_controls) {
101+
EXPECT_EQ(net::encode_query(std::string("a=\x01")), "a=%01");
102+
EXPECT_EQ(net::encode_query(std::string("a=\xc3\xa5")), "a=%C3%A5");
103+
}
104+
105+
TEST(net_url, get_request_path_encodes_the_query) {
106+
const net::url u = net::parse("http://host/cfg.php?Folder=my host");
107+
EXPECT_EQ(u.get_request_path(), "/cfg.php?Folder=my%20host");
108+
}
109+
110+
// --- log-safe rendering (keeps parameters out of the log) -------------------
111+
112+
TEST(net_url, to_log_safe_string_drops_the_query) {
113+
const net::url u = net::parse("https://cfgsrv:8443/nsclient.php?token=s3cret&host=a");
114+
EXPECT_EQ(u.to_log_safe_string(), "https://cfgsrv:8443/nsclient.php");
115+
EXPECT_EQ(u.to_log_safe_string().find("s3cret"), std::string::npos);
116+
// to_string() is still the faithful rendering.
117+
EXPECT_NE(u.to_string().find("token=s3cret"), std::string::npos);
118+
}
119+
120+
TEST(net_url, to_log_safe_string_equals_to_string_without_a_query) {
121+
const net::url u = net::parse("http://cfgsrv/settings.ini");
122+
EXPECT_EQ(u.to_log_safe_string(), u.to_string());
123+
}
124+
125+
TEST(net_url, get_baseurl_and_get_path_split_the_url) {
126+
const net::url u = net::parse("https://cfgsrv:8443/dir/nsclient.php?token=s3cret");
127+
EXPECT_EQ(u.get_baseurl(), "https://cfgsrv:8443");
128+
EXPECT_EQ(u.get_path(), "/dir/nsclient.php");
129+
EXPECT_EQ(u.get_baseurl() + u.get_path(), u.to_log_safe_string());
130+
}
131+
132+
TEST(net_url, get_baseurl_omits_an_unset_port) {
133+
EXPECT_EQ(net::parse("http://cfgsrv/x.ini").get_baseurl(), "http://cfgsrv");
134+
}
135+
136+
TEST(net_url, apply_and_import_carry_the_query) {
137+
net::url base = net::parse("http://host/a.ini");
138+
const net::url with_query = net::parse("http://host/b.ini?k=v");
139+
140+
net::url applied = base;
141+
applied.apply(with_query);
142+
EXPECT_EQ(applied.get_request_path(), "/b.ini?k=v");
143+
144+
net::url imported = net::parse("http://host/");
145+
imported.path.clear();
146+
imported.import(with_query);
147+
EXPECT_EQ(imported.get_request_path(), "/b.ini?k=v");
148+
}

include/net/socket/socket_helpers.cpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ std::string socket_helpers::expand_hostname(std::string spec) {
3535
if (spec == "auto-lc") return boost::algorithm::to_lower_copy(host_name);
3636
if (spec == "auto-uc") return boost::algorithm::to_upper_copy(host_name);
3737

38+
// The full name exactly as the system reports it. ${host} stops at the first
39+
// '.', so without this there is no way to get the fqdn from inside a template
40+
// - only by setting the whole spec to "auto", which a template cannot do.
41+
str::utils::replace(spec, "${hostname_uc}", boost::algorithm::to_upper_copy(host_name));
42+
str::utils::replace(spec, "${hostname_lc}", boost::algorithm::to_lower_copy(host_name));
43+
str::utils::replace(spec, "${hostname}", host_name);
44+
3845
const str::utils::token dn = str::utils::getToken(host_name, '.');
3946
str::utils::replace(spec, "${host}", dn.first);
4047
str::utils::replace(spec, "${domain}", dn.second);

include/net/socket/socket_helpers.hpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,10 @@ void validate_certificate(const std::string& certificate, std::list<std::string>
7070
// "auto" -> system host name as-is
7171
// "auto-lc" -> system host name, lower-cased
7272
// "auto-uc" -> system host name, upper-cased
73-
// anything else: ${host}, ${domain}, ${host_lc}, ${host_uc}, ${domain_lc}
74-
// and ${domain_uc} are substituted from the system host name (split on the
75-
// first '.' into host and domain). Other text is preserved.
73+
// anything else: ${hostname}, ${hostname_lc} and ${hostname_uc} are the
74+
// system host name as reported; ${host}, ${domain}, ${host_lc}, ${host_uc},
75+
// ${domain_lc} and ${domain_uc} are substituted from it after splitting on
76+
// the first '.' into host and domain. Other text is preserved.
7677
std::string expand_hostname(std::string spec);
7778

7879
class socket_exception : public std::exception {

include/net/socket/socket_helpers_test.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,30 @@ TEST(ExpandHostname, HostPlaceholderIsExpanded) {
291291
EXPECT_NE(out.find("-suffix"), std::string::npos);
292292
}
293293

294+
TEST(ExpandHostname, HostnamePlaceholderIsTheFullSystemName) {
295+
// ${host} stops at the first '.', ${hostname} does not - that is the whole
296+
// point of having both.
297+
const std::string host = boost::asio::ip::host_name();
298+
EXPECT_EQ(socket_helpers::expand_hostname("${hostname}"), host);
299+
EXPECT_EQ(socket_helpers::expand_hostname("a=${hostname}&b=1"), "a=" + host + "&b=1");
300+
}
301+
302+
TEST(ExpandHostname, HostnameCasePlaceholders) {
303+
const std::string host = boost::asio::ip::host_name();
304+
EXPECT_EQ(socket_helpers::expand_hostname("${hostname_lc}"), boost::algorithm::to_lower_copy(host));
305+
EXPECT_EQ(socket_helpers::expand_hostname("${hostname_uc}"), boost::algorithm::to_upper_copy(host));
306+
}
307+
308+
TEST(ExpandHostname, HostnameAndHostPlaceholdersDoNotCollide) {
309+
// "${host}" is a character-wise prefix of "${hostname}" up to the brace, so a
310+
// careless replace order would rewrite "${hostname}" into "<host>name}".
311+
const std::string host = boost::asio::ip::host_name();
312+
const std::string out = socket_helpers::expand_hostname("${hostname}|${host}|${hostname_lc}|${host_lc}");
313+
EXPECT_EQ(out.find("${"), std::string::npos) << out;
314+
EXPECT_EQ(out.find("name}"), std::string::npos) << out;
315+
EXPECT_EQ(out.substr(0, host.size()), host) << out;
316+
}
317+
294318
TEST(ExpandHostname, CasePlaceholdersAreExpanded) {
295319
// No assertion on the exact host name (varies per machine), only that all
296320
// placeholders are substituted away.

0 commit comments

Comments
 (0)