Blog post · Open Source, Networking, Security, Infrastructure

Headers Up! What's New in httpd and relayd

First published at rsadowski.de.

Rafael Sadowski details new features in OpenBSD's revived httpd and relayd: httpd header set, add and remove directives for response headers, header-based block and drop rules for filtering scrapers and scanners, and patterns(7) matching in relayd alongside glob, with config examples and a preview of upcoming glob ignorecase support.

As I mentioned in my Dead Software Walking: The ongoing evolution of relayd(8) and httpd(8) post, development of relayd(8) and httpd(8) was revived. In diesem Beitrag würde ich gerne über einige Featuers sprechen die in relayd(8) und httpd(8) einzuhalten werden.

httpd: add custom HTTP header support

OpenBSD’s httpd can now manipulate HTTP response headers directly in httpd.conf. Until now, adding security headers or stripping headers from a FastCGI backend meant changing the application code or putting relayd in front of httpd. For a simple setup, that was often more work than it should be.

The new header directive has three options. set adds a header or replaces an existing one with the same name. add appends a header even if one with that name already exists. remove suppresses a header, whether httpd set it or the backend sent it or it was inherited from the server context.

Blog post · Open Source, Networking, Security, Infrastructure

Headers Up! What's New in httpd and relayd

First published at rsadowski.de.

Rafael Sadowski details new features in OpenBSD's revived httpd and relayd: httpd header set, add and remove directives for response headers, header-based block and drop rules for filtering scrapers and scanners, and patterns(7) matching in relayd alongside glob, with config examples and a preview of upcoming glob ignorecase support.

As I mentioned in my Dead Software Walking: The ongoing evolution of relayd(8) and httpd(8) post, development of relayd(8) and httpd(8) was revived. In diesem Beitrag würde ich gerne über einige Featuers sprechen die in relayd(8) und httpd(8) einzuhalten werden.

httpd: add custom HTTP header support

OpenBSD’s httpd can now manipulate HTTP response headers directly in httpd.conf. Until now, adding security headers or stripping headers from a FastCGI backend meant changing the application code or putting relayd in front of httpd. For a simple setup, that was often more work than it should be.

The new header directive has three options. set adds a header or replaces an existing one with the same name. add appends a header even if one with that name already exists. remove suppresses a header, whether httpd set it or the backend sent it or it was inherited from the server context.

