raw Software

Current lighttpd releases can add a fixed response header directly with mod_setenv. This is the first option to try, including for ordinary error responses:

server.modules += ( "mod_setenv" )

setenv.set-response-header += (
  "X-Extra" => "Yes!"
)

setenv.set-response-header replaces an existing field with the same name, while setenv.add-response-header appends another field. Headers with dedicated lighttpd modules, such as cache lifetime or content encoding, should be configured through those modules instead.

The 2008 FastCGI Workaround

The original 2008 setup addressed a limitation in the lighttpd version available at the time: the configured extra header was not retained on the built-in 404 path. A small native FastCGI responder emitted the header and handed a static error document back to lighttpd through X-Sendfile. It avoided installing PHP or Lua solely for an error response.

The retired 404-handler.tar.gz archive contained CREDITS, an INSTALL file, and the following complete 404-handler.c. Its installation note contained only this build command:

gcc -o 404-handler 404-handler.c -lfcgi
#include <fcgi_stdio.h>
#include <string.h>
#include <stdlib.h>

int main() {

        char *f;

        char *header = NULL;
        char *sefile = NULL;


        if(NULL != (f = getenv("HEADER"))) {
                header = malloc(strlen(f) + 1);
                strcpy(header, f);
        }

        if(NULL != (f = getenv("SENDFILE"))) {
                sefile = malloc(strlen(f) + 1);
                strcpy(sefile, f);
        }

        while(FCGI_Accept() >= 0) {

                if(NULL != header) {
                        printf("%s\r\n", header);
                }

                if(NULL != sefile) {
                        printf("X-Sendfile: %s\r\n\r\n", sefile);
                } else {
                        printf("\r\n");
                }
        }

        if(header) free(header);
        if(sefile) free(sefile);

        return 0;
      }

The code is short, but it trusts a complete header line from the process environment, does not reject carriage returns or line feeds, and dereferences an allocation even if malloc() fails. The two copied strings never change, so the allocations and strcpy() calls are unnecessary. It also lets one generic variable act as either a normal field or a CGI Status field, which makes configuration mistakes harder to detect.

Hardened Inline Handler

This replacement keeps the original architecture and libfcgi API but gives every value one role. The process starts only when the configured status, header name, header value, and sendfile path pass validation. The fixed error root is checked here and again by lighttpd.

#include <ctype.h>
#include <fcgi_stdio.h>
#include <stdlib.h>
#include <string.h>

#define ERROR_ROOT "/var/www/errors/"

static int has_line_break(const char *value)
{
    return value == NULL
        || strchr(value, '\r') != NULL
        || strchr(value, '\n') != NULL;
}

static int valid_header_name(const char *name)
{
    const unsigned char *cursor = (const unsigned char *) name;

    if (name == NULL || *name == '\0') {
        return 0;
    }

    while (*cursor != '\0') {
        if (!isalnum(*cursor) && *cursor != '-') {
            return 0;
        }
        cursor++;
    }
    return 1;
}

static int valid_status(const char *status)
{
    return status != NULL
        && strlen(status) >= 5
        && isdigit((unsigned char) status[0])
        && isdigit((unsigned char) status[1])
        && isdigit((unsigned char) status[2])
        && status[3] == ' '
        && !has_line_break(status);
}

static int valid_sendfile(const char *path)
{
    const size_t root_length = sizeof(ERROR_ROOT) - 1;

    return path != NULL
        && !has_line_break(path)
        && strncmp(path, ERROR_ROOT, root_length) == 0
        && path[root_length] != '\0'
        && strstr(path + root_length, "..") == NULL;
}

int main(void)
{
    const char *status = getenv("STATUS");
    const char *header_name = getenv("HEADER_NAME");
    const char *header_value = getenv("HEADER_VALUE");
    const char *sendfile = getenv("SENDFILE");

    if (!valid_status(status)
        || !valid_header_name(header_name)
        || has_line_break(header_value)
        || !valid_sendfile(sendfile)) {
        return EXIT_FAILURE;
    }

    while (FCGI_Accept() >= 0) {
        printf("Status: %s\r\n", status);
        printf("%s: %s\r\n", header_name, header_value);
        printf("X-Sendfile: %s\r\n\r\n", sendfile);
    }

    return EXIT_SUCCESS;
  }

Install the FastCGI development headers supplied by the distribution, compile with warnings enabled, and place the binary outside the document root:

cc -O2 -Wall -Wextra -Wpedantic \
  -o 404-handler 404-handler.c -lfcgi
sudo install -m 0755 404-handler /usr/local/libexec/404-handler

lighttpd Configuration

The current FastCGI option is x-sendfile; allow-x-send-file has been deprecated since lighttpd 1.4.40. Restrict the files that the backend can name with x-sendfile-docroot:

server.modules += ( "mod_fastcgi" )
server.error-handler-404 = "/handler.404"

fastcgi.server = (
  ".404" => (
    "error-header" => (
      "socket" => "/run/lighttpd/404-handler.sock",
      "bin-path" => "/usr/local/libexec/404-handler",
      "check-local" => "disable",
      "x-sendfile" => "enable",
      "x-sendfile-docroot" => ( "/var/www/errors/" ),
      "bin-environment" => (
        "STATUS" => "404 Not Found",
        "HEADER_NAME" => "X-Extra",
        "HEADER_VALUE" => "Yes!",
        "SENDFILE" => "/var/www/errors/404.html"
      )
    )
  )
)

Create the socket directory with ownership suitable for the lighttpd process, make the error document readable by lighttpd, and test the complete configuration before reloading:

sudo lighttpd -tt -f /etc/lighttpd/lighttpd.conf
sudo service lighttpd reload
curl -i https://example.org/a-path-that-does-not-exist

The Status field is now explicit. A dedicated maintenance handler can use STATUS = "503 Service Unavailable" instead of smuggling that field through a generic header variable. Keep separate backend definitions when handlers require different process environments.

X-Sendfile lets the trusted FastCGI backend ask lighttpd to serve a local file that the backend itself might not be able to read. Without x-sendfile-docroot, that authority can extend to every file readable by the web-server account. Header validation in C does not replace the lighttpd path boundary.

References