Compare commits

...

4 Commits

Author SHA1 Message Date
Vladimir Dubrovin
629a978c00 help: the features of the build listed, and the tests skip what they need
Some checks failed
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Has been cancelled
C/C++ CI Linux / ${{ matrix.target }} (ubuntu-latest) (push) Has been cancelled
C/C++ CI MacOS / ${{ matrix.target }} (macos-15) (push) Has been cancelled
C/C++ CI Windows / ${{ matrix.target }} (windows-2022) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (macos-15) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-24.04-arm) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (ubuntu-latest) (push) Has been cancelled
C/C++ CI cmake / ${{ matrix.target }} (windows-2022) (push) Has been cancelled
C/C++ CI cmake / ubuntu-latest (wolfSSL) (push) Has been cancelled
3proxy --help (and the usage message of a bad invocation) now lists
what the build was made with: TLS and its backend, RADIUS, PCRE, IPv6,
UNIX sockets, the built-in HTTP server, transparency, ODBC and the
optional proxy services. The test runner reads the list before the
first case and the cases state the features they need with
t.skip_unless(), so a build without a feature skips its cases instead
of failing them; a binary too old to report anything skips the cases
with needs and runs the rest.
2026-10-30 14:24:46 +03:00
Vladimir Dubrovin
a75b488aaf radius: the reply hashed with the secret, and taken only from the server asked
The reply digest copied the shared secret right after the received
bytes, so a reply of 4100 bytes with a secret of 17 or more characters
wrote up to 47 bytes of it past the reply buffer on the stack, before
the reply was authenticated. The digest is now taken over the reply
and the secret in place, with nothing written past the reply, and it
fails closed when the hash cannot be made. A reply is also taken only
from the address and port of the server the request went to, and only
when its identifier and length field match the request, all before any
digest work; the reply's sender was never checked before.
2026-10-30 13:47:21 +03:00
Vladimir Dubrovin
e9714fa4df resolve: the PTR answer of dnsname walked inside its record data
The label walk of a DNS PTR answer ran to the end of the whole message,
not to the end of the record's data, and took a compression pointer for
a label length, so it could write past the end of the answer buffer on
the stack. An answer of 3841 bytes or more reached past the buffer
itself. A record that ended exactly at the end of the message was also
read one byte past it. The walk now stops at the end of the record's
data, a name that does not end there, or one that is compressed or
empty is not accepted, and the answer loop reads nothing past the
message.
2026-10-30 12:42:39 +03:00
Vladimir Dubrovin
9be415b731 StringsPlugin: a line's end cut off without reading past it
load_string() put a NUL where a line read from the file had its CR or
LF, and stepped over it as well as over the character after: past the
line's NUL terminator, into what was left in the buffer from longer
lines before, or past its end, writing NULs wherever it found CR or LF.
The line is cut at its first CR or LF instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e396da2696)
2026-10-28 12:02:37 +03:00
32 changed files with 230 additions and 32 deletions

View File