, and repetition. This lets you\nwrite rules that glob cannot express, like matching only numeric IDs. The `glob(7)` rules in `relayd`\nwere never really documented. The man page now explains what they can and cannot do.\n\n## What’s next in pattern patching syntax world\n\nI am also working on a follow-up change. Next to `pattern matching`, you will be able to write `glob` and `glob ignorecase`. `glob` stays the default. `glob ignorecase` makes matching case-insensitive on any field,\nincluding cookie values, paths, and query strings, which are otherwise compared exactly as sent. The\nchange is not committed yet, so the syntax may still change.\n\nI’m not entirely sure yet, but I could imagine extending this matching syntax to include additional elements and maybe httpd.\n\n## devel\n\nDevelopment happens primarily on the Gothub instance. The other locations are kept in sync:\n\n```\nPrimary: https://rsadowski.gothub.org/\nMirror: https://codeberg.org/rsadowski/relayd\nMirror: https://github.com/sizeofvoid/relayd\n```\nAs always, the source of truth is OpenBSD -current.A special thanks to all who [support my\nwork](https://rsadowski.de/sponsor/).\n","body_html":"\u003cp>As I mentioned in my \u003ca href=\"https://rsadowski.de/posts/2026/dead-software-walking-relayd-and-httpd/\" rel=\"nofollow ugc noopener\">Dead Software Walking: The ongoing evolution of relayd(8) and httpd(8)\u003c/a>\npost, development of\n\u003ccode>relayd(8)\u003c/code> and \u003ccode>httpd(8)\u003c/code> was revived. In diesem Beitrag würde\nich gerne über einige Featuers sprechen die in \u003ccode>relayd(8)\u003c/code> und \u003ccode>httpd(8)\u003c/code> einzuhalten werden.\u003c/p>\n\u003ch2 id=\"httpd-add-custom-http-header-support\">httpd: add custom HTTP header support\u003c/h2>\n\u003cp>OpenBSD’s \u003ccode>httpd\u003c/code> can now manipulate HTTP response headers directly in \u003ccode>httpd.conf\u003c/code>. Until now, adding\nsecurity headers or stripping headers from a FastCGI backend meant changing the application code or\nputting \u003ccode>relayd\u003c/code> in front of \u003ccode>httpd\u003c/code>. For a simple setup, that was often more work than it should be.\u003c/p>\n\u003cp>The new header directive has three options. \u003ccode>set\u003c/code> adds a header or replaces an existing one with the\nsame name. \u003ccode>add\u003c/code> appends a header even if one with that name already exists. \u003ccode>remove\u003c/code> suppresses a\nheader, whether \u003ccode>httpd\u003c/code> set it or the backend sent it or it was inherited from the server context.\u003c/p>\n\u003cp>By default, headers only apply to 2xx and 3xx responses. Add always to include error responses as well, which you usually want for security headers. Headers set in the server context are inherited by location blocks, and a location can override or remove them by name.\u003c/p>\n\u003cp>Here is a practical example: a personal blog with static pages, cacheable assets, a drafts folder, and some PHP.\u003c/p>\n\u003cpre>\u003ccode>server "blog.sizoefovid.org" {\n listen on * tls port 443\n tls {\n certificate "/etc/ssl/blog.sizeofvoid.org.crt"\n key "/etc/ssl/private/blog.sizeofvoid.org.key"\n }\n hsts max-age 31536000 # built-in, no "header set" needed\n root "/htdocs/blog"\n # Security headers for every response, including 404/500 pages\n header set "Content-Security-Policy" "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'" always\n header set "X-Content-Type-Options" "nosniff" always\n header set "Referrer-Policy" "strict-origin-when-cross-origin" always\n header set "Permissions-Policy" "camera=(), microphone=(), geolocation=()" always\n # Fingerprinted assets can be cached forever\n location "/assets/*" {\n header set "Cache-Control" "public, max-age=31536000, immutable"\n }\n # Drafts: shareable by link, but not indexed and not cached\n location "/drafts/*" {\n header set "X-Robots-Tag" "noindex, nofollow" always\n header set "Cache-Control" "no-store" always\n }\n # Don't reveal the PHP version\n location "*.php" {\n fastcgi socket "/run/php-fpm.sock"\n header remove "X-Powered-By"\n }\n}\u003c/code>\u003c/pre>\n\u003ch2 id=\"httpd-add-header-block-drop-rules-for-request-filtering\">httpd: add header block/drop rules for request filtering\u003c/h2>\n\u003cp>\u003ccode>httpd\u003c/code> can now reject incoming requests based on request headers. This is useful for keeping AI\nscrapers, vulnerability scanners, and other unwanted clients away, without \u003ccode>relayd\u003c/code> or a firewall rule\nthat only knows IP addresses.\u003c/p>\n\u003cp>Of course, it’s not perfect, and if the \u003ccode>user-agent\u003c/code> isn’t set, there’s not much we can do. But for a\nlarge number of AI scrapers, this seems to be helping at the moment!\u003c/p>\n\u003cp>I’d like to refer you to the email from purplerain () \u003ca href=\"https://secbsd.org\" rel=\"nofollow ugc noopener\">secbsd ! org\u003c/a>, who\ncarried out an analysis on this and requested this feature – and didn’t just write an initial\nversion of it. Thanks again!\u003c/p>\n\u003cp>Following those tests, the latest patch is now running in production. It has been\u003c/p>\n\u003cp>running without issues for at least 24 hours so far.\u003c/p>\n\u003cp>I will continue performing stress tests and will send additional results over the\u003c/p>\n\u003cp>next few days.\u003c/p>\n\u003cpre>\u003ccode>RESULTS\n Duration: 2h 51m 11s\n Requests sent: 10000000\n Dropped: 4820945 48.2%\n Blocked: 182191 1.8%\n Served: 4996864 50.0%\n Redirected: 0 0.0%\n Network errors: 0 0.0%\nBY CATEGORY\n FAKE: 5003136 sent · 4820945 dropped (96.4%) · 182191 responded (3.6%)\n LEGITIMATE: 4996864 sent · 0 dropped (0.0%) · 4996864 responded (100.0%)\nHTTP STATUS\n 200: 4996864\n 403: 45392\n 404: 136799\nHere are some other use cases:\n$ cat security-tools.conf\nheader drop "user-agent" "commix*"\nheader drop "user-agent" "dav*"\nheader drop "user-agent" "dirbuster*"\nheader drop "user-agent" "feroxbuster*"\nheader drop "user-agent" "ffuzz*"\nheader drop "user-agent" "gobuster*"\nheader drop "user-agent" "sqlmap*"\nheader drop "user-agent" "whatweb*"\nheader drop "user-agent" "wfuzz*"\nheader drop "user-agent" "wpscan*"\n# Drop requests with and empty User-Agent by default.\nheader drop "user-agent" ""\n# ncrack\n# lbd\n# legion\nPurple Rain\u003c/code>\u003c/pre>\n\u003cp>– \u003ca href=\"https://marc.info/?l=openbsd-tech&m=179086980940833&w=2\" rel=\"nofollow ugc noopener\">\u003ca href=\"https://marc.info/?l=openbsd-tech&m=179086980940833&w=2\" rel=\"nofollow ugc noopener\">https://marc.info/?l=openbsd-tech&m=179086980940833&w=2\u003c/a>\u003c/a>\u003c/p>\n\u003cp>There are two new options. \u003ccode>header block\u003c/code> answers a matching request with an HTTP status \u003ccode>code\u003c/code> and then\ncloses the connection. For \u003ccode>3xx codes\u003c/code>, you must give a target \u003ccode>URL\u003c/code>, which is sent as the Location\nheader. For all other codes, you can give an optional label that shows up in the log, so you can see\nwhich rule matched. \u003ccode>header drop\u003c/code> closes the connection silently, without any response. This is the\nbetter choice for scanners: they get no information back, and your server does no extra work.\u003c/p>\n\u003cp>Both the header name and the value are glob patterns (*, ?, […]) and are matched case-insensitively.\u003c/p>\n\u003cp>Here is the blog from the previous example, with some request filtering added:\u003c/p>\n\u003cpre>\u003ccode>server "blog.sizoefovid.org" {\n listen on * tls port 443\n tls {\n certificate "/etc/ssl/blog.sizeofvoid.org.crt"\n key "/etc/ssl/private/blog.sizeofvoid.org.key"\n }\n # AI scrapers: answer with 403 and a log label per rule\n header block "User-Agent" "*GPTBot*" 403 "ai-gptbot"\n header block "User-Agent" "*ClaudeBot*" 403 "ai-claudebot"\n header block "User-Agent" "*CCBot*" 403 "ai-ccbot"\n header block "User-Agent" "*Bytespider*" 403 "ai-bytespider"\n # Known scanners: no response at all\n header drop "User-Agent" "*zgrab*"\n header drop "User-Agent" "*masscan*"\n header drop "User-Agent" "*Nuclei*"\n # Log4Shell probes can hide in any header\n header drop "*" "*${jndi:*"\n # Legacy browsers: send them to a static fallback site\n header block "User-Agent" "*MSIE*" 302 "https://legacy.example.org/"\n # ... see example above\n}\u003c/code>\u003c/pre>\n\u003cp>For inspiration, here’s a list from Purple Rain that he sent me. It contains his research. You can\nsave lists like this in a file and include them in your httpd config using \u003ccode>include "ua.conf"\u003c/code>.\u003c/p>\n\u003cpre>\u003ccode># GENERIC USER-AGENTS\nheader drop "user-agent" "*bot*"\nheader drop "user-agent" "go*"\nheader drop "user-agent" "modat*"\nheader drop "user-agent" "axios*"\nheader drop "user-agent" "curl*"\nheader drop "user-agent" "crawler*"\nheader drop "user-agent" "headlesschrome*"\nheader drop "user-agent" "httpie*"\nheader drop "user-agent" "java*"\nheader drop "user-agent" "libredtail*"\nheader drop "user-agent" "libwww*"\nheader drop "user-agent" "lwp*"\nheader drop "user-agent" "node*"\nheader drop "user-agent" "okhttp*"\nheader drop "user-agent" "php*"\nheader drop "user-agent" "puppeteer*"\nheader drop "user-agent" "*python*"\nheader drop "user-agent" "*request*"\nheader drop "user-agent" "ruby*"\nheader drop "user-agent" "scrapy*"\nheader drop "user-agent" "selenium*"\nheader drop "user-agent" "wget*"\n# AI AND TRAINING\nheader drop "user-agent" "*anthropic*"\nheader drop "user-agent" "aiwebindex*"\nheader drop "user-agent" "amazon*"\nheader drop "user-agent" "amzn*"\nheader drop "user-agent" "anomura*"\nheader drop "user-agent" "apify*"\nheader drop "user-agent" "aranet*"\nheader drop "user-agent" "awario*"\nheader drop "user-agent" "azureai*"\nheader drop "user-agent" "bigsur*"\nheader drop "user-agent" "bytespider*"\nheader drop "user-agent" "chatglm*"\nheader drop "user-agent" "chatgpt*"\nheader drop "user-agent" "claude*"\nheader drop "user-agent" "cloudflare*"\nheader drop "user-agent" "cohere*"\nheader drop "user-agent" "cotoyogi*"\nheader drop "user-agent" "cragcrawler*"\nheader drop "user-agent" "crawl4ai*"\nheader drop "user-agent" "crawlspace*"\nheader drop "user-agent" "cursor*"\nheader drop "user-agent" "datenbank*"\nheader drop "user-agent" "deepseek*"\nheader drop "user-agent" "devin*"\nheader drop "user-agent" "exa*"\nheader drop "user-agent" "facebook*"\nheader drop "user-agent" "factset*"\nheader drop "user-agent" "firecrawl*"\nheader drop "user-agent" "friendlycrawler*"\nheader drop "user-agent" "geisthaus*"\nheader drop "user-agent" "iask*"\nheader drop "user-agent" "img2dataset*"\nheader drop "user-agent" "imagespider*"\nheader drop "user-agent" "isscyberriskcrawler*"\nheader drop "user-agent" "kagi-fetcher*"\nheader drop "user-agent" "kangaroo*"\nheader drop "user-agent" "kimi*"\nheader drop "user-agent" "klaviyo*"\nheader drop "user-agent" "kunatocrawler*"\nheader drop "user-agent" "laion*"\nheader drop "user-agent" "lcc*"\nheader drop "user-agent" "lightpanda*"\nheader drop "user-agent" "linguee*"\nheader drop "user-agent" "manus*"\nheader drop "user-agent" "meta*"\nheader drop "user-agent" "mistralai*"\nheader drop "user-agent" "netestate*"\nheader drop "user-agent" "newsai*"\nheader drop "user-agent" "notebooklm*"\nheader drop "user-agent" "novaact*"\nheader drop "user-agent" "omgili*"\nheader drop "user-agent" "openai*"\nheader drop "user-agent" "opencode*"\nheader drop "user-agent" "operator*"\nheader drop "user-agent" "panscient*"\nheader drop "user-agent" "perplexity*"\nheader drop "user-agent" "poggio*"\nheader drop "user-agent" "poseidon*"\nheader drop "user-agent" "querit*"\nheader drop "user-agent" "shap*"\nheader drop "user-agent" "sidetrade*"\nheader drop "user-agent" "terra*"\nheader drop "user-agent" "tiktok*"\nheader drop "user-agent" "trae*"\nheader drop "user-agent" "twinagent*"\nheader drop "user-agent" "useai*"\nheader drop "user-agent" "velenpublicwebcrawler*"\nheader drop "user-agent" "webzio*"\nheader drop "user-agent" "yaK*"\nheader drop "user-agent" "yandex*"\n# SECURITY TOOLS\n# Drop reconnaissance tools, scanners, fuzzers, brute-force tools, etc.\n# This is not and exhaustive list of tools.\n# Dirbuster, feroxbuster, and gobuster are examples, but these could be covered\n# by a single "*buster*" pattern.\n# The same applies to fuzzers as ffuf and wfuzz, both use the string "fuzz" in\n# their User-Agent, so they could be covered by a single "fuzz*" pattern. \nheader drop "user-agent" "*buster*"\nheader drop "user-agent" "commix*"\nheader drop "user-agent" "dav*"\nheader drop "user-agent" "dirbuster*"\nheader drop "user-agent" "feroxbuster*"\nheader drop "user-agent" "fuff*"\nheader drop "user-agent" "fuzz*"\nheader drop "user-agent" "gobuster*"\nheader drop "user-agent" "sqlmap*"\nheader drop "user-agent" "whatweb*"\nheader drop "user-agent" "wfuzz*"\nheader drop "user-agent" "wpscan*"\n# Drop requests with and empty User-Agent by default.\nheader drop "user-agent" ""\n# ncrack\n# nuclei\n# lbd\n# legion\n# SEARCH ENGINE AND CRAWLERS\nheader drop "user-agent" "baidu*"\nheader drop "user-agent" "bing*"\nheader drop "user-agent" "seekport*"\nheader drop "user-agent" "slurp*"\nheader drop "user-agent" "sogou*"\nheader drop "user-agent" "yahoo*"\n# SEO TOOLS AND SCRAPERS\nheader drop "user-agent" "ahrefs*"\nheader drop "user-agent" "deepcrawl*"\nheader drop "user-agent" "majestic*"\nheader drop "user-agent" "screamingfrog*"\nheader drop "user-agent" "sitebulb*"\u003c/code>\u003c/pre>\n\u003cp>\u003cstrong>!!! This list is very aggressive. It also blocks search engines, link previews, curl, and uptime\nmonitors. Read it before you use it and take only what fits your site.\u003c/strong>\u003c/p>\n\u003ch2 id=\"relayd-add-patters-7-support-and-improve-glob-7-documentation\">relayd: add patters(7) support and improve glob(7) documentation\u003c/h2>\n\u003cp>\u003ccode>relayd\u003c/code> filter rules for \u003ccode>cookie, header, path, query, and url\u003c/code> now accept an optional \u003ccode>pattern\u003c/code> keyword\nbefore the key or value. With it, the string is read as a \u003ccode>patterns(7)\u003c/code> expression, the same syntax\n\u003ccode>httpd\u003c/code> uses for location match. Without it, \u003ccode>relayd\u003c/code> uses \u003ccode>glob(7)\u003c/code> as before, so existing configs keep\nworking.\u003c/p>\n\u003cp>\u003ccode>patterns(7)\u003c/code> gives you character classes like \u003ccode>%d\u003c/code>, anchors like \u003ccode>^\u003c/code> and \u003ccode>$\u003c/code>, and repetition. This lets you\nwrite rules that glob cannot express, like matching only numeric IDs. The \u003ccode>glob(7)\u003c/code> rules in \u003ccode>relayd\u003c/code>\nwere never really documented. The man page now explains what they can and cannot do.\u003c/p>\n\u003ch2 id=\"what-s-next-in-pattern-patching-syntax-world\">What’s next in pattern patching syntax world\u003c/h2>\n\u003cp>I am also working on a follow-up change. Next to \u003ccode>pattern matching\u003c/code>, you will be able to write \u003ccode>glob\u003c/code> and \u003ccode>glob ignorecase\u003c/code>. \u003ccode>glob\u003c/code> stays the default. \u003ccode>glob ignorecase\u003c/code> makes matching case-insensitive on any field,\nincluding cookie values, paths, and query strings, which are otherwise compared exactly as sent. The\nchange is not committed yet, so the syntax may still change.\u003c/p>\n\u003cp>I’m not entirely sure yet, but I could imagine extending this matching syntax to include additional elements and maybe httpd.\u003c/p>\n\u003ch2 id=\"devel\">devel\u003c/h2>\n\u003cp>Development happens primarily on the Gothub instance. The other locations are kept in sync:\u003c/p>\n\u003cpre>\u003ccode>Primary: https://rsadowski.gothub.org/\nMirror: https://codeberg.org/rsadowski/relayd\nMirror: https://github.com/sizeofvoid/relayd\u003c/code>\u003c/pre>\n\u003cp>As always, the source of truth is OpenBSD -current.A special thanks to all who \u003ca href=\"https://rsadowski.de/sponsor/\" rel=\"nofollow ugc noopener\">support my\nwork\u003c/a>.\u003c/p>","headings":[{"level":2,"text":"httpd: add custom HTTP header support","id":"httpd-add-custom-http-header-support"},{"level":2,"text":"httpd: add header block/drop rules for request filtering","id":"httpd-add-header-block-drop-rules-for-request-filtering"},{"level":2,"text":"relayd: add patters(7) support and improve glob(7) documentation","id":"relayd-add-patters-7-support-and-improve-glob-7-documentation"},{"level":2,"text":"What’s next in pattern patching syntax world","id":"what-s-next-in-pattern-patching-syntax-world"},{"level":2,"text":"devel","id":"devel"}]}}