From defc1e8ce923e5e1463c35f8a260eea94507f4e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Kugland?= Date: Tue, 6 Dec 2022 07:46:52 -0300 Subject: [PATCH] Custom headers with the CLI option `--header` (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These changes add a command-line option --header, e.g. --header 'Access-Control-Allow-Origin: *'. Basic tests are included for this option. When accepting the argument, a very simple sanitization is made, the string is required to contain ": ", and can’t contain a '\n' character. These checks are far from what is required to truly validate a HTTP header, but will at least detect simple mistakes and forbid the abuse of having arguments that include more than one header, or, worse, that include a body for the response (after "\r\n\r\n"). This should also close the Issue #16 and PR #27, I think, since CORS functionality can be obtained by specifying a custom header. --- README.md | 8 +++ darkhttpd.c | 37 ++++++++++++-- devel/run-tests | 16 ++++++ devel/test_custom_headers.py | 95 ++++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 devel/test_custom_headers.py diff --git a/README.md b/README.md index 225d842..6f5d7e8 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Features: * Supports If-Modified-Since. * Supports Keep-Alive connections. * Supports IPv6. +* Support arbitrary custom response headers. * Can serve 301 redirects based on Host header. * Uses sendfile() on FreeBSD, Solaris and Linux. * Can use acceptfilter on FreeBSD. @@ -139,6 +140,13 @@ Web forward (301) requests for all hosts: --forward-all http://catchall.example.com ``` +Arbitrary custom response headers (in this case, allow all cross-origin +requests): + +``` +./darkhttpd /var/www/htdocs --header 'Access-Control-Allow-Origin: *' +``` + Commandline options can be combined: ``` diff --git a/darkhttpd.c b/darkhttpd.c index 39357b8..0343131 100644 --- a/darkhttpd.c +++ b/darkhttpd.c @@ -305,6 +305,7 @@ static int want_chroot = 0, want_daemon = 0, want_accf = 0, want_keepalive = 1, want_server_id = 1; static char *server_hdr = NULL; static char *auth_key = NULL; +static char *custom_hdrs = NULL; static uint64_t num_requests = 0, total_in = 0, total_out = 0; static int accepting = 1; /* set to 0 to stop accept()ing */ static int syslog_enabled = 0; @@ -935,6 +936,10 @@ static void usage(const char *argv0) { "\t\tIf the client requested HTTP, forward to HTTPS.\n" "\t\tThis is useful if darkhttpd is behind a reverse proxy\n" "\t\tthat supports SSL.\n\n"); + printf("\t--header 'Header: Value'\n" + "\t\tAdd a custom header to all responses.\n" + "\t\tThis option can be specified multiple times, in which case\n" + "\t\tthe headers are added in order of appearance.\n\n"); #ifdef HAVE_INET6 printf("\t--ipv6\n" "\t\tListen on IPv6 address.\n\n"); @@ -1025,6 +1030,8 @@ static void parse_commandline(const int argc, char *argv[]) { if (getuid() == 0) bindport = 80; + custom_hdrs = strdup(""); + wwwroot = xstrdup(argv[1]); /* Strip ending slash. */ len = strlen(wwwroot); @@ -1151,6 +1158,15 @@ static void parse_commandline(const int argc, char *argv[]) { else if (strcmp(argv[i], "--forward-https") == 0) { forward_to_https = 1; } + else if (strcmp(argv[i], "--header") == 0) { + if (++i >= argc) + errx(1, "missing argument after --header"); + if (strchr(argv[i], '\n') != NULL || strstr(argv[i], ": ") == NULL) + errx(1, "malformed argument after --header"); + char *old_custom_hdrs = custom_hdrs; + xasprintf(&custom_hdrs, "%s%s\r\n", old_custom_hdrs, argv[i]); + free(old_custom_hdrs); + } #ifdef HAVE_INET6 else if (strcmp(argv[i], "--ipv6") == 0) { inet6 = 1; @@ -1536,12 +1552,13 @@ static void default_reply(struct connection *conn, "%s" /* server */ "Accept-Ranges: bytes\r\n" "%s" /* keep-alive */ + "%s" /* custom headers */ "Content-Length: %llu\r\n" "Content-Type: text/html; charset=UTF-8\r\n" "%s" "\r\n", errcode, errname, date, server_hdr, keep_alive(conn), - llu(conn->reply_length), + custom_hdrs, llu(conn->reply_length), (auth_key != NULL ? auth_header : "")); conn->reply_type = REPLY_GENERATED; @@ -1580,10 +1597,12 @@ static void redirect(struct connection *conn, const char *format, ...) { /* "Accept-Ranges: bytes\r\n" - not relevant here */ "Location: %s\r\n" "%s" /* keep-alive */ + "%s" /* custom headers */ "Content-Length: %llu\r\n" "Content-Type: text/html; charset=UTF-8\r\n" "\r\n", - date, server_hdr, where, keep_alive(conn), llu(conn->reply_length)); + date, server_hdr, where, keep_alive(conn), + custom_hdrs, llu(conn->reply_length)); free(where); conn->reply_type = REPLY_GENERATED; @@ -2015,10 +2034,12 @@ static void generate_dir_listing(struct connection *conn, const char *path, "%s" /* server */ "Accept-Ranges: bytes\r\n" "%s" /* keep-alive */ + "%s" /* custom headers */ "Content-Length: %llu\r\n" "Content-Type: text/html; charset=UTF-8\r\n" "\r\n", - date, server_hdr, keep_alive(conn), llu(conn->reply_length)); + date, server_hdr, keep_alive(conn), custom_hdrs, + llu(conn->reply_length)); conn->reply_type = REPLY_GENERATED; conn->http_code = 200; @@ -2158,8 +2179,10 @@ static void process_get(struct connection *conn) { "%s" /* server */ "Accept-Ranges: bytes\r\n" "%s" /* keep-alive */ + "%s" /* custom headers */ "\r\n", - rfc1123_date(date, now), server_hdr, keep_alive(conn)); + rfc1123_date(date, now), server_hdr, keep_alive(conn), + custom_hdrs); conn->reply_length = 0; conn->reply_type = REPLY_GENERATED; conn->header_only = 1; @@ -2219,6 +2242,7 @@ static void process_get(struct connection *conn) { "%s" /* server */ "Accept-Ranges: bytes\r\n" "%s" /* keep-alive */ + "%s" /* custom headers */ "Content-Length: %llu\r\n" "Content-Range: bytes %llu-%llu/%llu\r\n" "Content-Type: %s\r\n" @@ -2226,6 +2250,7 @@ static void process_get(struct connection *conn) { "\r\n" , rfc1123_date(date, now), server_hdr, keep_alive(conn), + custom_hdrs, llu(conn->reply_length), llu(from), llu(to), llu(filestat.st_size), mimetype, lastmod ); @@ -2243,13 +2268,14 @@ static void process_get(struct connection *conn) { "%s" /* server */ "Accept-Ranges: bytes\r\n" "%s" /* keep-alive */ + "%s" /* custom headers */ "Content-Length: %llu\r\n" "Content-Type: %s\r\n" "Last-Modified: %s\r\n" "\r\n" , rfc1123_date(date, now), server_hdr, keep_alive(conn), - llu(conn->reply_length), mimetype, lastmod + custom_hdrs, llu(conn->reply_length), mimetype, lastmod ); conn->http_code = 200; } @@ -2880,6 +2906,7 @@ int main(int argc, char **argv) { free(wwwroot); free(server_hdr); free(auth_key); + free(custom_hdrs); } /* usage stats */ diff --git a/devel/run-tests b/devel/run-tests index ecca31c..ce1810a 100755 --- a/devel/run-tests +++ b/devel/run-tests @@ -120,6 +120,22 @@ runtests() { kill $PID wait $PID + echo "===> run --header tests" + # Wrong flags: + ./a.out . --header >/dev/null 2>/dev/null + ./a.out . --header missing_colon >/dev/null 2>/dev/null + ./a.out . --header $'X-Header: Abusive\r\n\r\nBody' >/dev/null 2>/dev/null + # Correct flags: + ./a.out $DIR --port $PORT \ + --header 'X-Header-A: First Value' --header 'X-Header-B: Second Value' \ + --forward example.com http://www.example.com \ + >>test.out.stdout 2>>test.out.stderr & + PID=$! + kill -0 $PID || exit 1 + python3 test_custom_headers.py + kill $PID + wait $PID + echo "===> run --forward-https tests" ./a.out $DIR --port $PORT --forward-https \ >>test.out.stdout 2>>test.out.stderr & diff --git a/devel/test_custom_headers.py b/devel/test_custom_headers.py new file mode 100644 index 0000000..cd3ddd8 --- /dev/null +++ b/devel/test_custom_headers.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# This is run by the "run-tests" script. +import unittest +import os +from test import WWWROOT, TestHelper, parse, random_bytes + +class TestCustomHeaders(TestHelper): + def setUp(self): + self.datalen = 2345 + self.data = random_bytes(self.datalen) + self.url = '/data.jpeg' + self.not_found = '/not_found.jpeg' + self.fn = WWWROOT + self.url + with open(self.fn, 'wb') as f: + f.write(self.data) + + def tearDown(self): + os.unlink(self.fn) + + def test_custom_headers(self): + resp = self.get(self.url) + status, hdrs, body = parse(resp) + self.assertContains(status, '200 OK') + self.assertEqual(hdrs["Accept-Ranges"], "bytes") + self.assertEqual(hdrs["Content-Length"], str(self.datalen)) + self.assertEqual(hdrs["Content-Type"], "image/jpeg") + self.assertEqual(hdrs["X-Header-A"], "First Value") + self.assertEqual(hdrs["X-Header-B"], "Second Value") + self.assertContains(hdrs["Server"], "darkhttpd/") + assert body == self.data, [self.url, resp, status, hdrs, body] + self.assertEqual(body, self.data) + + def test_custom_headers_not_found(self): + resp = self.get(self.not_found) + status, hdrs, body = parse(resp) + self.assertContains(status, '404 Not Found') + self.assertContains(hdrs["Server"], "darkhttpd/") + self.assertEqual(hdrs["X-Header-A"], "First Value") + self.assertEqual(hdrs["X-Header-B"], "Second Value") + + def test_custom_headers_listing(self): + resp = self.get("/") + status, hdrs, body = parse(resp) + self.assertContains(status, '200 OK') + self.assertContains(body, 'data.jpeg') + self.assertContains(body, 'Generated by darkhttpd/') + self.assertEqual(hdrs["Accept-Ranges"], "bytes") + self.assertEqual(hdrs["Content-Type"], "text/html; charset=UTF-8") + self.assertEqual(hdrs["X-Header-A"], "First Value") + self.assertEqual(hdrs["X-Header-B"], "Second Value") + self.assertContains(hdrs["Server"], "darkhttpd/") + + def test_custom_headers_range(self): + resp = self.get(self.url, req_hdrs={'Range': 'bytes=0-99'}) + status, hdrs, body = parse(resp) + self.assertContains(status, '206 Partial Content') + self.assertEqual(hdrs["Accept-Ranges"], "bytes") + self.assertEqual(hdrs["Content-Length"], '100') + self.assertEqual(hdrs["Content-Type"], "image/jpeg") + self.assertEqual(hdrs["X-Header-A"], "First Value") + self.assertEqual(hdrs["X-Header-B"], "Second Value") + self.assertContains(hdrs["Server"], "darkhttpd/") + assert body == self.data[0:100], [self.url, resp, status, hdrs, body] + self.assertEqual(body, self.data[0:100]) + + def test_custom_header_if_modified_since(self): + resp1 = self.get(self.url, method="HEAD") + status, hdrs, body = parse(resp1) + lastmod = hdrs["Last-Modified"] + + resp2 = self.get(self.url, method="GET", req_hdrs= + {"If-Modified-Since": lastmod }) + status, hdrs, body = parse(resp2) + self.assertContains(status, "304 Not Modified") + self.assertEqual(hdrs["Accept-Ranges"], "bytes") + self.assertFalse("Last-Modified" in hdrs) + self.assertFalse("Content-Length" in hdrs) + self.assertFalse("Content-Type" in hdrs) + self.assertEqual(hdrs["X-Header-A"], "First Value") + self.assertEqual(hdrs["X-Header-B"], "Second Value") + + def test_custom_header_forward(self): + resp = self.get('/', req_hdrs={'Host': 'example.com'}) + status, hdrs, body = parse(resp) + self.assertEqual(hdrs["X-Header-A"], "First Value") + self.assertEqual(hdrs["X-Header-B"], "Second Value") + self.assertContains(status, "301 Moved Permanently") + expect = "http://www.example.com/" + self.assertEqual(hdrs["Location"], expect) + self.assertContains(body, expect) + +if __name__ == '__main__': + unittest.main() + +# vim:set ts=4 sw=4 et: