Index | Thread | Search

From:
Rafael Sadowski <rafael@sizeofvoid.org>
Subject:
Re: httpd: add custom HTTP header support #2
To:
tech@openbsd.org
Date:
Wed, 15 Jul 2026 08:08:51 +0200

Download raw body.

Thread
On Mon Jul 13, 2026 at 02:16:58AM +0200, Kirill A. Korinsky wrote:
> On Sat, 11 Jul 2026 16:12:46 +0200,
> Rafael Sadowski <rafael@sizeofvoid.org> wrote:
> > 
> > I think this is a solid first version for that feature. We'll certainly
> > find out about more edge cases through user feedback ... or maybe not ;)
> > 
> 
> Here first pass for review. I haven't spent much time on this. I may be wrong.
> 
> > +	TAILQ_FOREACH(hdr, &parent_srv->srv_conf.headers, entry) {
> > +		if (header_exists(srv_conf, hdr->name)) {
> > +			DPRINTF("%s: skipping header \"%s\" from parent "
> > +			    "\"%s\", overridden in location \"%s\"",
> > +			    __func__, hdr->name,
> > +			    parent_srv->srv_conf.name, srv_conf->location);
> > +			continue;
> > +		}
> 
> What happens with duplicated headers? Multiple Set-Cookie is good example.

Good catch!

Fixed by collecting the inherited headers in a temporary list and
TAILQ_CONCAT()ing them afterwards, so config_header_exists() only ever sees
headers defined in the location itself.

The semantics are unchanged otherwise: if a location mentions a header name at
all, it takes over that name completely and none of the parent's headers
with that name are inherited. So a "header add "Set-Cookie"" in a
location replaces the whole parent group rather than adding to it. The
users can use remove if they want to drop individual inherited headers.
Documented in httpd.conf(5).

> 
> > @@ -1050,6 +1063,7 @@ server_abort_http(struct client *clt, unsigned int code, const char *msg)
> >  	free(hstsheader);
> >  	free(clenheader);
> >  	free(bannerheader);
> > +	free(customheaders);
> 
> I think this code is reached only on error, and it makes customheaders leaks
> on sucess.
> 
> And next things which I not yet sure. I think that roughly 25 directives
> exceed the 16K imsg limit because every one is fixed 664 byte structure nad
> it is packed into one message.

So true! That's what happens when you follow the code from fastcgi.
FastCGI has the same problem. It's actually even worse:

 #define HTTPD_FCGI_NAME_MAX    511
 #define HTTPD_FCGI_VAL_MAX     511

I fixed the problem by sending the headers one after the other.

> 
> What seems worse: call sites ignore the resulting failure, so reload can
> succeed with all custom headers absent.
> 

Fixed.

In addition, I've improved lots of other bits and pieces:

- I **removed the whole custom headers handling** in the
  server_abort_http path. server_abort_http is not ready yet. I would
  suggest to do this in the next iteration
- I rewored the parse and some other tweaks by Fabien Romano. (see
  optalways and the new enum)
- I added header_name_forbidden in the parser to ignore some headers
  that nobody should try to change.
- Added more return checks
- Addded server_print_custom_header for better debug the header handling

Fixed and tested all findings. Keep in mind this fix none of the fastcgi
issues we found during this iterations.

diff --git a/config.c b/config.c
index 146ed2c..af02f72 100644
--- a/config.c
+++ b/config.c
@@ -197,6 +197,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));
@@ -285,6 +286,11 @@ config_setserver(struct httpd *env, struct server *srv)
 		}
 	}
 
+	/* Configure custom headers if necessary. */
+	config_inherit_headers(env, srv);
+	if (config_setserver_headers(env, srv) == -1)
+		return (-1);
+
 	/* Close server socket early to prevent fd exhaustion in the parent. */
 	if (srv->srv_s != -1) {
 		close(srv->srv_s);
@@ -443,6 +449,135 @@ config_setserver_fcgiparams(struct httpd *env, struct server *srv)
 	return (0);
 }
 
+int
+config_getserver_headers(struct httpd *env, struct imsg *imsg)
+{
+	struct server_config	*srv_conf;
+	struct custom_header	*hdr;
+	uint32_t		 id;
+	uint8_t			*p = imsg->data;
+
+	if (IMSG_DATA_SIZE(imsg) != sizeof(id) + sizeof(*hdr)) {
+		log_debug("%s: invalid message length", __func__);
+		return (-1);
+	}
+
+	memcpy(&id, p, sizeof(id));	/* server conf id */
+	p += sizeof(id);
+
+	if ((srv_conf = serverconfig_byid(id)) == NULL) {
+		log_debug("%s: invalid config id", __func__);
+		return (-1);
+	}
+
+	if ((hdr = calloc(1, sizeof(*hdr))) == NULL)
+		fatal("headers out of memory");
+
+	memcpy(hdr, p, sizeof(*hdr));
+
+	TAILQ_INSERT_TAIL(&srv_conf->headers, hdr, entry);
+
+#ifdef DEBUG
+	server_print_custom_header(__func__, hdr);
+#endif
+
+	return (0);
+}
+
+static int
+config_header_exists(struct server_config *srv_conf, const char *name)
+{
+	struct custom_header	*hdr;
+
+	TAILQ_FOREACH(hdr, &srv_conf->headers, entry) {
+		if (strcasecmp(hdr->name, name) == 0)
+			return (1);
+	}
+	return (0);
+}
+
+/*
+ * Inherit headers from parent server, skipping those
+ * already defined in the location.
+ */
+void
+config_inherit_headers(struct httpd *env, struct server *srv)
+{
+	struct server		*parent_srv;
+	struct server_config	*srv_conf = &srv->srv_conf;
+	struct custom_header	*hdr, *hdr_copy;
+	struct server_headers	 inherited;
+
+	if (!(srv_conf->flags & SRVFLAG_LOCATION))
+		return;
+
+	/* Find parent server by parent_id */
+	TAILQ_FOREACH(parent_srv, env->sc_servers, srv_entry) {
+		if (parent_srv->srv_conf.id == srv_conf->parent_id)
+			break;
+	}
+
+	if (parent_srv == NULL)
+		return;
+
+	TAILQ_INIT(&inherited);
+
+	TAILQ_FOREACH(hdr, &parent_srv->srv_conf.headers, entry) {
+		if (config_header_exists(srv_conf, hdr->name)) {
+			DPRINTF("%s: skipping header \"%s\" from parent "
+			    "\"%s\", overridden in location \"%s\"",
+			    __func__, hdr->name,
+			    parent_srv->srv_conf.name, srv_conf->location);
+			continue;
+		}
+
+		if ((hdr_copy = calloc(1, sizeof(*hdr_copy))) == NULL)
+			fatal("out of memory");
+
+		(void)strlcpy(hdr_copy->name, hdr->name,
+		    sizeof(hdr_copy->name));
+		(void)strlcpy(hdr_copy->value, hdr->value,
+		    sizeof(hdr_copy->value));
+		hdr_copy->flags = hdr->flags;
+
+		TAILQ_INSERT_TAIL(&inherited, hdr_copy, entry);
+		DPRINTF("%s: inheriting header \"%s\" from parent \"%s\" "
+		    "to location \"%s\"", __func__, hdr->name,
+		    parent_srv->srv_conf.name, srv_conf->location);
+	}
+
+	TAILQ_CONCAT(&srv_conf->headers, &inherited, entry);
+}
+
+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[2];
+
+	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);
+
+	TAILQ_FOREACH(hdr, &srv_conf->headers, entry) {
+		iov[0].iov_base = &srv_conf->id;
+		iov[0].iov_len = sizeof(srv_conf->id);
+		iov[1].iov_base = hdr;
+		iov[1].iov_len = sizeof(*hdr);
+
+		if (proc_composev(ps, PROC_SERVER, IMSG_CFG_HEADERS,
+		    iov, 2) != 0) {
+			log_warn("%s: failed to compose IMSG_CFG_HEADERS "
+			    "imsg for `%s'", __func__, srv_conf->name);
+			return (-1);
+		}
+	}
+
+	return (0);
+}
+
 int
 config_setserver_tls(struct httpd *env, struct server *srv)
 {
@@ -727,6 +862,8 @@ config_getserver(struct httpd *env, struct imsg *imsg)
 	memcpy(&srv->srv_conf, &srv_conf, sizeof(srv->srv_conf));
 	srv->srv_s = fd;
 
+	TAILQ_INIT(&srv->srv_conf.headers);
+
 	if (config_getserver_auth(env, &srv->srv_conf) != 0)
 		goto fail;
 
diff --git a/httpd.conf.5 b/httpd.conf.5
index caf55a4..a7b4cf4 100644
--- a/httpd.conf.5
+++ b/httpd.conf.5
@@ -485,6 +485,62 @@ 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
+.Ic header
+statements may be specified.
+Valid options are:
+.Bl -tag -width Ds
+.It Ic set Ar name Ar value Op Ic always
+Set a custom HTTP response header with the specified
+.Ar name
+and
+.Ar value .
+If a header with the same
+.Ar name
+is already present in the response, its value will be replaced.
+The header is added to successful responses
+(2xx and 3xx status codes) by default.
+.It Ic add Ar name Ar value Op Ic always
+Add a custom HTTP response header with the specified
+.Ar name
+and
+.Ar value .
+Unlike
+.Ic set ,
+this option appends the header even if one with the same
+.Ar name
+already exists, allowing for multiple headers with the same name.
+The header is added to successful responses
+(2xx and 3xx status codes) by default.
+.It Ic remove Ar name
+Suppress the HTTP response header with the specified
+.Ar name .
+This can be used to remove headers added by default, such as
+.Qq Last-Modified ,
+as well as headers inherited from a parent server configuration.
+.El
+.Pp
+If
+.Ic always
+is specified, the header will be included in all responses,
+including error pages (4xx and 5xx status codes).
+.Pp
+Custom headers are not added to responses generated by the server itself,
+such as built-in error pages and
+.Ic block return
+redirects.
+.Pp
+Header names are limited to 127 characters and values to 511 characters.
+Headers defined in a
+.Ic location
+block are inherited from the
+.Ic server
+context and override defined headers with the same name.
+If you do not wish to inherit these, you can remove them again with
+.Ic remove .
+
 .It Ic hsts Oo Ar option Oc
 Enable HTTP Strict Transport Security.
 Valid options are:
@@ -917,6 +973,32 @@ 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
+
+	header set "X-Powered-By" "OpenBSD httpd"
+
+	header set "X-Content-Type-Options" "nosniff" always
+	header set "X-Frame-Options" "DENY" always
+
+	header add "Set-Cookie" "id=23; HttpOnly; Secure"
+
+	# Remove default Last-Modified header
+	header remove "Last-Modified"
+
+	location "/api/*" {
+		# This location defines its own headers
+		header add "Access-Control-Allow-Origin" "*"
+		# Override from server context
+		header set "Set-Cookie" "id=5; HttpOnly; Secure"
+		# Remove from server context
+		header remove "X-Frame-Options"
+	}
+}
+.Ed
 .Sh SEE ALSO
 .Xr htpasswd 1 ,
 .Xr glob 7 ,
