From: Rafael Sadowski Subject: add custom HTTP header support to httpd To: tech@openbsd.org Date: Fri, 26 Dec 2025 12:01:22 +0100 Add custom HTTP header support to httpd Allow httpd.conf to add custom HTTP response headers or suppress existing ones. This enables httpd to add security headers, custom metadata, or remove unwanted headers without modifying app code. Three new directives are added: header add Add custom header to successful responses (2xx, 3xx) header always add Add custom header to all responses including errors header hide Suppress a response header Headers can be specified in server blocks and are inherited by location blocks unless the location defines its own headers. Example configuration: server "example.com" { listen on * tls port 443 header always add "X-Frame-Options" "SAMEORIGIN" header always add "X-Content-Type-Options" "nosniff" header add "X-Powered-By" "OpenBSD httpd" location "/api/*" { header add "Access-Control-Allow-Origin" "*" } } The implementation follows the existing pattern form FastCGI parameters. So I didn't have to invent anything new. Much of the code is from existing patterns (so the potential bugs too). Just to give one example: The entire IMSG part. The motivation was to have something similar to ngx_http_headers_module without relayd coming into play. For this reason I follow the nginx pattern: "Adds the specified field to a response header provided that the response code equals 200, 201 (1.3.10), 204, 206, 301, 302, 303, 304, 307 (1.1.16, 1.0.13), or 308 (1.13.0). Parameter value can contain variables." -- https://nginx.org/en/docs/http/ngx_http_headers_module.html Tests, feedback... welcome. Rafael diff --git a/usr.sbin/httpd/config.c b/usr.sbin/httpd/config.c index b45081129b7..b115a1a8efa 100644 --- a/usr.sbin/httpd/config.c +++ b/usr.sbin/httpd/config.c @@ -196,6 +196,7 @@ clear_config_server_ptrs(struct server_config *cfg) /* clear TAILQ_HEAD */ memset(&cfg->fcgiparams, 0, sizeof(cfg->fcgiparams)); + memset(&cfg->headers, 0, sizeof(cfg->headers)); /* clear TAILQ_ENTRY */ memset(&cfg->entry, 0, sizeof(cfg->entry)); @@ -281,6 +282,9 @@ config_setserver(struct httpd *env, struct server *srv) /* Configure FCGI parameters if necessary. */ config_setserver_fcgiparams(env, srv); + + /* Configure custom headers if necessary. */ + config_setserver_headers(env, srv); } } @@ -442,6 +446,104 @@ config_setserver_fcgiparams(struct httpd *env, struct server *srv) return (0); } +int +config_getserver_headers(struct httpd *env, struct imsg *imsg) +{ + struct server *srv; + struct server_config *srv_conf, *iconf; + struct custom_header *hdr; + uint32_t id; + size_t c, nc, len; + uint8_t *p = imsg->data; + + len = sizeof(nc) + sizeof(id); + if (IMSG_DATA_SIZE(imsg) < len) { + log_debug("%s: invalid message length", __func__); + return (-1); + } + + memcpy(&nc, p, sizeof(nc)); /* number of headers */ + p += sizeof(nc); + + memcpy(&id, p, sizeof(id)); /* server conf id */ + srv_conf = serverconfig_byid(id); + p += sizeof(id); + + len += nc*sizeof(*hdr); + if (IMSG_DATA_SIZE(imsg) < len) { + log_debug("%s: invalid message length", __func__); + return (-1); + } + + /* Find associated server config */ + TAILQ_FOREACH(srv, env->sc_servers, srv_entry) { + if (srv->srv_conf.id == id) { + srv_conf = &srv->srv_conf; + break; + } + TAILQ_FOREACH(iconf, &srv->srv_hosts, entry) { + if (iconf->id == id) { + srv_conf = iconf; + break; + } + } + } + + /* Fetch custom headers */ + for (c = 0; c < nc; c++) { + if ((hdr = calloc(1, sizeof(*hdr))) == NULL) + fatalx("headers out of memory"); + memcpy(hdr, p, sizeof(*hdr)); + TAILQ_INSERT_HEAD(&srv_conf->headers, hdr, entry); + + p += sizeof(*hdr); + } + + return (0); +} + +int +config_setserver_headers(struct httpd *env, struct server *srv) +{ + struct privsep *ps = env->sc_ps; + struct server_config *srv_conf = &srv->srv_conf; + struct custom_header *hdr; + struct iovec *iov; + size_t c = 0, nc = 0; + + DPRINTF("%s: sending headers for \"%s[%u]\" to %s fd %d", __func__, + srv_conf->name, srv_conf->id, ps->ps_title[PROC_SERVER], + srv->srv_s); + + if (TAILQ_EMPTY(&srv_conf->headers)) /* nothing to do */ + return (0); + + TAILQ_FOREACH(hdr, &srv_conf->headers, entry) { + nc++; + } + if ((iov = calloc(nc + 2, sizeof(*iov))) == NULL) + return (-1); + + iov[c].iov_base = &nc; /* number of headers */ + iov[c++].iov_len = sizeof(nc); + iov[c].iov_base = &srv_conf->id; /* server config id */ + iov[c++].iov_len = sizeof(srv_conf->id); + + TAILQ_FOREACH(hdr, &srv_conf->headers, entry) { /* push headers */ + iov[c].iov_base = hdr; + iov[c++].iov_len = sizeof(*hdr); + } + if (proc_composev(ps, PROC_SERVER, IMSG_CFG_HEADERS, iov, c) != 0) { + log_warn("%s: failed to compose IMSG_CFG_HEADERS imsg for " + "`%s'", __func__, srv_conf->name); + free(iov); + return (-1); + } + free(iov); + + return (0); +} + int config_setserver_tls(struct httpd *env, struct server *srv) { @@ -640,6 +742,20 @@ config_getserver_config(struct httpd *env, struct server *srv, srv_conf->flags |= parent->flags & SRVFLAG_NO_BANNER; + /* Inherit custom headers from parent if location has none */ + if (TAILQ_EMPTY(&srv_conf->headers)) { + struct custom_header *hdr, *hdr_copy; + TAILQ_FOREACH(hdr, &parent->headers, entry) { + if ((hdr_copy = calloc(1, sizeof(*hdr_copy))) == NULL) + goto fail; + /* Copy only data fields, not TAILQ_ENTRY */ + strlcpy(hdr_copy->name, hdr->name, sizeof(hdr_copy->name)); + strlcpy(hdr_copy->value, hdr->value, sizeof(hdr_copy->value)); + hdr_copy->flags = hdr->flags; + TAILQ_INSERT_TAIL(&srv_conf->headers, hdr_copy, entry); + } + } + DPRINTF("%s: %s %d location \"%s\", " "parent \"%s[%u]\", flags: %s", __func__, ps->ps_title[privsep_process], ps->ps_instance, diff --git a/usr.sbin/httpd/httpd.conf.5 b/usr.sbin/httpd/httpd.conf.5 index b3673284e0b..1b3cbea2504 100644 --- a/usr.sbin/httpd/httpd.conf.5 +++ b/usr.sbin/httpd/httpd.conf.5 @@ -464,6 +464,32 @@ Enable static gzip compression to save bandwidth. If gzip encoding is accepted and if the requested file exists with an additional .gz suffix, use the compressed file instead and deliver it with content encoding gzip. +.It Ic header Ar option +Manipulate HTTP response headers. +Multiple header statements may be specified. +Valid options are: +.Bl -tag -width Ds +.It Ic add Ar name Ar value +Add a custom HTTP response header with the specified +.Ar name +and +.Ar value . +The header will be added to successful responses (2xx and 3xx status codes). +.It Ic always add Ar name Ar value +Add a custom HTTP response header that is included in all responses, +including error responses (4xx and 5xx status codes). +.It Ic hide Ar name +Suppress an HTTP response header with the specified +.Ar name . +This can be used to remove headers that are added by default, +such as +.Qq Last-Modified +or custom headers inherited from a parent server configuration. +.El +.Pp +Header names are limited to 127 characters and values to 511 characters. +Headers specified in a location block inherit headers from the parent +server configuration unless the location defines its own headers. .It Ic hsts Oo Ar option Oc Enable HTTP Strict Transport Security. Valid options are: @@ -895,6 +921,29 @@ server "example.com" { } } .Ed +.Pp +Custom HTTP headers can be added to responses for security or compatibility: +.Bd -literal -offset indent +server "example.com" { + listen on * tls port 443 + + # Add security headers to all responses + header always add "X-Frame-Options" "SAMEORIGIN" + header always add "X-Content-Type-Options" "nosniff" + header always add "Content-Security-Policy" "default-src 'self'" + + # Add custom header to successful responses only + header add "X-Powered-By" "OpenBSD httpd" + + # Hide default Last-Modified header + header hide "Last-Modified" + + location "/api/*" { + # Location inherits parent headers and can add more + header add "Access-Control-Allow-Origin" "*" + } +} +.Ed .Sh SEE ALSO .Xr htpasswd 1 , .Xr glob 7 , diff --git a/usr.sbin/httpd/httpd.h b/usr.sbin/httpd/httpd.h index baf11d1b523..a6dd2fb8a22 100644 --- a/usr.sbin/httpd/httpd.h +++ b/usr.sbin/httpd/httpd.h @@ -209,6 +209,7 @@ enum imsg_type { IMSG_CFG_MEDIA, IMSG_CFG_AUTH, IMSG_CFG_FCGI, + IMSG_CFG_HEADERS, IMSG_CFG_DONE, IMSG_LOG_ACCESS, IMSG_LOG_ERROR, @@ -470,6 +471,17 @@ struct fastcgi_param { }; TAILQ_HEAD(server_fcgiparams, fastcgi_param); +struct custom_header { + char name[128]; + char value[512]; + uint8_t flags; +#define HEADER_HIDE 0x01 +#define HEADER_ALWAYS 0x02 + + TAILQ_ENTRY(custom_header) entry; +}; +TAILQ_HEAD(server_headers, custom_header); + struct server_config { uint32_t id; uint32_t parent_id; @@ -540,6 +552,7 @@ struct server_config { struct server_fcgiparams fcgiparams; int fcgistrip; + struct server_headers headers; char errdocroot[HTTPD_ERRDOCROOT_MAX]; TAILQ_ENTRY(server_config) entry; @@ -673,6 +686,8 @@ void server_http(void); int server_httpdesc_init(struct client *); void server_read_http(struct bufferevent *, void *); void server_abort_http(struct client *, unsigned int, const char *); +void server_add_custom_headers(struct server_config *, struct kvtree *, + int, unsigned int); unsigned int server_httpmethod_byname(const char *); const char @@ -825,9 +840,11 @@ int config_getcfg(struct httpd *, struct imsg *); int config_setserver(struct httpd *, struct server *); int config_setserver_tls(struct httpd *, struct server *); int config_setserver_fcgiparams(struct httpd *, struct server *); +int config_setserver_headers(struct httpd *, struct server *); int config_getserver(struct httpd *, struct imsg *); int config_getserver_tls(struct httpd *, struct imsg *); int config_getserver_fcgiparams(struct httpd *, struct imsg *); +int config_getserver_headers(struct httpd *, struct imsg *); int config_setmedia(struct httpd *, struct media_type *); int config_getmedia(struct httpd *, struct imsg *); int config_setauth(struct httpd *, struct auth *); diff --git a/usr.sbin/httpd/parse.y b/usr.sbin/httpd/parse.y index 326b249662f..f5fbd02e524 100644 --- a/usr.sbin/httpd/parse.y +++ b/usr.sbin/httpd/parse.y @@ -142,6 +142,7 @@ typedef struct { %token ERROR INCLUDE AUTHENTICATE WITH BLOCK DROP RETURN PASS REWRITE %token CA CLIENT CRL OPTIONAL PARAM FORWARDED FOUND NOT %token ERRDOCS GZIPSTATIC BANNER +%token HEADER ADD ALWAYS HIDE %token STRING %token NUMBER %type port @@ -323,6 +324,7 @@ server : SERVER optmatch STRING { SPLAY_INIT(&srv->srv_clients); TAILQ_INIT(&srv->srv_hosts); TAILQ_INIT(&srv_conf->fcgiparams); + TAILQ_INIT(&srv_conf->headers); TAILQ_INSERT_TAIL(&srv->srv_hosts, srv_conf, entry); } '{' optnl serveropts_l '}' { @@ -557,6 +559,7 @@ serveroptsl : LISTEN ON STRING opttls port { | root | directory | banner + | header | logformat | fastcgi | authenticate @@ -643,6 +646,8 @@ serveroptsl : LISTEN ON STRING opttls port { srv = s; srv_conf = &srv->srv_conf; SPLAY_INIT(&srv->srv_clients); + TAILQ_INIT(&srv_conf->fcgiparams); + TAILQ_INIT(&srv_conf->headers); } '{' optnl serveropts_l '}' { struct server *s = NULL; uint32_t f; @@ -710,6 +715,82 @@ banner : BANNER { } ; +header : HEADER HIDE STRING { + struct custom_header *hdr; + + if ((hdr = calloc(1, sizeof(*hdr))) == NULL) + fatal("out of memory"); + + if (strlcpy(hdr->name, $3, sizeof(hdr->name)) >= + sizeof(hdr->name)) { + yyerror("header name truncated"); + free($3); + free(hdr); + YYERROR; + } + free($3); + + hdr->flags = HEADER_HIDE; + TAILQ_INSERT_TAIL(&srv->srv_conf.headers, hdr, entry); + } + | HEADER ADD STRING STRING { + struct custom_header *hdr; + + if ((hdr = calloc(1, sizeof(*hdr))) == NULL) + fatal("out of memory"); + + if (strlcpy(hdr->name, $3, sizeof(hdr->name)) >= + sizeof(hdr->name)) { + yyerror("header name truncated"); + free($3); + free($4); + free(hdr); + YYERROR; + } + free($3); + + if (strlcpy(hdr->value, $4, sizeof(hdr->value)) >= + sizeof(hdr->value)) { + yyerror("header value truncated"); + free($4); + free(hdr); + YYERROR; + } + free($4); + + hdr->flags = 0; + TAILQ_INSERT_TAIL(&srv->srv_conf.headers, hdr, entry); + } + | HEADER ALWAYS ADD STRING STRING { + struct custom_header *hdr; + + if ((hdr = calloc(1, sizeof(*hdr))) == NULL) + fatal("out of memory"); + + if (strlcpy(hdr->name, $4, sizeof(hdr->name)) >= + sizeof(hdr->name)) { + yyerror("header name truncated"); + free($4); + free($5); + free(hdr); + YYERROR; + } + free($4); + + if (strlcpy(hdr->value, $5, sizeof(hdr->value)) >= + sizeof(hdr->value)) { + yyerror("header value truncated"); + free($5); + free(hdr); + YYERROR; + } + free($5); + + hdr->flags = HEADER_ALWAYS; + TAILQ_INSERT_TAIL(&srv->srv_conf.headers, hdr, entry); + } + ; + optfound : /* empty */ { $$ = 0; } | FOUND { $$ = 1; } | NOT FOUND { $$ = -1; } @@ -1447,7 +1528,9 @@ lookup(char *s) /* this has to be sorted always */ static const struct keywords keywords[] = { { "access", ACCESS }, + { "add", ADD }, { "alias", ALIAS }, + { "always", ALWAYS }, { "authenticate", AUTHENTICATE}, { "auto", AUTO }, { "backlog", BACKLOG }, @@ -1475,6 +1558,8 @@ lookup(char *s) { "forwarded", FORWARDED }, { "found", FOUND }, { "gzip-static", GZIPSTATIC }, + { "header", HEADER }, + { "hide", HIDE }, { "hsts", HSTS }, { "include", INCLUDE }, { "index", INDEX }, diff --git a/usr.sbin/httpd/server.c b/usr.sbin/httpd/server.c index 5d5063b6480..e63ae942035 100644 --- a/usr.sbin/httpd/server.c +++ b/usr.sbin/httpd/server.c @@ -503,6 +503,7 @@ serverconfig_reset(struct server_config *srv_conf) srv_conf->tls_ocsp_staple = NULL; srv_conf->tls_ocsp_staple_file = NULL; TAILQ_INIT(&srv_conf->fcgiparams); + TAILQ_INIT(&srv_conf->headers); } struct server * @@ -1369,6 +1370,9 @@ server_dispatch_parent(int fd, struct privsep_proc *p, struct imsg *imsg) case IMSG_CFG_FCGI: config_getserver_fcgiparams(httpd_env, imsg); break; + case IMSG_CFG_HEADERS: + config_getserver_headers(httpd_env, imsg); + break; case IMSG_CFG_DONE: config_getcfg(httpd_env, imsg); break; diff --git a/usr.sbin/httpd/server_fcgi.c b/usr.sbin/httpd/server_fcgi.c index f3c01a459b0..b0e57194d39 100644 --- a/usr.sbin/httpd/server_fcgi.c +++ b/usr.sbin/httpd/server_fcgi.c @@ -730,6 +730,8 @@ server_fcgi_header(struct client *clt, unsigned int code) kv_add(&resp->http_headers, "Date", tmbuf) == NULL)) return (-1); + server_add_custom_headers(srv_conf, &resp->http_headers, 0, code); + if (server_writeresponse_http(clt) == -1 || server_bufferevent_print(clt, "\r\n") == -1 || server_headers(clt, resp, server_writeheader_http, NULL) == -1 || diff --git a/usr.sbin/httpd/server_http.c b/usr.sbin/httpd/server_http.c index 485b67bc5e8..18a9fe2e1b3 100644 --- a/usr.sbin/httpd/server_http.c +++ b/usr.sbin/httpd/server_http.c @@ -54,6 +54,7 @@ char *server_expand_http(struct client *, const char *, char *, size_t); char *replace_var(char *, const char *, const char *); char *read_errdoc(const char *, const char *); +char *get_always_custom_headers(struct server_config *); static struct http_method http_methods[] = HTTP_METHODS; static struct http_error http_errors[] = HTTP_ERRORS; @@ -891,6 +892,7 @@ server_abort_http(struct client *clt, unsigned int code, const char *msg) char tmbuf[32], hbuf[128], *hstsheader = NULL; char *clenheader = NULL; char *bannerheader = NULL, *bannertoken = NULL; + char *customheaders = NULL; char buf[IBUF_READ_SIZE]; char *escapedmsg = NULL; char cstr[5]; @@ -1059,6 +1061,8 @@ server_abort_http(struct client *clt, unsigned int code, const char *msg) goto done; } + customheaders = get_always_custom_headers(srv_conf); + /* Add basic HTTP headers */ if (asprintf(&httpmsg, "HTTP/1.0 %03d %s\r\n" @@ -1069,6 +1073,7 @@ server_abort_http(struct client *clt, unsigned int code, const char *msg) "%s" "%s" "%s" + "%s" "\r\n" "%s", code, httperr, tmbuf, @@ -1076,6 +1081,7 @@ server_abort_http(struct client *clt, unsigned int code, const char *msg) clenheader == NULL ? "" : clenheader, extraheader == NULL ? "" : extraheader, hstsheader == NULL ? "" : hstsheader, + customheaders == NULL ? "" : customheaders, desc->http_method == HTTP_METHOD_HEAD || clenheader == NULL ? "" : body) == -1) goto done; @@ -1091,6 +1097,7 @@ server_abort_http(struct client *clt, unsigned int code, const char *msg) free(clenheader); free(bannerheader); free(bannertoken); + free(customheaders); if (msg == NULL) msg = "\"\""; if (asprintf(&httpmsg, "%s (%03d %s)", msg, code, httperr) == -1) { @@ -1560,6 +1567,76 @@ server_locationaccesstest(struct server_config *srv_conf, const char *path) (ret == 0 && SRVFLAG_LOCATION_NOT_FOUND & srv_conf->flags)); } +/* + * Add or remove custom headers configured for this server. + * Headers marked as always are included in all responses. + * Regular headers are only added to 2xx and 3xx responses. + */ +void +server_add_custom_headers(struct server_config *srv_conf, + struct kvtree *headers, int is_error, unsigned int code) +{ + struct custom_header *hdr; + struct kv *kv, search; + + TAILQ_FOREACH(hdr, &srv_conf->headers, entry) { + if (is_error && !(hdr->flags & HEADER_ALWAYS)) + continue; + + if (!is_error && !(hdr->flags & HEADER_ALWAYS)) { + if (code != 200 && code != 201 && code != 204 && + code != 206 && code != 301 && code != 302 && + code != 303 && code != 304 && code != 307 && + code != 308) + continue; + } + + if (hdr->flags & HEADER_HIDE) { + /* Remove header from response */ + search.kv_key = hdr->name; + if ((kv = kv_find(headers, &search)) != NULL) + kv_delete(headers, kv); + } else { + /* Add header to response */ + kv_add(headers, hdr->name, hdr->value); + } + } +} + +/* + * Build a raw custom HTTP header. + * only includes headers marked as always. + * Returns the string or NULL on error/no headers. + */ +char * +get_always_custom_headers(struct server_config *srv_conf) +{ + struct custom_header *hdr; + char *headers = NULL; + char *tmp = NULL; + + TAILQ_FOREACH(hdr, &srv_conf->headers, entry) { + if (!(hdr->flags & HEADER_ALWAYS) || hdr->flags & HEADER_HIDE) + continue; + + if (headers == NULL) { + if (asprintf(&headers, "%s: %s\r\n", hdr->name, + hdr->value) == -1) { + return (NULL); + } + } else { + if (asprintf(&tmp, "%s%s: %s\r\n", headers, hdr->name, + hdr->value) == -1) { + free(headers); + return (NULL); + } + free(headers); + headers = tmp; + } + } + return (headers); +} + int server_response_http(struct client *clt, unsigned int code, struct media_type *media, off_t size, time_t mtime) @@ -1627,6 +1704,8 @@ server_response_http(struct client *clt, unsigned int code, return (-1); } + server_add_custom_headers(srv_conf, &resp->http_headers, 0, code); + /* Date header is mandatory and should be added as late as possible */ if (server_http_time(time(NULL), tmbuf, sizeof(tmbuf)) <= 0 || kv_add(&resp->http_headers, "Date", tmbuf) == NULL)