From ef5adaa7fc6d22d9da87eee43bb22b3e5ffdbe9f Mon Sep 17 00:00:00 2001 From: DannyArends Date: Fri, 31 Jul 2026 13:55:31 +0100 Subject: [PATCH 1/9] Just a bool is enough --- danode/https.d | 2 +- danode/interfaces.d | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/danode/https.d b/danode/https.d index e2cd3a4..678ebbf 100644 --- a/danode/https.d +++ b/danode/https.d @@ -83,7 +83,7 @@ version(SSL) { } override long receiveData(ref char[] buffer) { return(SSL_read(ssl, cast(void*) buffer, cast(int)buffer.length)); } - override ptrdiff_t bufferedBytes() { return(ssl !is null ? SSL_pending(ssl) : 0); } + override bool hasBuffered() const { return(ssl !is null && SSL_pending(ssl) > 0); } // Send upto maxsize bytes from the response to the client override void send(ref Response response, Socket socket, ptrdiff_t maxsize = 4096){ diff --git a/danode/interfaces.d b/danode/interfaces.d index f17c237..88936c2 100644 --- a/danode/interfaces.d +++ b/danode/interfaces.d @@ -33,7 +33,7 @@ abstract class DriverInterface { void touch() { modtime = Clock.currTime(); } private ptrdiff_t readSocket(ref char[] tmpbuffer) { if (!socketReady()) return 0; - if (bufferedBytes() <= 0 && sISelect(false, 25) <= 0) return 0; + if (!hasBuffered() && sISelect(false, 25) <= 0) return 0; // SSL may hold decrypted bytes select can't see ptrdiff_t received = receiveData(tmpbuffer); if (received > 0) { touch(); log(Level.Trace, "Received %d bytes of data", received); } return received; @@ -68,7 +68,7 @@ abstract class DriverInterface { } long receiveData(ref char[] buffer); - ptrdiff_t bufferedBytes() { return 0; } + bool hasBuffered() const { return false; } bool openConnection(bool blocking = false); void closeConnection(); @nogc bool isSecure() const nothrow; From 5c15b559f67a9433c1e73264ccc77c44130f8737 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Sat, 5 Sep 2026 12:39:50 +0100 Subject: [PATCH 2/9] Fix: CGI input-file protocol injection & HTTP_PROXY from request header & Host header path traversal --- danode/post.d | 7 +++++-- danode/request.d | 5 ++++- danode/router.d | 4 +++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/danode/post.d b/danode/post.d index 75cb4d9..871f0a1 100644 --- a/danode/post.d +++ b/danode/post.d @@ -80,6 +80,9 @@ final void parseXform(ref Request request, const string content) { return -1; } +/* Strip CR/LF so a field cannot forge extra lines in the CGI input protocol */ +pure string oneline(in string s) { return s.replace("\r", "").replace("\n", ""); } + /* The serverAPI functions prepares and writes out the input file for external process execution The inputfile contains the SERVER, COOKIES, POST, and FILES information that can be used by the external script This data is picked-up by the different CGI APIs, and presented to the client in the regular way */ @@ -108,8 +111,8 @@ final void serverAPI(in FileSystem filesystem, in WebConfig config, in Request r // Write the post information we received foreach (p; request.postinfo) { - if(p.type == PostType.Input) content.put(format("P=%s=%s\n", p.name, p.value)); - if(p.type == PostType.File) content.put(format("F=%s=%s=%s=%s\n", p.name, p.filename, p.mime, p.value)); + if(p.type == PostType.Input) content.put(format("P=%s=%s\n", oneline(p.name), oneline(p.value))); + if(p.type == PostType.File) content.put(format("F=%s=%s=%s=%s\n", oneline(p.name), oneline(p.filename), oneline(p.mime), p.value)); } string fIn = request.inputfile(filesystem); diff --git a/danode/request.d b/danode/request.d index 4c39489..ac6734e 100644 --- a/danode/request.d +++ b/danode/request.d @@ -195,7 +195,10 @@ struct Request { env["HTTPS"] = isSecure ? "on" : ""; env["REDIRECT_STATUS"] = "200"; env["PATH"] = environment.get("PATH", ""); - foreach (k, v; headers) { env["HTTP_" ~ k.toUpper().replace("-", "_")] = v; } + foreach (k, v; headers) { + if(icmp(k, "Proxy") == 0) continue; + env["HTTP_" ~ k.toUpper().replace("-", "_")] = v; + } return env; } diff --git a/danode/router.d b/danode/router.d index 0f6adca..cb609d8 100644 --- a/danode/router.d +++ b/danode/router.d @@ -58,7 +58,9 @@ class Router { string localroot = filesystem.localroot(request.shorthost()); log(Level.Trace, "Router: [T] %s:%s %s client (%s)", request.ip, request.port, (finalrewrite? "redirecting" : "routing"), request.id); log(Level.Trace, "Router: [T] shorthost '%s' -> localroot '%s'", request.shorthost(), localroot); - if (request.shorthost() == "" || !exists(localroot)) return(response.domainNotFound()); + if (request.shorthost() == "" || request.shorthost().canFind("..") || request.shorthost().canFind("\0") || !exists(localroot)) { + return(response.domainNotFound()); + } version(SSL) { if (serveACMEChallenge(request, response)) return; } From 5c5d3e5eb86a4dfd27a0423dacb302656bce54a1 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Sat, 5 Sep 2026 12:50:54 +0100 Subject: [PATCH 3/9] SSL unittests --- .github/workflows/d.yml | 26 +++++++++++++++++++++----- danode/https.d | 2 +- danode/ssl.d | 22 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.github/workflows/d.yml b/.github/workflows/d.yml index e765db5..20b2f41 100644 --- a/.github/workflows/d.yml +++ b/.github/workflows/d.yml @@ -4,7 +4,7 @@ name: D on: push: - branches: [ master ] + branches: [ development, master ] pull_request: branches: [ master ] @@ -13,10 +13,26 @@ jobs: runs-on: ubuntu-slim steps: - - uses: actions/checkout@v4 - - uses: dlang-community/setup-dlang@v1 + - uses: actions/checkout@v5 + with: + submodules: recursive + - uses: dlang-community/setup-dlang@v2 + + - name: 'Install OpenSSL build deps' + run: sudo apt-get update && sudo apt-get install -y build-essential perl + + - name: 'Build OpenSSL submodule' + run: | + cd deps/openssl + ./Configure linux-x86_64 + make -j$(nproc) - - name: 'Build & Test' + - name: 'Build' run: | dub build --compiler=$DC - dub test --compiler=$DC \ No newline at end of file + dub build --compiler=$DC --config=ssl + + - name: 'Test' + run: | + dub test --compiler=$DC + dub test --compiler=$DC --config=ssl \ No newline at end of file diff --git a/danode/https.d b/danode/https.d index 678ebbf..96ea242 100644 --- a/danode/https.d +++ b/danode/https.d @@ -83,7 +83,7 @@ version(SSL) { } override long receiveData(ref char[] buffer) { return(SSL_read(ssl, cast(void*) buffer, cast(int)buffer.length)); } - override bool hasBuffered() const { return(ssl !is null && SSL_pending(ssl) > 0); } + override bool hasBuffered() const { return(ssl !is null && SSL_pending(cast(SSL*) ssl) > 0); } // Send upto maxsize bytes from the response to the client override void send(ref Response response, Socket socket, ptrdiff_t maxsize = 4096){ diff --git a/danode/ssl.d b/danode/ssl.d index b67285c..77d396c 100644 --- a/danode/ssl.d +++ b/danode/ssl.d @@ -164,5 +164,27 @@ version(SSL) { unittest { tag(Level.Always, "FILE", "%s", __FILE__); + + // SNI context matching - no handshake, context pointer left null (findContext reads only hostname) + auto saved = contexts; + scope(exit) contexts = saved; + static SSLcontext mk(string h) { SSLcontext c; c.hostname[0 .. h.length] = h[]; c.hostname[h.length] = '\0'; return c; } + contexts = [mk("example.com"), mk("test.org")]; + + assert(findContext("example.com") == 0, "exact host must match its context"); + assert(findContext("test.org") == 1, "second exact host must match"); + assert(findContext("absent.net") == -1, "unknown host must return -1"); + assert(hasCertificate("example.com"), "hasCertificate must see a loaded host"); + assert(!hasCertificate("absent.net"), "hasCertificate must reject unknown host"); + // Documents current behaviour: matching is endsWith(), so a look-alike also matches (no label boundary) + assert(findContext("notexample.com") == 0, "suffix match currently accepts look-alike host"); + + // generateKey - exercises libcrypto EVP RSA keygen + PEM write + string kpath = buildPath(tempDir, "danode_test_ssl.key"); + if (kpath.exists) remove(kpath); + generateKey(kpath, 2048); + assert(kpath.exists, "generateKey must write a key file"); + assert(getSize(kpath) > 0, "generated key must be non-empty"); + remove(kpath); } } From 66e78074b0323e79c45961e0eee9a8ba7da7f907 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Sat, 5 Sep 2026 12:57:03 +0100 Subject: [PATCH 4/9] Cache the OpenSSL build --- .github/workflows/d.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/d.yml b/.github/workflows/d.yml index 20b2f41..6936ae4 100644 --- a/.github/workflows/d.yml +++ b/.github/workflows/d.yml @@ -21,7 +21,19 @@ jobs: - name: 'Install OpenSSL build deps' run: sudo apt-get update && sudo apt-get install -y build-essential perl + - name: 'OpenSSL submodule SHA' + id: openssl-sha + run: echo "sha=$(git rev-parse HEAD:deps/openssl)" >> "$GITHUB_OUTPUT" + + - name: 'Cache OpenSSL build' + id: cache-openssl + uses: actions/cache@v4 + with: + path: deps/openssl + key: openssl-${{ runner.os }}-${{ steps.openssl-sha.outputs.sha }} + - name: 'Build OpenSSL submodule' + if: steps.cache-openssl.outputs.cache-hit != 'true' run: | cd deps/openssl ./Configure linux-x86_64 From e9ca3a6bebea42d18321647948e45fdbe9102e6c Mon Sep 17 00:00:00 2001 From: DannyArends Date: Sat, 5 Sep 2026 13:00:53 +0100 Subject: [PATCH 5/9] Minimize the configure --- .github/workflows/d.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/d.yml b/.github/workflows/d.yml index 6936ae4..d7e250d 100644 --- a/.github/workflows/d.yml +++ b/.github/workflows/d.yml @@ -36,7 +36,7 @@ jobs: if: steps.cache-openssl.outputs.cache-hit != 'true' run: | cd deps/openssl - ./Configure linux-x86_64 + ./Configure linux-x86_64 no-tests no-apps no-docs make -j$(nproc) - name: 'Build' From 39bca5246534a0a6d03c214b769f56eade3ef9a2 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Sat, 5 Sep 2026 13:06:44 +0100 Subject: [PATCH 6/9] Remove non existing options --- .github/workflows/d.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/d.yml b/.github/workflows/d.yml index d7e250d..2739721 100644 --- a/.github/workflows/d.yml +++ b/.github/workflows/d.yml @@ -27,7 +27,7 @@ jobs: - name: 'Cache OpenSSL build' id: cache-openssl - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: deps/openssl key: openssl-${{ runner.os }}-${{ steps.openssl-sha.outputs.sha }} @@ -36,7 +36,7 @@ jobs: if: steps.cache-openssl.outputs.cache-hit != 'true' run: | cd deps/openssl - ./Configure linux-x86_64 no-tests no-apps no-docs + ./Configure linux-x86_64 no-tests make -j$(nproc) - name: 'Build' From 47c405475f38ca947ae4bff5b48f69c8537e0800 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Sat, 5 Sep 2026 13:28:26 +0100 Subject: [PATCH 7/9] Latest, not slim --- .github/workflows/d.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/d.yml b/.github/workflows/d.yml index 2739721..962a70a 100644 --- a/.github/workflows/d.yml +++ b/.github/workflows/d.yml @@ -10,8 +10,8 @@ on: jobs: build: - runs-on: ubuntu-slim - + runs-on: ubuntu-latest + timeout-minutes: 60 steps: - uses: actions/checkout@v5 with: From a0b1d2dedd11837314d8cb2c3ff610a2736c0a78 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Sat, 5 Sep 2026 13:28:42 +0100 Subject: [PATCH 8/9] Minimize & optimize --- danode/interfaces.d | 25 +++++++++++++++---------- danode/server.d | 16 +++++++--------- danode/workerpool.d | 14 ++++++-------- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/danode/interfaces.d b/danode/interfaces.d index 88936c2..76e02eb 100644 --- a/danode/interfaces.d +++ b/danode/interfaces.d @@ -14,14 +14,16 @@ import danode.log : log, error, Level; /* Connection/Driver interface available to the client */ abstract class DriverInterface { public: - Appender!(char[]) inbuffer; /// Input appender buffer - Socket socket; /// Client socket for reading and writing - SocketSet set; /// SocketSet used for non-blocking select on this connection - long requests = 0; /// Number of requests we handled - long[long] senddata; /// Size of data send per request - SysTime systime; /// Time in ms since this process came alive - SysTime modtime; /// Time in ms since this process was last modified - Address address; /// Private address field + Appender!(char[]) inbuffer; /// Input appender buffer + Socket socket; /// Client socket for reading and writing + SocketSet set; /// SocketSet used for non-blocking select on this connection + long requests = 0; /// Number of requests we handled + long[long] senddata; /// Size of data send per request + SysTime systime; /// Time in ms since this process came alive + SysTime modtime; /// Time in ms since this process was last modified + private Address peer; /// Peer address, resolved once on assignment + private string peerIp = "0.0.0.0"; /// Cached peer IP + private long peerPort = -1; /// Cached peer port this(Socket s) { socket = s; @@ -29,6 +31,9 @@ abstract class DriverInterface { systime = Clock.currTime(); touch(); } + + @property void address(Address a) { peer = a; if (a !is null) { peerIp = a.toAddrString(); peerPort = to!long(a.toPortString()); } } + @property const(Address) address() const { return(peer); } bool socketReady() const { if (socket !is null) { return socket.isAlive(); } return false; }; /// Is the connection alive ? void touch() { modtime = Clock.currTime(); } private ptrdiff_t readSocket(ref char[] tmpbuffer) { @@ -76,8 +81,8 @@ abstract class DriverInterface { // Send upto maxsize bytes from the response to the client void send(ref Response response, Socket conn, ptrdiff_t maxsize = 4096); - final @property long port() const { if (address !is null){ return(to!long(address.toPortString())); } return(-1); } - final @property string ip() const { if (address !is null){ return(address.toAddrString()); } return("0.0.0.0"); } + final @property long port() const { return(peerPort); } + final @property string ip() const { return(peerIp); } final @property long starttime() const { return(Msecs(systime)); } final @property long lastmodified() const { return(Msecs(modtime)); } final @property string header() const { return(fullheader(inbuffer.data)); } diff --git a/danode/server.d b/danode/server.d index a458ddc..1aef28a 100644 --- a/danode/server.d +++ b/danode/server.d @@ -15,10 +15,11 @@ import danode.workerpool : WorkerPool; import danode.webconfig : serverConfig, ServerConfig, serverConfigMutex; version(SSL) { + enum hasSSL = true; import danode.acme : checkAndRenew; import danode.ssl : loadSSL, closeSSL; import danode.https : HTTPS; -} +} else { enum hasSSL = false; } class Server { private: @@ -71,14 +72,11 @@ class Server { if (set.sISelect(socket, false, 5) <= 0) return; try { Socket accepted = socket.accept(); - string ip = accepted.remoteAddress().toAddrString(); - bool isLoopback = (ip == "127.0.0.1" || ip == "::1"); - DriverInterface driver = null; - if (!secure) driver = new HTTP(accepted); - version(SSL) { if (secure) driver = new HTTPS(accepted); } - if (driver is null) { accepted.close(); return; } - if (!pool.push(driver, ip, isLoopback)) { - log(Level.Always, "Rate limit or capacity exceeded [%s]", ip); + DriverInterface driver = (hasSSL && secure) ? new HTTPS(accepted) : new HTTP(accepted); + driver.address = accepted.remoteAddress(); + bool isLoopback = (driver.ip == "127.0.0.1" || driver.ip == "::1"); + if (!pool.push(driver, driver.ip, isLoopback)) { + log(Level.Always, "Rate limit or capacity exceeded [%s]", driver.ip); driver.closeConnection(); } } catch(Exception e) { error("Unable to accept connection, Exception: %s", e.msg); diff --git a/danode/workerpool.d b/danode/workerpool.d index 1ae62a0..32bd2cb 100644 --- a/danode/workerpool.d +++ b/danode/workerpool.d @@ -40,6 +40,7 @@ class WorkerPool { if (!isLoopback && nAlivePerIP.get(ip, 0L) >= serverConfig.get("max_clients_per_ip", 32)) return(false); if (queue.length >= serverConfig.get("max_clients", 2048)) return(false); queue ~= driver; + nAlivePerIP[ip]++; } sem.notify(); return true; @@ -73,22 +74,19 @@ class WorkerPool { if (stopped) return; if (queue.length == 0) continue; // spurious notify from stop() driver = queue[0]; - queue = queue[1 .. $]; + queue = queue[1 .. $]; } - - string ip = driver.ip; - synchronized(mutex) { nAlivePerIP[ip]++; } try { auto client = new Client(router, driver); client.run(); client.destroy(); - } catch(Exception e) { error("WorkerPool: Client exception [%s]: %s", ip, e.msg); - } catch(Error e) { error("WorkerPool: Client error [%s]: %s", ip, e.msg); } + } catch(Exception e) { error("WorkerPool: Client exception [%s]: %s", driver.ip, e.msg); + } catch(Error e) { error("WorkerPool: Client error [%s]: %s", driver.ip, e.msg); } driver = null; if (GC.stats().usedSize > 32 * 1_048_576) { GC.collect(); GC.minimize(); } synchronized(mutex) { - if (ip in nAlivePerIP && nAlivePerIP[ip] > 0) nAlivePerIP[ip]--; - if (nAlivePerIP[ip] == 0) nAlivePerIP.remove(ip); + if (driver.ip in nAlivePerIP && nAlivePerIP[driver.ip] > 0) nAlivePerIP[driver.ip]--; + if (nAlivePerIP[driver.ip] == 0) nAlivePerIP.remove(driver.ip); } } } From 1181729f45f59adfc29531c3b3d2b20864594ec7 Mon Sep 17 00:00:00 2001 From: DannyArends Date: Sat, 5 Sep 2026 13:34:53 +0100 Subject: [PATCH 9/9] Just alias out of issues --- danode/server.d | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/danode/server.d b/danode/server.d index 1aef28a..e6347fb 100644 --- a/danode/server.d +++ b/danode/server.d @@ -15,11 +15,10 @@ import danode.workerpool : WorkerPool; import danode.webconfig : serverConfig, ServerConfig, serverConfigMutex; version(SSL) { - enum hasSSL = true; import danode.acme : checkAndRenew; import danode.ssl : loadSSL, closeSSL; import danode.https : HTTPS; -} else { enum hasSSL = false; } +} else { alias HTTPS = HTTP; /* No HTTPS driver without SSL */ } class Server { private: @@ -72,7 +71,7 @@ class Server { if (set.sISelect(socket, false, 5) <= 0) return; try { Socket accepted = socket.accept(); - DriverInterface driver = (hasSSL && secure) ? new HTTPS(accepted) : new HTTP(accepted); + DriverInterface driver = secure ? new HTTPS(accepted) : new HTTP(accepted); driver.address = accepted.remoteAddress(); bool isLoopback = (driver.ip == "127.0.0.1" || driver.ip == "::1"); if (!pool.push(driver, driver.ip, isLoopback)) {