diff --git a/httpd.h b/httpd.h
index 740721e..20219c0 100644
--- a/httpd.h
+++ b/httpd.h
@@ -67,6 +67,8 @@
 #define HTTPD_TLS_ECDHE_CURVES	"default"
 #define HTTPD_FCGI_NAME_MAX	511
 #define HTTPD_FCGI_VAL_MAX	511
+#define HTTPD_HEADER_NAME_MAX	128
+#define HTTPD_HEADER_VAL_MAX	512
 #define FD_RESERVE		5
 
 #define SERVER_MAX_CLIENTS	1024
@@ -187,6 +189,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,
@@ -410,6 +413,13 @@ enum log_format {
 	LOG_FORMAT_FORWARDED
 };
 
+enum header_flags {
+	HEADER_REMOVE	= 0x01,
+	HEADER_ADD	= 0x02,
+	HEADER_SET	= 0x04,
+	HEADER_ALWAYS	= 0x08
+};
+
 struct log_file {
 	char			log_name[PATH_MAX];
 	int			log_fd;
@@ -448,6 +458,15 @@ struct fastcgi_param {
 };
 TAILQ_HEAD(server_fcgiparams, fastcgi_param);
 
+struct custom_header {
+	char			name[HTTPD_HEADER_NAME_MAX];
+	char			value[HTTPD_HEADER_VAL_MAX];
+	enum header_flags	flags;
+
+	TAILQ_ENTRY(custom_header) entry;
+};
+TAILQ_HEAD(server_headers, custom_header);
+
 struct server_config {
 	uint32_t		 id;
 	uint32_t		 parent_id;
@@ -519,6 +538,7 @@ struct server_config {
 	struct server_fcgiparams fcgiparams;
 	int			 fcgistrip;
 	int			 fcgiallowchunked;
+	struct server_headers	 headers;
 	char			 errdocroot[HTTPD_ERRDOCROOT_MAX];
 
 	TAILQ_ENTRY(server_config) entry;
@@ -642,6 +662,10 @@ 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 *);
+int	 server_custom_headers(struct server_config *, struct kvtree *,
+	    unsigned int);
+void	 server_print_custom_header(const char *,
+	    const struct custom_header *);
 unsigned int
 	 server_httpmethod_byname(const char *);
 const char
@@ -771,9 +795,12 @@ 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 *);
+void	 config_inherit_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/parse.y b/parse.y
index 65664e1..263afe9 100644
--- a/parse.y
+++ b/parse.y
@@ -121,6 +121,7 @@ int		 getservice(char *);
 int		 is_if_in_group(const char *, const char *);
 int		 get_fastcgi_dest(struct server_config *, const char *, char *);
 void		 remove_locations(struct server_config *);
+int		 header_name_forbidden(const char *);
 
 typedef struct {
 	union {
@@ -135,14 +136,14 @@ typedef struct {
 
 %}
 
-%token	ACCESS ALIAS AUTHENTICATE AUTO
+%token	ACCESS ADD ALIAS ALWAYS AUTHENTICATE AUTO
 %token	BACKLOG BANNER BLOCK BODY BUFFER
 %token	CA CERTIFICATE CHROOT CIPHERS CLIENT COMBINED COMMON CONNECTION CRL
 %token	DEFAULT DHE DIRECTORY DROP
 %token	ECDHE ERR ERRDOCS ERROR
 %token	FCGI FORWARDED FOUND
 %token	GZIPSTATIC
-%token	HSTS
+%token	HEADER HSTS
 %token	INCLUDE INDEX IP
 %token	KEY
 %token	LIFETIME LISTEN LOCATION LOG LOGDIR
@@ -150,15 +151,16 @@ typedef struct {
 %token	NO NODELAY NOT
 %token	OCSP ON OPTIONAL
 %token	PARAM PASS PORT PREFORK PRELOAD PROTOCOLS
-%token	REQUEST REQUESTS RETURN REWRITE ROOT
-%token	SACK SERVER SOCKET STATIC_CACHE_CONTROL STRIP STYLE SUBDOMAINS SYSLOG
+%token	REMOVE REQUEST REQUESTS RETURN REWRITE ROOT
+%token	SACK SERVER SET SOCKET STATIC_CACHE_CONTROL STRIP STYLE SUBDOMAINS
+%token	SYSLOG
 %token	TCP TICKET TIMEOUT TLS TYPE TYPES
 %token	WITH
 %token	<v.string>	STRING
 %token  <v.number>	NUMBER
 %type	<v.port>	port
 %type	<v.string>	fcgiport
-%type	<v.number>	opttls optmatch optfound
+%type	<v.number>	optalways opttls optmatch optfound
 %type	<v.tv>		timeout
 %type	<v.string>	numberstring optstring
 %type	<v.auth>	authopts
@@ -336,6 +338,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 '}'	{
@@ -570,6 +573,7 @@ serveroptsl	: LISTEN ON STRING opttls port	{
 		| root
 		| directory
 		| banner
+		| header
 		| static_cache_control
 		| logformat
 		| fastcgi
@@ -657,6 +661,7 @@ serveroptsl	: LISTEN ON STRING opttls port	{
 			srv = s;
 			srv_conf = &srv->srv_conf;
 			SPLAY_INIT(&srv->srv_clients);
+			TAILQ_INIT(&srv_conf->headers);
 		} '{' optnl serveropts_l '}'	{
 			struct server	*s = NULL;
 			uint64_t	 f;
@@ -724,6 +729,116 @@ banner		: BANNER		{
 		}
 		;
 
+optalways	:
+		/* empty */ { $$ = 0; }
+		| ALWAYS    { $$ = 1; }
+		;
+
+header		: HEADER REMOVE 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;
+			}
+			if (strcmp("Server", hdr->name) == 0) {
+				yyerror("'header remove Server' "
+					"ignored, use 'no banner'");
+				free($3);
+				free(hdr);
+				YYERROR;
+			}
+			if (header_name_forbidden(hdr->name)) {
+				free($3);
+				free(hdr);
+				YYERROR;
+			}
+			free($3);
+
+			hdr->flags = HEADER_REMOVE;
+			TAILQ_INSERT_TAIL(&srv->srv_conf.headers, hdr, entry);
+		}
+		| HEADER ADD STRING STRING optalways {
+			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;
+			}
+			if (header_name_forbidden(hdr->name)) {
+				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 = HEADER_ADD;
+			if ($5)
+				hdr->flags |= HEADER_ALWAYS;
+
+			TAILQ_INSERT_TAIL(&srv->srv_conf.headers, hdr, entry);
+		}
+		| HEADER SET STRING STRING optalways {
+			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;
+			}
+			if (header_name_forbidden(hdr->name)) {
+				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 = HEADER_SET;
+			if ($5)
+				hdr->flags |= HEADER_ALWAYS;
+			TAILQ_INSERT_TAIL(&srv->srv_conf.headers, hdr, entry);
+		}
+		;
+
 optfound	: /* empty */	{ $$ = 0; }
 		| FOUND		{ $$ = 1; }
 		| NOT FOUND	{ $$ = -1; }
@@ -1483,7 +1598,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 },
@@ -1511,6 +1628,7 @@ lookup(char *s)
 		{ "forwarded",		FORWARDED },
 		{ "found",		FOUND },
 		{ "gzip-static",	GZIPSTATIC },
+		{ "header",		HEADER },
 		{ "hsts",		HSTS },
 		{ "include",		INCLUDE },
 		{ "index",		INDEX },
@@ -1536,6 +1654,7 @@ lookup(char *s)
 		{ "prefork",		PREFORK },
 		{ "preload",		PRELOAD },
 		{ "protocols",		PROTOCOLS },
+		{ "remove",		REMOVE },
 		{ "request",		REQUEST },
 		{ "requests",		REQUESTS },
 		{ "return",		RETURN },
@@ -1543,6 +1662,7 @@ lookup(char *s)
 		{ "root",		ROOT },
 		{ "sack",		SACK },
 		{ "server",		SERVER },
+		{ "set",		SET },
 		{ "socket",		SOCKET },
 		{ "static-cache-control",	STATIC_CACHE_CONTROL },
 		{ "strip",		STRIP },
@@ -2330,17 +2450,43 @@ host(const char *s, struct addresslist *al, int max,
 	return (host_dns(s, al, max, port, ifname, ipproto));
 }
 
+int
+header_name_forbidden(const char *name)
+{
+	if (strcasecmp(name, "Content-Length") == 0 ||
+	    strcasecmp(name, "Transfer-Encoding") == 0 ||
+	    strcasecmp(name, "Connection") == 0 ||
+	    strcasecmp(name, "Date") == 0) {
+		yyerror("header \"%s\" is reserved and cannot be "
+		    "set from httpd.conf, ignored", name);
+		return (1);
+	}
+	return (0);
+}
+
 struct server *
 server_inherit(struct server *src, struct server_config *alias,
     struct server_config *addr)
 {
 	struct server	*dst, *s, *dstl;
+	struct custom_header	*hdr, *nhdr;
 
 	if ((dst = calloc(1, sizeof(*dst))) == NULL)
 		fatal("out of memory");
 
 	/* Copy the source server and assign a new Id */
 	memcpy(&dst->srv_conf, &src->srv_conf, sizeof(dst->srv_conf));
+
+	TAILQ_INIT(&dst->srv_conf.headers);
+	TAILQ_FOREACH(hdr, &src->srv_conf.headers, entry) {
+		if ((nhdr = calloc(1, sizeof(*nhdr))) == NULL)
+			fatal("out of memory");
+		strlcpy(nhdr->name, hdr->name, sizeof(nhdr->name));
+		strlcpy(nhdr->value, hdr->value, sizeof(nhdr->value));
+		nhdr->flags = hdr->flags;
+		TAILQ_INSERT_TAIL(&dst->srv_conf.headers, nhdr, entry);
+	}
+
 	if ((dst->srv_conf.tls_cert_file =
 	    strdup(src->srv_conf.tls_cert_file)) == NULL)
 		fatal("out of memory");
@@ -2430,6 +2576,18 @@ server_inherit(struct server *src, struct server_config *alias,
 			fatal("out of memory");
 
 		memcpy(&dstl->srv_conf, &s->srv_conf, sizeof(dstl->srv_conf));
+
+		/* Copy custom headers from source location */
+		TAILQ_INIT(&dstl->srv_conf.headers);
+		TAILQ_FOREACH(hdr, &s->srv_conf.headers, entry) {
+			if ((nhdr = calloc(1, sizeof(*nhdr))) == NULL)
+				fatal("out of memory");
+			strlcpy(nhdr->name, hdr->name, sizeof(nhdr->name));
+			strlcpy(nhdr->value, hdr->value, sizeof(nhdr->value));
+			nhdr->flags = hdr->flags;
+			TAILQ_INSERT_TAIL(&dstl->srv_conf.headers, nhdr, entry);
+		}
+
 		strlcpy(dstl->srv_conf.name, alias->name,
 		    sizeof(dstl->srv_conf.name));
 
diff --git a/server.c b/server.c
index 0731730..832788e 100644
--- a/server.c
+++ b/server.c
@@ -470,6 +470,7 @@ void
 serverconfig_free(struct server_config *srv_conf)
 {
 	struct fastcgi_param	*param, *tparam;
+	struct custom_header	*hdr, *thdr;
 
 	free(srv_conf->return_uri);
 	free(srv_conf->tls_ca_file);
@@ -485,6 +486,9 @@ serverconfig_free(struct server_config *srv_conf)
 
 	TAILQ_FOREACH_SAFE(param, &srv_conf->fcgiparams, entry, tparam)
 		free(param);
+
+	TAILQ_FOREACH_SAFE(hdr, &srv_conf->headers, entry, thdr)
+		free(hdr);
 }
 
 void
@@ -503,6 +507,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 *
@@ -1356,6 +1361,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/server_fcgi.c b/server_fcgi.c
index 15e5ee8..0daf55a 100644
--- a/server_fcgi.c
+++ b/server_fcgi.c
@@ -724,6 +724,9 @@ server_fcgi_header(struct client *clt, unsigned int code)
 			return (-1);
 	}
 
+	if (server_custom_headers(srv_conf, &resp->http_headers, code) == -1)
+		return (-1);
+
 	/* Date header is mandatory and should be added as late as possible */
 	key.kv_key = "Date";
 	if (kv_find(&resp->http_headers, &key) == NULL &&
diff --git a/server_http.c b/server_http.c
index 9706c80..7437dd9 100644
--- a/server_http.c
+++ b/server_http.c
@@ -51,6 +51,7 @@ void		 server_httpdesc_free(struct http_descriptor *);
 int		 server_http_authenticate(struct server_config *,
 		    struct client *);
 static int	 http_version_num(char *);
+static int	 http_is_success(unsigned int code);
 char		*server_expand_http(struct client *, const char *,
 		    char *, size_t);
 char		*replace_var(char *, const char *, const char *);
@@ -220,6 +221,27 @@ http_version_num(char *version)
 	return (0);
 }
 
+static int
+http_is_success(unsigned int code)
+{
+	return (code >= 200 && code < 400);
+}
+
+void
+server_print_custom_header(const char *i, const struct custom_header *hdr)
+{
+	if (hdr == NULL) {
+		DPRINTF("%s: hdr=NULL", i);
+		return;
+	}
+	DPRINTF("%s: hdr (%s%s%s%s) %s: %s", i,
+	    (hdr->flags & HEADER_REMOVE) ? "remove " : "",
+	    (hdr->flags & HEADER_ADD)    ? "add "    : "",
+	    (hdr->flags & HEADER_SET)    ? "set "    : "",
+	    (hdr->flags & HEADER_ALWAYS) ? "always"  : "",
+	    hdr->name, hdr->value);
+}
+
 void
 server_read_http(struct bufferevent *bev, void *arg)
 {
@@ -1598,6 +1620,45 @@ server_locationaccesstest(struct server_config *srv_conf, const char *path)
 	    (ret == 0 && SRVFLAG_LOCATION_NOT_FOUND & srv_conf->flags));
 }
 
+int
+server_custom_headers(struct server_config *srv_conf, struct kvtree *headers,
+    unsigned int code)
+{
+	struct custom_header	*hdr;
+	struct kv		*kv, search;
+
+	TAILQ_FOREACH(hdr, &srv_conf->headers, entry) {
+		/* Only include headers not marked ALWAYS on success. */
+		if (!(hdr->flags & HEADER_ALWAYS) && !http_is_success(code)) {
+			server_print_custom_header("skip", hdr);
+			continue;
+		}
+
+		search.kv_key = hdr->name;
+
+		/* deletes all existing headers of the same key */
+		if (hdr->flags & HEADER_REMOVE) {
+			server_print_custom_header("remove", hdr);
+			while ((kv = kv_find(headers, &search)) != NULL)
+				kv_delete(headers, kv);
+		/* replaces all existing headers of the same name */
+		} else if (hdr->flags & HEADER_SET) {
+			server_print_custom_header("set", hdr);
+			while ((kv = kv_find(headers, &search)) != NULL)
+				kv_delete(headers, kv);
+
+			if (kv_add(headers, hdr->name, hdr->value) == NULL)
+				return (-1);
+		/* appends a new header without checking for duplicates */
+		} else if (hdr->flags & HEADER_ADD) {
+			server_print_custom_header("add", hdr);
+			if (kv_add(headers, hdr->name, hdr->value) == NULL)
+				return (-1);
+		}
+	}
+	return(0);
+}
+
 int
 server_response_http(struct client *clt, unsigned int code,
     struct media_type *media, off_t size, time_t mtime)
@@ -1675,6 +1736,9 @@ server_response_http(struct client *clt, unsigned int code,
 			return (-1);
 	}
 
+	if (server_custom_headers(srv_conf, &resp->http_headers, code) == -1)
+		return (-1);
+
 	/* 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)