Compare commits

...

3 Commits

Author SHA1 Message Date
Xavier Roche
915aa8d6ba proxyurl test: percent-encoded specials and UTF-8
Pin that the host/port split is byte-transparent: a percent-encoded '@' (%40) or
':' (%3A) in the userinfo stays encoded and never mis-splits host or port, a
second raw '@' is handled by the last-'@' host rule, and UTF-8 bytes in the
username and host label pass through intact. Decoding happens later at auth time,
not in the parser. Raw UTF-8 CLI args match the existing 01_engine-idna.test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-14 13:53:50 +02:00
Xavier Roche
6856cdd5c4 hts_parse_proxy: scan from one-past-end, avoid forming arg-1
The backward port scan started at arg+strlen(arg)-1 and decremented while
a >= authority, so on any argument with no ':' or '@' in the authority (every
bare-host proxy, and -P "") it stepped a to arg-1: a pointer formed before the
string, which is undefined behavior. It is not a memory-safety bug (never
dereferenced, no wrap, so UBSan stays green), but it is latent UB reachable from
-P.

Scan from one-past-end and test a[-1] with a > authority instead, so no pointer
below the string is ever formed. Guard name_size == 0 against a future
small-buffer caller (the name_size - 1 truncation would otherwise underflow).

Add a userinfo-free socks explicit-port case and a lone ':' boundary case to the
self-test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-14 13:49:01 +02:00
Xavier Roche
01d274c388 Parse -P proxy URLs scheme-aware, and recognize socks5 schemes
The -P argument parser scanned backward for the port ':' from the end of the
string with no notion of a scheme, so a scheme-prefixed proxy with no explicit
port ("-P http://proxy") mistook the scheme's own colon for a port separator:
the name came out as "http" with a garbage port. A port or a "user:pass@" prefix
happened to shield the scan, so this only bit the no-port case.