@ -382,6 +382,21 @@ void cyclestep(void){
#define RETURN(x) {res = x; goto CLEARRETURN;}
static void printusage(struct extparam *cconf, int argc, char * argv[]){
fprintf(stderr, "Usage: %s [conffile]\n", argv[0]);
#ifdef _WIN32
fprintf(stderr, "\n\t%s --install [conffile]\n\tto install as service\n"
"\n\t%s --remove\n\tto remove service\n", argv[0], argv[0]);
#else
fprintf(stderr, "\n if conffile is missing, configuration is expected from stdin\n");
#endif
fprintf(stderr, "\n\t%s --help\n\tthis message\n", argv[0]);
fprintf(stderr, "available socket options:\n\t%s\n", printopts("\n\t"));
fprintf(stderr, "built with:\n\t%s\n", printfeatures("\n\t"));
fprintf(stderr, "\n%s %s\n%s\n", cconf->stringtable[2], cconf->stringtable[3], copyright);
}
#ifndef _WINCE
int main(int argc, char * argv[]) {
@ -424,6 +439,10 @@ int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int
#endif
conf.stringtable = strings;
if(argc == 2 && (!strcmp((char *)argv[1], "--help") || !strcmp((char *)argv[1], "-h"))) {
printusage(&conf, argc, argv);
return 0;
}
#ifdef _WIN32
#ifndef _WINCE
if((argc == 2 || argc == 3)&& !strcmp((char *)argv[1], "--install")) {
@ -536,18 +555,9 @@ int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int
}
if(argc > 2 || !(fp)) {
fprintf(stderr, "Usage: %s [conffile]\n", argv[0]);
#ifdef _WIN32
fprintf(stderr, "\n\t%s --install [conffile]\n\tto install as service\n"
"\n\t%s --remove\n\tto remove service\n", argv[0], argv[0]);
#else
fprintf(stderr, "\n if conffile is missing, configuration is expected from stdin\n");
#endif
fprintf(stderr, "available socket options:\n\t%s\n", printopts("\n\t"));
fprintf(stderr, "\n%s %s\n%s\n", conf.stringtable[2], conf.stringtable[3], copyright);
printusage(&conf, argc, argv);
return 1;
}
}
#ifndef _WIN32
/* before readconfig() starts the services: a client gone while it is

View File

@ -198,12 +198,20 @@ static int calc_replydigest(char *packet, char *original, const char *secret, in
int secretlen;
uint8_t calc_digest[AUTH_VECTOR_LEN];
uint8_t calc_vector[AUTH_VECTOR_LEN];
unsigned int dlen = AUTH_VECTOR_LEN;
mdh_ctx *ctx;
memcpy(calc_vector, packet + 4, AUTH_VECTOR_LEN);
memcpy(packet + 4, original, AUTH_VECTOR_LEN);
secretlen = strlen(secret);
memcpy(packet + len, secret, secretlen);
md5_calc(calc_digest, (u_char *)packet, len + secretlen);
/* hash the reply, then the secret: nothing is written past the reply */
if(!(ctx = mdh_init(MDH_MD5, 16))) return 2;
if(!mdh_update(ctx, packet, len) || !mdh_update(ctx, secret, secretlen) ||
!mdh_final(ctx, calc_digest, &dlen)) {
mdh_free(ctx);
return 2;
}
mdh_free(ctx);
/*
* Return 0 if OK, 2 if not OK.
@ -298,6 +306,7 @@ int radsend(struct clientparam * param, int auth, int stop){
int total_length;
int len;
PROXYSOCKADDRTYPE saremote;
PROXYSOCKADDRTYPE safrom;
struct pollfd fds[1];
char vector[AUTH_VECTOR_LEN];
radius_packet_t packet, rpacket;
@ -568,15 +577,25 @@ int radsend(struct clientparam * param, int auth, int stop){
continue;
}
salen = sizeof(saremote);
salen = sizeof(safrom);
data_len = so._recvfrom(so.state, remsock, (char *)&rpacket, sizeof(packet)-16,
0, (struct sockaddr *)&saremote, &salen);
0, (struct sockaddr *)&safrom, &salen);
if (data_len < 20) {
continue;
}
/* the reply must come from the server we asked */
if(*SAFAMILY(&safrom) != *SAFAMILY(&saremote) ||
memcmp(SAADDR(&safrom), SAADDR(&saremote), SAADDRLEN(&saremote)) ||
*SAPORT(&safrom) != *SAPORT(&saremote)) {
continue;
}
/* and answer this request, and be of its own length */
if(data_len != ntohs(rpacket.length) || rpacket.id != packet.id) {
continue;
}
if( auth && rpacket.code != PW_AUTHENTICATION_ACK &&
rpacket.code != PW_AUTHENTICATION_REJECT ){

View File

@ -61,14 +61,9 @@ char **load_string(FILE *f,int max_count_str, int *countloadstr,
if (strstr(tmpbuf1,"[end]")==NULL)
{
/* find and replace \n \r*/
rstr = tmpbuf1;
while (*rstr!='\0')
{
if (*rstr=='\r' || *rstr=='\n' )
{ *rstr='\0'; rstr++; }
rstr++;
}
/* the line's end cut off: what the file says with \n is what is
sent */
tmpbuf1[strcspn(tmpbuf1, "\r\n")] = '\0';
while ((rstr=strstr(tmpbuf1,"\\n")))
{

View File

@ -512,6 +512,7 @@ extern struct socketoptions {
int getopts(const char *s, struct sockoptset *opts);
void setopts(SOCKET s, const struct sockoptset *opts);
char * printopts(char *sep);
char * printfeatures(char *sep);
#ifdef _WINCE
char * CEToUnicode (const char *str);

View File

@ -226,6 +226,69 @@ char * printopts(char *sep){
}
char featsbuf[1024];
/* The features of this build, by the names the manual and the test suite
know them, so a user can see what a binary was made with. */
char * printfeatures(char *sep){
int pos=0;
#define ADDFEATURE(x) pos += sprintf(featsbuf+pos,"%s%s",pos?sep:"",x)
#ifdef WITH_SSL
ADDFEATURE("ssl");
#ifdef WITH_WOLFSSL
ADDFEATURE("wolfssl");
#else
ADDFEATURE("openssl");
#endif
#endif
#ifndef NORADIUS
ADDFEATURE("radius");
#endif
#ifdef WITH_PCRE
ADDFEATURE("pcre");
#endif
#ifndef NOIPV6
ADDFEATURE("ipv6");
#endif
#ifdef WITH_UN
ADDFEATURE("unix");
#endif
#ifdef WITH_HTTPSRV
ADDFEATURE("httpsrv");
#endif
#ifdef WITH_TRANSPARENT
ADDFEATURE("transparent");
#endif
#ifdef WITH_NETFILTER
ADDFEATURE("netfilter");
#endif
#ifdef WITH_PF
ADDFEATURE("pf");
#endif
#ifdef WITH_ODBC
ADDFEATURE("odbc");
#endif
#ifdef WITH_FTP
ADDFEATURE("ftppr");
#endif
#ifdef WITH_POP3P
ADDFEATURE("pop3p");
#endif
#ifdef WITH_SMTPP
ADDFEATURE("smtpp");
#endif
#ifdef WITH_IMAPP
ADDFEATURE("imapp");
#endif
#ifndef NOPLUGINS
ADDFEATURE("plugins");
#endif
#undef ADDFEATURE
return featsbuf;
}
/* A comma separated list of options, each NAME or NAME=VALUE; a name alone
stands for 1, or for TCP_MAXSEG for the value of maxseg. Options are added
to what is already set, so a default is changed only by naming it, and

View File

@ -167,6 +167,7 @@ uint32_t udpresolve(int af, unsigned char * name, unsigned char * value, uint32_
k += 4;
if(na > 255) na = 255; /* somebody is very evil */
for (j = 0; j < na; j++) { /* now there should be answers */
if(k >= len) break;
while(buf[k] < 192 && buf[k] !=0 && (k+buf[k]+14) < len) k+= (buf[k] + 1);
if(!buf[k]) k--;
if((k+(af == AF_INET6?28:16)) > len) {
@ -195,15 +196,17 @@ uint32_t udpresolve(int af, unsigned char * name, unsigned char * value, uint32_
k+= (12 + flen);
continue; /* we need A PTR */
}
for (s2 = buf + k + 12; s2 < (buf + k + 12 + len) && *s2; ){
s1 = s2 + ((unsigned)*s2) + 1;
s1 = buf + k + 12 + flen; /* the end of this record's data, inside the answer */
for (s2 = buf + k + 12; s2 < s1 && *s2 && *s2 < 64; ){
unsigned char *next = s2 + ((unsigned)*s2) + 1;
*s2 = '.';
s2 = s1;
s2 = next;
}
*s2 = 0;
if(s2 >= s1 || *s2 || s2 == buf + k + 12)
break; /* the name runs past its data, is compressed or empty */
if(param->username)free(param->username);
param->username = (unsigned char *)strdup ((char *)buf + k + 13);
return udpresolve(af,param->username, value, NULL, NULL, 2);
}
}

View File

@ -13,6 +13,14 @@ skips itself when that is missing or the build has no TLS support. With no
`--bin` it looks in `bin/`, then `build/bin/`, then the per-configuration
directories a multi-configuration CMake generator uses.
The binary reports what it was built with in `3proxy --help` (a `built
with:` list), and the runner reads that before the first case: a case names
the features it needs with `if not t.skip_unless("ssl"): return`, and
skips on a build without them instead of failing. A binary too old to
report its features skips every case that needs one, and the cases with no
feature needs still run; some cases also probe at runtime, as a build can
leave a feature compiled in but unusable.
The proxy under test is also the origin server the tests talk to: the `http`
command's `echo` operation reports back how a request arrived - method, path,
query, host, and the source port it came from - and `data` generates a body

View File

@ -4,6 +4,8 @@ import time
def run(t):
if not t.skip_unless("httpsrv"):
return
adm = t.free_port()
lim = t.free_port()
server = t.start("admin", f"""

View File

@ -7,7 +7,9 @@ handshake and the service supplies the port.
def run(t):
certs = t.certs()
if not t.skip_unless("httpsrv"):
return
certs = t.certs() if t.has("ssl") else None
plain = t.free_port()
port = t.free_port()
secure = t.free_port() if certs else None

View File

@ -60,6 +60,8 @@ def reply(f):
def run(t):
if not t.skip_unless("ftppr"):
return
servers = {}
for name, (user, password) in LOGINS.items():
listener = socket.socket()

View File

@ -2,6 +2,8 @@
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
openport = t.free_port()
t.start("httpsrv_auth", f"""

View File

@ -8,6 +8,8 @@ import os
def run(t):
if not t.skip_unless("httpsrv"):
return
root = os.path.join(t.tmpdir, "web")
os.makedirs(os.path.join(root, "picts", "set"), exist_ok=True)
with open(os.path.join(root, "a.html"), "w") as fp:

View File

@ -10,6 +10,8 @@ import os
def run(t):
if not t.skip_unless("httpsrv"):
return
root = os.path.join(t.tmpdir, "ka")
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "a.html"), "w") as fp:

View File

@ -4,6 +4,8 @@ import time
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
t.start("httpsrv_ops", f"""
log

View File

@ -6,6 +6,8 @@ most of them away before they ever reached the server.
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
t.start("httpsrv_parsing", f"""
log

View File

@ -9,6 +9,8 @@ import os
def run(t):
if not t.skip_unless("httpsrv"):
return
root = os.path.join(t.tmpdir, "pp")
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "a.html"), "w") as fp:

View File

@ -2,6 +2,8 @@
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
srv2 = t.free_port()
t.start("httpsrv_rules", f"""

View File

@ -7,6 +7,8 @@ nscache6: nscache holds the IPv4 side and nothing else.
def run(t):
if not t.skip_unless("httpsrv", "ipv6"):
return
if not t.has_ipv6():
t.skip("IPv6 (this machine has no IPv6 loopback)")
return

View File

@ -8,6 +8,8 @@ import time
def run(t):
if not t.skip_unless("httpsrv"):
return
# The origin runs in a process of its own, so the limit only applies to
# the mapper in front of it.
origin = t.free_port()

View File

@ -30,6 +30,8 @@ def wait_served(server, needle, count, since=0, timeout=5.0):
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
good = t.free_port()
spare = t.free_port()

View File

@ -46,6 +46,8 @@ UNHONOURED = (1, 99)
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
prx = t.free_port()
sks = t.free_port()

View File

@ -37,6 +37,8 @@ def wait_served(server, needle, count, since=0, timeout=5.0):
def run(t):
if not t.skip_unless("httpsrv"):
return
adm = t.free_port()
prx = t.free_port()
dummy = t.free_port()

View File

@ -20,6 +20,8 @@ def _has_pcre(t):
def run(t):
if not t.skip_unless("httpsrv", "pcre"):
return
if not _has_pcre(t):
t.skip("PCRE (this build has no PCRE support)")
return

View File

@ -26,6 +26,8 @@ def halfclose_origin(listener):
def run(t):
if not t.skip_unless("httpsrv"):
return
# --- tcppm ---------------------------------------------------------
origin = t.free_port()
mapped = t.free_port()

View File

@ -6,6 +6,8 @@ from a clean list.
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
other = t.free_port()
prx = t.free_port()

View File

@ -2,6 +2,8 @@
def run(t):
if not t.skip_unless("httpsrv"):
return
srv = t.free_port()
sks = t.free_port()
sauth = t.free_port()

View File

@ -11,6 +11,8 @@ def _no_tls(t, server):
def run(t):
if not t.skip_unless("httpsrv", "ssl"):
return
certs = t.certs()
if not certs:
t.skip("TLS (openssl is not available to generate certificates)")

View File

@ -2,6 +2,8 @@
def run(t):
if not t.skip_unless("httpsrv", "ssl"):
return
certs = t.certs()
if not certs:
t.skip("tlspr (openssl is not available to generate certificates)")

View File

@ -39,6 +39,8 @@ def _config(t, name, body):
def run(t):
if not t.skip_unless("httpsrv"):
return
# --- the command and its modes ------------------------------------
# These need no redirection, so they run wherever the feature is built.
out = _config(t, "transparent_probe", "log\ntransparent")

View File

@ -36,6 +36,8 @@ def unix_get(path, url, timeout=10):
def run(t):
if not t.skip_unless("httpsrv", "unix"):
return
if not hasattr(socket, "AF_UNIX") or sys.platform.startswith("win"):
t.skip("Unix domain sockets (not on this platform)")
return

View File

@ -122,10 +122,11 @@ class Failure(Exception):
class Tester:
"""The API a case runs against: start servers, make requests, assert."""
def __init__(self, binary, tmpdir, case):
def __init__(self, binary, tmpdir, case, features=None):
self.binary = binary
self.tmpdir = tmpdir
self.case = case
self.features = features if features is not None else frozenset()
self.servers = []
self.checks = []
self.timeout = 10
@ -135,6 +136,25 @@ class Tester:
self.logs = []
self.udp_servers = []
# ---- build features ----------------------------------------------
def has(self, *names):
"""Whether the binary reports every named feature."""
return all(name in self.features for name in names)
def skip_unless(self, *names):
"""Skip what needs a feature the binary was built without.
Returns False after recording the skip, so a case says
`if not t.skip_unless("ssl"): return` and keeps its checks out of
the failed count on a build that cannot pass them.
"""
missing = [name for name in names if name not in self.features]
if not missing:
return True
self.skip("built without: " + ", ".join(missing))
return False
# ---- servers -----------------------------------------------------
def has_ipv6(self):

View File

@ -47,6 +47,37 @@ def load_case(path):
return name, module
def built_features(binary):
"""The features the binary reports in `3proxy --help`, as a set.
A binary too old to have the list, or one that cannot be run at all,
reports nothing: every case that needs a feature then skips, and the
ones that need none still run.
"""
import subprocess
try:
done = subprocess.run([binary, "--help"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=15)
text = done.stdout.decode("utf-8", "replace")
except (OSError, subprocess.SubprocessError):
return frozenset()
features = set()
collecting = False
for line in text.splitlines():
stripped = line.strip()
if line.startswith("built with:"):
collecting = True
rest = stripped[len("built with:"):].strip()
if rest:
features.update(rest.split())
continue
if collecting:
if not line[:1].isspace() or not stripped:
break
features.update(stripped.split())
return frozenset(features)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("pattern", nargs="?", default="",
@ -75,7 +106,9 @@ def main():
return 2
tmpdir = tempfile.mkdtemp(prefix="3proxy-tests.")
features = built_features(binary)
print(f"3proxy tests: {binary}")
print(f"built with: {' '.join(sorted(features)) or '(nothing reported)'}")
print(f"working in: {tmpdir}\n")
passed = failed = skipped = 0
@ -85,7 +118,7 @@ def main():
for path in paths:
name, module = load_case(path)
print(f" {name}")
tester = Tester(binary, tmpdir, name)
tester = Tester(binary, tmpdir, name, features)
error = None
try:
module.run(tester)