Extract the parse into hts_parse_proxy(), which skips any "scheme://" before
hunting the port, so the scheme colon is never read as a port. Teach
jump_protocol_const the socks5/socks5h schemes (so the proxy host resolves with
the scheme stripped) and default the port by scheme (1080 for socks, 8080
otherwise). This is the parsing groundwork for SOCKS5 support (#563); the
handshake lands separately.

Add a -#test=proxyurl self-test and tests/01_engine-proxyurl.test covering bare
hosts, user:pass, the scheme-colon regression, and the socks defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-07-14 13:42:22 +02:00
6 changed files with 113 additions and 12 deletions

View File

@@ -2253,21 +2253,12 @@ static int hts_main_internal(int argc, char **argv, httrackp * opt) {
htsmain_free();
return -1;
} else {
char *a;
char BIGSTK pname[HTS_URLMAXSIZE * 2];
na++;
opt->proxy.active = 1;
// Rechercher MAIS en partant de la fin à cause de user:pass@proxy:port
a = argv[na] + strlen(argv[na]) - 1;
while((a > argv[na]) && (*a != ':') && (*a != '@'))
a--;
if (*a == ':') { // un port est présent, <proxy>:port
sscanf(a + 1, "%d", &opt->proxy.port);
StringCopyN(opt->proxy.name, argv[na], (int) (a - argv[na]));
} else { // <proxy>
opt->proxy.port = 8080;
StringCopy(opt->proxy.name, argv[na]);
}
hts_parse_proxy(argv[na], pname, sizeof(pname), &opt->proxy.port);
StringCopy(opt->proxy.name, pname);
}
break;
case 'F': // user-agent field

View File

@@ -3866,6 +3866,10 @@ const char *jump_protocol_const(const char *source) {
source += p;
else if ((p = strfield(source, "file:")))
source += p;
else if ((p = strfield(source, "socks5h:")))
source += p;
else if ((p = strfield(source, "socks5:")))
source += p;
// net_path
if (strncmp(source, "//", 2) == 0)
source += 2;
@@ -3874,6 +3878,43 @@ const char *jump_protocol_const(const char *source) {
DECLARE_NON_CONST_VERSION(jump_protocol)
// default proxy port for a -P argument, keyed on the scheme
static int proxy_default_port(const char *arg) {
if (strfield(arg, "socks5h:") || strfield(arg, "socks5:"))
return 1080;
return 8080;
}
void hts_parse_proxy(const char *arg, char *name, size_t name_size, int *port) {
const char *authority = strstr(arg, "://");
const char *a;
size_t namelen;
if (name_size == 0)
return;
// scan back to the port ':' (or userinfo '@'), but never past the authority,
// so a scheme's own colon is not read as a port separator; inspect a[-1] from
// one-past-end so no pointer below the string is ever formed
authority = (authority != NULL) ? authority + 3 : arg;
a = arg + strlen(arg);
while (a > authority && a[-1] != ':' && a[-1] != '@')
a--;
if (a > authority && a[-1] == ':') {
int p = -1;
sscanf(a, "%d", &p);
*port = (p > 0) ? p : proxy_default_port(arg);
namelen = (size_t) (a - 1 - arg);
} else {
*port = proxy_default_port(arg);
namelen = strlen(arg);
}
if (namelen >= name_size) // arg is user-controlled: truncate, don't overflow
namelen = name_size - 1;
memcpy(name, arg, namelen);
name[namelen] = '\0';
}
// codage base 64 a vers b
void code64(unsigned char *a, int size_a, unsigned char *b, int crlf) {
int i1 = 0, i2 = 0, i3 = 0, i4 = 0;

View File

@@ -289,6 +289,12 @@ int may_unknown2(httrackp * opt, const char *mime, const char *filename);
const char *strrchr_limit(const char *s, char c, const char *limit);
char *jump_protocol(char *source);
const char *jump_protocol_const(const char *source);
/* Split a -P proxy argument "[scheme://][user:pass@]host[:port]" into the proxy
host string (scheme and any user:pass kept, for later stripping and auth),
written NUL-terminated into name[name_size] (truncated to fit), and the port
in *port. The port defaults by scheme: 1080 for socks5/socks5h, else 8080. */
void hts_parse_proxy(const char *arg, char *name, size_t name_size, int *port);
void code64(unsigned char *a, int size_a, unsigned char *b, int crlf);
#define copychar(catbuff,a) concat(catbuff,(a),NULL)

View File

@@ -1308,6 +1308,22 @@ static int st_identurl(httrackp *opt, int argc, char **argv) {
return 0;
}
static int st_proxyurl(httrackp *opt, int argc, char **argv) {
char BIGSTK name[HTS_URLMAXSIZE * 2];
int port = -1;
(void) opt;
if (argc < 1) {
fprintf(stderr, "proxyurl: needs a proxy argument\n");
return 1;
}
hts_parse_proxy(argv[0], name, sizeof(name), &port);
// host= is what the connect actually resolves (scheme + user:pass stripped)
printf("name=%s port=%d host=%s\n", name, port,
jump_identification_const(name));
return 0;
}
/* Regression for the one-byte fil[] overflow: a 2047-byte hostless "?"-URL used
to abort in strncat_safe_ when the missing leading '/' pushed fil to 2048. */
static int st_identabs(httrackp *opt, int argc, char **argv) {
@@ -2756,6 +2772,8 @@ static const struct selftest_entry {
{"resolve", "<link> <adr> <fil>", "resolve a link against an origin",
st_resolve},
{"identurl", "<url>", "split an absolute URL into (adr, fil)", st_identurl},
{"proxyurl", "<proxy-arg>", "parse a -P proxy URL into host/port",
st_proxyurl},
{"identabs", "", "ident_url_absolute one-byte fil[] overflow self-test",
st_identabs},
{"header", "<raw-header-line> ...", "response header-line parsing",

44
tests/01_engine-proxyurl.test Executable file
View File

@@ -0,0 +1,44 @@
#!/bin/bash
#
set -euo pipefail
# hts_parse_proxy splits a -P argument "[scheme://][user:pass@]host[:port]" into
# the proxy host string and port. -#test=proxyurl <arg> prints
# "name=.. port=.. host=..", where host= is what the connect resolves.
p() {
test "$(httrack -O /dev/null -#test=proxyurl "$1")" == "$2" || exit 1
}
# bare host, with and without an explicit port
p 'proxy.example.com:8080' 'name=proxy.example.com port=8080 host=proxy.example.com'
p 'proxy.example.com' 'name=proxy.example.com port=8080 host=proxy.example.com'
# user:pass@ is kept in name (for proxy auth) and stripped from host
p 'user:pass@proxy:3128' 'name=user:pass@proxy port=3128 host=proxy'
p 'user:pass@proxy' 'name=user:pass@proxy port=8080 host=proxy'
# a scheme colon must not be read as a port: http://host with no port used to
# parse to name=http with a garbage port
p 'http://proxy:8080' 'name=http://proxy port=8080 host=proxy'
p 'http://proxy' 'name=http://proxy port=8080 host=proxy'
# socks5/socks5h default to 1080 and resolve to the bare host
p 'socks5://host:1080' 'name=socks5://host port=1080 host=host'
p 'socks5://host' 'name=socks5://host port=1080 host=host'
p 'socks5h://host' 'name=socks5h://host port=1080 host=host'
# explicit port wins over the 1080 default, with and without userinfo
p 'socks5://host:9050' 'name=socks5://host port=9050 host=host'
p 'socks5://user:pass@host:9050' 'name=socks5://user:pass@host port=9050 host=host'
# the split is byte-transparent: percent-escapes stay encoded (decoded later at
# auth time), only structural ASCII ':'/'@' delimit, and the last '@' ends the
# userinfo. So %40/%3A and UTF-8 bytes never mis-split host or port.
p 'socks5://user:p%40ss@host:1080' 'name=socks5://user:p%40ss@host port=1080 host=host'
p 'socks5://us%3Aer:pass@host:1080' 'name=socks5://us%3Aer:pass@host port=1080 host=host'
p 'socks5://a@b:c@host:1080' 'name=socks5://a@b:c@host port=1080 host=host'
p 'socks5://naïve:pass@héllo:1080' 'name=socks5://naïve:pass@héllo port=1080 host=héllo'
# a lone ':' must not underflow the backward scan
p ':' 'name= port=8080 host='

View File

@@ -46,6 +46,7 @@ TESTS = \
01_engine-header.test \
01_engine-idna.test \
01_engine-identurl.test \
01_engine-proxyurl.test \
01_engine-identabs.test \
01_engine-escape-room.test \
01_engine-inplace-escape.test \