cmc/dotfiles

Using GNU Stow to manage my dotfiles.

clone: git clone https://gitbay.org/cmc/dotfiles.git

41618d30fc1fdc9ef26aa5003ac48fadf0d1fe82

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-03T16:43:15Z

revamp security headers and site confs
 linux/nginx/etc/nginx/conf.d/cleberg.dev.conf      |  1 +
 linux/nginx/etc/nginx/conf.d/cleberg.net.conf      | 25 +++++-
 linux/nginx/etc/nginx/conf.d/cv.conf               |  2 +
 linux/nginx/etc/nginx/conf.d/files.conf            |  2 +
 linux/nginx/etc/nginx/conf.d/hn.conf               |  1 +
 linux/nginx/etc/nginx/conf.d/img.conf              |  2 +
 linux/nginx/etc/nginx/conf.d/office.conf           |  2 +
 linux/nginx/etc/nginx/conf.d/org.conf              |  2 +
 linux/nginx/etc/nginx/conf.d/piped.conf            | 75 ++++++-----------
 linux/nginx/etc/nginx/conf.d/projects.conf         | 22 -----
 .../etc/nginx/conf.d/reminiscecleberg.com.conf     |  1 +
 linux/nginx/etc/nginx/conf.d/rogue.conf            | 15 +++-
 linux/nginx/etc/nginx/conf.d/rss.conf              |  8 ++
 .../etc/nginx/custom.d/http/content_type_maps.conf | 95 ++++++++++++++++++++--
 .../security/content-security-policy-cmc.conf      |  8 ++
 ...ntent-security-policy-freshrss-report-only.conf |  4 +
 .../security/content-security-policy-inline.conf   |  9 ++
 .../content-security-policy-piped-report-only.conf |  5 ++
 .../custom.d/security/headers_in_location.conf     | 27 ++++++
 .../security/headers_in_location_media.conf        | 11 +++
 .../security/headers_in_location_no_xfo.conf       | 12 +++
 21 files changed, 239 insertions(+), 90 deletions(-)

diff --git a/linux/nginx/etc/nginx/conf.d/cleberg.dev.conf b/linux/nginx/etc/nginx/conf.d/cleberg.dev.conf
index 69282e0..bd7dd07 100644
--- a/linux/nginx/etc/nginx/conf.d/cleberg.dev.conf
+++ b/linux/nginx/etc/nginx/conf.d/cleberg.dev.conf
@@ -5,6 +5,7 @@ server {
         server_name cleberg.dev;
         root /var/www/cleberg.dev/;
         include custom.d/basic.conf;
+        include custom.d/security/content-security-policy.conf;
         location / { try_files $uri $uri/ =404; }
 }
 
diff --git a/linux/nginx/etc/nginx/conf.d/cleberg.net.conf b/linux/nginx/etc/nginx/conf.d/cleberg.net.conf
index 6030fd0..fb24a37 100644
--- a/linux/nginx/etc/nginx/conf.d/cleberg.net.conf
+++ b/linux/nginx/etc/nginx/conf.d/cleberg.net.conf
@@ -5,10 +5,15 @@ server {
 	# No per-vhost error_log: it overrode the global "off" and wrote visitor
 	# IPs to disk.
 
-	# basic.conf now carries HSTS + Permissions-Policy. CSP stays explicit --
-	# the shared policy is written for this vhost (it allows img.cleberg.net).
+	# basic.conf carries HSTS + Permissions-Policy. CSP stays explicit, and now
+	# uses the cmc-specific policy: the shared $content_security_policy was made
+	# identity-neutral so it is safe to reach for on a krz vhost by default.
+	#
+	# NOTE: every `location` below that sets its own add_header must ALSO
+	# include these two files, or it discards them (nginx add_header does not
+	# merge across levels). See custom.d/security/headers_in_location.conf.
 	include custom.d/basic.conf;
-	include custom.d/security/content-security-policy.conf;
+	include custom.d/security/content-security-policy-cmc.conf;
 	root /var/www/cleberg.net/;
 	include custom.d/redirects/blog.conf;
 	port_in_redirect off;
@@ -21,6 +26,10 @@ server {
 		default_type text/markdown;
 		add_header Content-Type "text/markdown; charset=utf-8" always;
 		add_header Vary "Accept" always;
+		# Restore what this block's own add_header discarded:
+		include custom.d/security/headers_in_location.conf;
+		include custom.d/security/content-security-policy-cmc.conf;
+		add_header Onion-Location "http://paske4urhs6nttrtlkuwa5cowum3fjkc6yv6kl4ncx3mjxcd77764nqd.onion$request_uri" always;
 	}
 
 	location = / {
@@ -29,6 +38,11 @@ server {
 		}
 
 		add_header Vary "Accept" always;
+		# Restore what this block's own add_header discarded. Without these the
+		# HOMEPAGE served no security headers and no Onion-Location at all.
+		include custom.d/security/headers_in_location.conf;
+		include custom.d/security/content-security-policy-cmc.conf;
+		add_header Onion-Location "http://paske4urhs6nttrtlkuwa5cowum3fjkc6yv6kl4ncx3mjxcd77764nqd.onion$request_uri" always;
 		try_files /index.html =404;
 	}
 
@@ -48,6 +62,11 @@ server {
 			rewrite ^/(.+)\.html$ /org/$1.org last;
 		}
 		add_header Vary "Accept" always;
+		# Restore what this block's own add_header discarded. This location
+		# serves every article/blog page on the site.
+		include custom.d/security/headers_in_location.conf;
+		include custom.d/security/content-security-policy-cmc.conf;
+		add_header Onion-Location "http://paske4urhs6nttrtlkuwa5cowum3fjkc6yv6kl4ncx3mjxcd77764nqd.onion$request_uri" always;
 		try_files $uri =404;
 	}
 }
diff --git a/linux/nginx/etc/nginx/conf.d/cv.conf b/linux/nginx/etc/nginx/conf.d/cv.conf
index 3dc73a2..a8e6836 100644
--- a/linux/nginx/etc/nginx/conf.d/cv.conf
+++ b/linux/nginx/etc/nginx/conf.d/cv.conf
@@ -3,6 +3,7 @@ server {
 	server_name cv.cleberg.net;
 	add_header Onion-Location "http://xe43aewwiybmbo5qsstx7t5kmgkd2ei4bkkvqinwd7fxfmkekvnjbbad.onion$request_uri" always;
 	include custom.d/basic.conf;
+	include custom.d/security/content-security-policy.conf;
 	root /var/www/cv/;
 	autoindex on;
 	location / { try_files $uri $uri/ /index.html; }
@@ -12,6 +13,7 @@ server {
     listen 127.0.0.1:10015;
     server_name xe43aewwiybmbo5qsstx7t5kmgkd2ei4bkkvqinwd7fxfmkekvnjbbad.onion;
     include custom.d/basic.conf;
+    include custom.d/security/content-security-policy.conf;
     root /var/www/cv/;
     autoindex on;
     location / { try_files $uri $uri/ /index.html; }
diff --git a/linux/nginx/etc/nginx/conf.d/files.conf b/linux/nginx/etc/nginx/conf.d/files.conf
index 684e895..3c36465 100644
--- a/linux/nginx/etc/nginx/conf.d/files.conf
+++ b/linux/nginx/etc/nginx/conf.d/files.conf
@@ -7,6 +7,7 @@ server {
 	server_name files.krz.sh;
 	add_header Onion-Location "http://yt2y635dc6zyxziy5ytkfz36bmkgcxuccq3meom6qp44gjdat54wt5id.onion$request_uri" always;
 	include custom.d/basic.conf;
+	include custom.d/security/content-security-policy.conf;
 	root /var/www/files/;
 	autoindex on;
 	location / { try_files $uri $uri/ /index.html; }
@@ -16,6 +17,7 @@ server {
     listen 127.0.0.1:10018;
     server_name yt2y635dc6zyxziy5ytkfz36bmkgcxuccq3meom6qp44gjdat54wt5id.onion;
     include custom.d/basic.conf;
+    include custom.d/security/content-security-policy.conf;
     root /var/www/files/;
     autoindex on;
     location / { try_files $uri $uri/ /index.html; }
diff --git a/linux/nginx/etc/nginx/conf.d/hn.conf b/linux/nginx/etc/nginx/conf.d/hn.conf
index 4b2cab2..8f699da 100644
--- a/linux/nginx/etc/nginx/conf.d/hn.conf
+++ b/linux/nginx/etc/nginx/conf.d/hn.conf
@@ -7,6 +7,7 @@ server {
     autoindex on;
     add_header Onion-Location "http://r3yfeffyj7ornpikojmw75u3sn2la7tqnmcmwgv2ov7if24sm5czqeid.onion$request_uri" always;
     include custom.d/basic.conf;
+    include custom.d/security/content-security-policy.conf;
     location / { try_files $uri $uri/ /index.html; }
 }
 
diff --git a/linux/nginx/etc/nginx/conf.d/img.conf b/linux/nginx/etc/nginx/conf.d/img.conf
index 16c9ab0..bf5128b 100644
--- a/linux/nginx/etc/nginx/conf.d/img.conf
+++ b/linux/nginx/etc/nginx/conf.d/img.conf
@@ -3,6 +3,7 @@ server {
 	server_name img.cleberg.net;
 	add_header Onion-Location "http://ltf2dfg7tj263ll24lxuq7igejcrhcazjp6whgg6u6vyjir2iahqggad.onion$request_uri" always;
 	include custom.d/basic.conf;
+	include custom.d/security/content-security-policy.conf;
 	root /var/www/img/;
 	autoindex on;
 	location / { try_files $uri $uri/ =404; }
@@ -12,6 +13,7 @@ server {
     listen 127.0.0.1:10026;
     server_name ltf2dfg7tj263ll24lxuq7igejcrhcazjp6whgg6u6vyjir2iahqggad.onion;
     include custom.d/basic.conf;
+    include custom.d/security/content-security-policy.conf;
     root /var/www/img/;
     autoindex on;
     location / { try_files $uri $uri/ =404; }
diff --git a/linux/nginx/etc/nginx/conf.d/office.conf b/linux/nginx/etc/nginx/conf.d/office.conf
index 74c7032..a9bd5bb 100644
--- a/linux/nginx/etc/nginx/conf.d/office.conf
+++ b/linux/nginx/etc/nginx/conf.d/office.conf
@@ -5,12 +5,14 @@ server {
 	add_header Onion-Location "http://uwtjz6pof52bzdkj242mub77ls2ji3qiznbsuoir235rxynqxadpghid.onion$request_uri" always;
 	root /var/www/office/;
 	include custom.d/basic.conf;
+	include custom.d/security/content-security-policy-inline.conf;
 	location / { try_files $uri $uri/ /index.html; }
 }
 server {
     listen 127.0.0.1:10032;
     server_name uwtjz6pof52bzdkj242mub77ls2ji3qiznbsuoir235rxynqxadpghid.onion;
     include custom.d/basic.conf;
+    include custom.d/security/content-security-policy-inline.conf;
     root /var/www/office/;
     location / { try_files $uri $uri/ /index.html; }
 }
diff --git a/linux/nginx/etc/nginx/conf.d/org.conf b/linux/nginx/etc/nginx/conf.d/org.conf
index fbf84a0..06b5f17 100644
--- a/linux/nginx/etc/nginx/conf.d/org.conf
+++ b/linux/nginx/etc/nginx/conf.d/org.conf
@@ -5,12 +5,14 @@ server {
 	add_header Onion-Location "http://7vsifofpucgi3wva52nbwhwglrmbyxgdapbkkmgniqlsskspux5zocyd.onion$request_uri" always;
 	root /var/www/org/;
 	include custom.d/basic.conf;
+	include custom.d/security/content-security-policy.conf;
 	location / { try_files $uri $uri/ /index.html; }
 }
 server {
     listen 127.0.0.1:10034;
     server_name 7vsifofpucgi3wva52nbwhwglrmbyxgdapbkkmgniqlsskspux5zocyd.onion;
     include custom.d/basic.conf;
+    include custom.d/security/content-security-policy.conf;
     root /var/www/org/;
     location / { try_files $uri $uri/ /index.html; }
 }
diff --git a/linux/nginx/etc/nginx/conf.d/piped.conf b/linux/nginx/etc/nginx/conf.d/piped.conf
index c1ac91e..ad5dcda 100644
--- a/linux/nginx/etc/nginx/conf.d/piped.conf
+++ b/linux/nginx/etc/nginx/conf.d/piped.conf
@@ -1,23 +1,24 @@
-# Piped -- Host-based router on 127.0.0.1:8077
+# Piped frontend -- 127.0.0.1:8077 -> container on :8076
 #
-# WHY THIS EXISTS: the Cloudflare tunnel routes all three Piped hostnames to
-# localhost:8077 --
-#     piped.krz.sh       -> :8077
-#     pipedapi.krz.sh    -> :8077   (should be the backend)
-#     pipedproxy.krz.sh  -> :8077   (should be the media proxy)
-# so the API and media-proxy hostnames landed on the frontend and Piped was
-# broken. The frontend advertises BACKEND_HOSTNAME=pipedapi.krz.sh to browsers,
-# so every API call failed.
+# HISTORY: this file used to be a Host-based router for all three Piped
+# hostnames, because the Cloudflare tunnel sent pipedapi.krz.sh and
+# pipedproxy.krz.sh to :8077 as well, landing them on the frontend and breaking
+# every API call. The dashboard was corrected 2026-08-03 to point pipedapi at
+# :8078 and pipedproxy at :8079 directly, so those two server blocks became
+# dead code and were removed.
 #
-# The tidier fix is two edits in the Cloudflare dashboard (point pipedapi at
-# :8078 and pipedproxy at :8079). This file fixes it server-side instead, and
-# is harmless if the dashboard is corrected later -- the tunnel would simply
-# reach the containers directly and these blocks would go unused.
+# Ports: frontend :8076, backend :8078, media proxy :8079.
 #
-# Ports: frontend :8076 (moved from :8077), backend :8078, media proxy :8079.
+# WHY THE FRONTEND BLOCK STAYS: the tunnel still routes piped.krz.sh here
+# (confirmed -- the security headers added below appear on the public
+# response), and the Tor onion for piped targets :8077 with a .onion Host
+# header, which needs default_server to land somewhere. Routing the frontend
+# straight to :8076 would also drop the header/CSP work below.
 #
 # NOTE: custom.d/basic.conf is deliberately NOT included. Its Permissions-Policy
-# sets fullscreen=(), which would stop videos going fullscreen.
+# sets fullscreen=(), which would stop videos going fullscreen. See
+# headers_in_location_media.conf, included in the location, for the variant
+# that keeps every other restriction.
 
 # Frontend. default_server so the Tor onion for piped (which targets :8077 with
 # a .onion Host header) also lands here.
@@ -32,43 +33,13 @@ server {
         proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
         proxy_set_header X-Forwarded-Proto $scheme;
         proxy_http_version 1.1;
-    }
-}
-
-# Backend API.
-server {
-    listen 127.0.0.1:8077;
-    server_name pipedapi.krz.sh;
-
-    location / {
-        proxy_pass http://127.0.0.1:8078;
-        proxy_set_header Host              $host;
-        proxy_set_header X-Real-IP         $remote_addr;
-        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-        proxy_http_version 1.1;
-        # The backend emits its own CORS headers; do not add or override any
-        # here or the browser will reject the API responses.
-        proxy_read_timeout 120;
-    }
-}
-
-# Media proxy. Streams video, so no buffering and generous timeouts.
-server {
-    listen 127.0.0.1:8077;
-    server_name pipedproxy.krz.sh;
-
-    location / {
-        proxy_pass http://127.0.0.1:8079;
-        proxy_set_header Host              $host;
-        proxy_set_header X-Real-IP         $remote_addr;
-        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-        proxy_http_version 1.1;
 
-        proxy_buffering off;
-        proxy_request_buffering off;
-        proxy_read_timeout 300;
-        proxy_send_timeout 300;
+        # This vhost previously served NO security headers at all, because
+        # basic.conf was excluded wholesale to avoid its `fullscreen=()`.
+        # The media bundle keeps every other restriction and permits
+        # fullscreen/autoplay/PiP on this origin, so the player still works.
+        include custom.d/security/headers_in_location_media.conf;
+        # CSP is REPORT-ONLY -- see content_type_maps.conf. Nothing is blocked.
+        include custom.d/security/content-security-policy-piped-report-only.conf;
     }
 }
diff --git a/linux/nginx/etc/nginx/conf.d/projects.conf b/linux/nginx/etc/nginx/conf.d/projects.conf
deleted file mode 100644
index bcdfae9..0000000
--- a/linux/nginx/etc/nginx/conf.d/projects.conf
+++ /dev/null
@@ -1,22 +0,0 @@
-server {
-	listen 127.0.0.1:10040;
-	server_name projects.zerolabs.sh;
-
-	add_header Onion-Location "http://krednuajd2wpt67ozmkollddpbdphpeyh74hbenagzoxqjncrtrgxkid.onion$request_uri" always;
-	root /var/www/projects/;
-	autoindex on;
-	include custom.d/basic.conf;
-	location / { try_files $uri $uri/ /index.html; }
-}
-
-server {
-    listen 127.0.0.1:10041;
-    server_name krednuajd2wpt67ozmkollddpbdphpeyh74hbenagzoxqjncrtrgxkid.onion;
-    include custom.d/basic.conf;
-    root /var/www/projects/;
-    autoindex on;
-    location / { try_files $uri $uri/ /index.html; }
-}
-
-
-
diff --git a/linux/nginx/etc/nginx/conf.d/reminiscecleberg.com.conf b/linux/nginx/etc/nginx/conf.d/reminiscecleberg.com.conf
index 02b1721..a84bf2c 100644
--- a/linux/nginx/etc/nginx/conf.d/reminiscecleberg.com.conf
+++ b/linux/nginx/etc/nginx/conf.d/reminiscecleberg.com.conf
@@ -5,6 +5,7 @@ server {
 	server_name reminiscecleberg.com;
 	root /var/www/reminiscecleberg.com/;
 	include custom.d/basic.conf;
+	include custom.d/security/content-security-policy.conf;
 	location / { try_files $uri $uri/ =404; }
 }
 
diff --git a/linux/nginx/etc/nginx/conf.d/rogue.conf b/linux/nginx/etc/nginx/conf.d/rogue.conf
index 1618be3..3c85dd8 100644
--- a/linux/nginx/etc/nginx/conf.d/rogue.conf
+++ b/linux/nginx/etc/nginx/conf.d/rogue.conf
@@ -2,8 +2,9 @@ server {
     listen 127.0.0.1:10001;
     server_name rogue.krz.sh;
 
-    root /var/www/rogue;
+    root /var/www/rogue/;
     index index.html;
+    autoindex on;
 
     server_tokens off;
     etag off;
@@ -13,9 +14,15 @@ server {
     gzip_vary off;
     charset off;
 
-    location = / {
-        try_files /index.html =404;
-    }
+    # `/` used to 404: this block did `try_files /index.html =404` and there is
+    # no index.html at the webroot, so it short-circuited before `autoindex on`
+    # could ever produce a listing. Removed, so `/` now falls through to the
+    # server-level `index`/`autoindex` and lists 1kb/ and 1mb/.
+    #
+    # Deliberately NOT adding basic.conf here: this vhost strips headers on
+    # purpose (server_tokens/etag/expires/gzip/charset all off) because it is a
+    # byte-size experiment -- a 1 KB CSS-only game. Adding security headers
+    # would defeat the point of the vhost.
 
     location = /index.html {
         try_files /index.html =404;
diff --git a/linux/nginx/etc/nginx/conf.d/rss.conf b/linux/nginx/etc/nginx/conf.d/rss.conf
index 44bde88..963950f 100644
--- a/linux/nginx/etc/nginx/conf.d/rss.conf
+++ b/linux/nginx/etc/nginx/conf.d/rss.conf
@@ -6,7 +6,15 @@ server {
 	include custom.d/basic.conf;
 	location / {
 		proxy_pass http://freshrss/;
+		# This add_header discarded EVERYTHING from basic.conf -- nginx does
+		# not merge add_header across levels. Verified: this vhost was serving
+		# X-Frame-Options and nothing else (no Referrer-Policy, no HSTS, no
+		# Permissions-Policy, no X-Content-Type-Options). Restored below.
+		# SAMEORIGIN (not the shared map's DENY) -- hence the _no_xfo bundle,
+		# which omits X-Frame-Options so the two do not conflict.
 		add_header X-Frame-Options SAMEORIGIN;
+		include custom.d/security/headers_in_location_no_xfo.conf;
+		include custom.d/security/content-security-policy-freshrss-report-only.conf;
 		proxy_redirect off;
 		proxy_buffering off;
 		proxy_set_header Host $host;
diff --git a/linux/nginx/etc/nginx/custom.d/http/content_type_maps.conf b/linux/nginx/etc/nginx/custom.d/http/content_type_maps.conf
index 8f00d12..ee53aed 100644
--- a/linux/nginx/etc/nginx/custom.d/http/content_type_maps.conf
+++ b/linux/nginx/etc/nginx/custom.d/http/content_type_maps.conf
@@ -32,16 +32,93 @@ map $sent_http_content_type $x_frame_options {
 }
 
 # Add Content-Security-Policy for HTML documents.
+#
+# TWO POLICIES, DELIBERATELY SEPARATE -- do not merge them.
+#
+# $content_security_policy      -- identity-neutral. Safe on ANY vhost.
+# $content_security_policy_cmc  -- for `cleberg.*` vhosts ONLY. Names
+#                                  img.cleberg.net, so serving it on a
+#                                  `krz`/`zerolabs` vhost would put the
+#                                  real-name domain in a response header.
+#
+# The previous single map hardcoded `img-src 'self' https://img.cleberg.net`
+# and was the only policy available, so any per-app CSP work would have leaked
+# the real-name domain onto a pseudonymous vhost by default. Keeping the
+# default neutral makes the safe choice the automatic one.
+#
+# Also fixed here: the old value was a multi-line quoted string containing 8
+# literal newlines, which nginx emitted verbatim -- a folded, malformed header
+# that clients saw as empty. Policies must be ONE line.
 map $sent_http_content_type $content_security_policy {
-  ~*text/(html|javascript)|application/pdf|xml "
-    default-src 'self';
-    img-src 'self' https://img.cleberg.net;
-    base-uri 'none';
-    form-action 'self';
-    frame-ancestors 'none';
-    object-src 'none';
-    upgrade-insecure-requests
-  ";
+  ~*text/(html|javascript)|application/pdf|xml "default-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests";
+}
+
+# cleberg.* only. Mirrors what the site actually loads, verified by grepping
+# the served HTML: <img src> -> img.cleberg.net (151), <script src> ->
+# bubbles.town (174), stylesheets -> https://cleberg.net (180, absolute).
+#
+# `https://cleberg.net` must be allowed explicitly: the HTML uses ABSOLUTE
+# stylesheet URLs, so on the .onion (a different origin) they are cross-origin
+# and 'self' would block them.
+#
+# `upgrade-insecure-requests` is deliberately OMITTED: this vhost also serves
+# the .onion over plain http, where UIR would upgrade same-origin subresource
+# URLs to https and break them. Cloudflare already sets UIR on the clearnet
+# path. See the onion self-containment note in the project record.
+map $sent_http_content_type $content_security_policy_cmc {
+  ~*text/(html|javascript)|application/pdf|xml "default-src 'self'; img-src 'self' https://img.cleberg.net data:; script-src 'self' https://bubbles.town; connect-src 'self' https://bubbles.town; style-src 'self' https://cleberg.net; font-src 'self' https://cleberg.net; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'";
+}
+
+# For apps that ship inline <script>/<style>/style="" and cannot use the strict
+# policy. Currently only office.zerolabs.sh (5 files with inline <script>,
+# 3 with <style>, 2 with style attributes). Identity-neutral -- no host is
+# named, so it is safe on any vhost.
+#
+# 'unsafe-inline' is a real weakening: it is what strict CSP exists to prevent.
+# It is used here only because the alternative is no CSP at all, which is
+# strictly worse. The upgrade path is per-file hashes or nonces; that needs
+# changes to the app, not to nginx.
+map $sent_http_content_type $content_security_policy_inline {
+  ~*text/(html|javascript)|application/pdf|xml "default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; upgrade-insecure-requests";
+}
+
+# --- Proxied third-party apps -------------------------------------------
+# These two are shipped REPORT-ONLY (see the *-report-only.conf includes).
+# Both are third-party SPAs whose runtime behaviour cannot be exercised from
+# the shell -- no browser here -- and a wrong directive fails SILENTLY: video
+# stops playing, or every article image disappears, with only a console
+# message. Report-Only gives the visibility with zero outage risk. Promote to
+# enforcing after checking a real browser console.
+
+# piped.krz.sh. Derived from evidence, not guesswork:
+#   - connect-src: BACKEND_HOSTNAME=pipedapi.krz.sh (container env).
+#   - img/media-src: the live API returns PROXY_PART=https://pipedproxy.krz.sh
+#     (57 refs in a real /streams response).
+#   - script-src needs 'unsafe-inline' AND data:  -- index.html carries 4 inline
+#     Vite shims, and the first one does `import 'data:text/javascript,...'`.
+#   - blob: for media/worker -- HLS/DASH playback builds blob URLs.
+# NOTE pipedproxy.kavin.rocks is baked into the JS bundle as a fallback and is
+# deliberately NOT allowed: failing closed to the self-hosted proxy is the
+# privacy-correct outcome.
+map $sent_http_content_type $csp_piped {
+  ~*text/(html|javascript)|application/pdf|xml "default-src 'self'; connect-src 'self' https://pipedapi.krz.sh; img-src 'self' https://pipedproxy.krz.sh data: blob:; media-src 'self' https://pipedproxy.krz.sh blob:; script-src 'self' 'unsafe-inline' data:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; font-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'";
+}
+
+# rss.zerolabs.sh (FreshRSS). Operator-only behind Cloudflare Access, so the
+# T5 stake is low; the risk is breakage. An RSS reader renders arbitrary feed
+# HTML, so img/media must allow remote hosts or every article image dies.
+# That remote fetching is inherent to the app and is controlled by FreshRSS's
+# own "load remote images" setting, not by CSP.
+map $sent_http_content_type $csp_freshrss {
+  ~*text/(html|javascript)|application/pdf|xml "default-src 'self'; img-src 'self' https: data:; media-src 'self' https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-src https:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; object-src 'none'";
+}
+
+# Permissions-Policy variant for media apps. The shared policy sets
+# `fullscreen=()`, which is exactly why piped.conf excludes basic.conf -- it
+# would stop videos going fullscreen. This keeps every other restriction and
+# permits fullscreen on the app's own origin.
+map $sent_http_content_type $permissions_policy_media {
+  ~*text/(html|javascript)|application/pdf|xml "accelerometer=(),autoplay=(self),browsing-topics=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(self),fullscreen=(self),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),midi=(),payment=(),picture-in-picture=(self),publickey-credentials-get=(),screen-wake-lock=(self),sync-xhr=(self),usb=(),web-share=(),xr-spatial-tracking=()";
 }
 
 # Add Permissions-Policy for HTML documents.
diff --git a/linux/nginx/etc/nginx/custom.d/security/content-security-policy-cmc.conf b/linux/nginx/etc/nginx/custom.d/security/content-security-policy-cmc.conf
new file mode 100644
index 0000000..ee76fd3
--- /dev/null
+++ b/linux/nginx/etc/nginx/custom.d/security/content-security-policy-cmc.conf
@@ -0,0 +1,8 @@
+# Content-Security-Policy for `cleberg.*` vhosts ONLY.
+#
+# Uses $content_security_policy_cmc, which names img.cleberg.net.
+# DO NOT include this file in a `krz`/`zerolabs` vhost -- it would put the
+# real-name domain into a response header on a pseudonymous surface.
+# Those vhosts want custom.d/security/content-security-policy.conf, whose
+# policy is identity-neutral.
+add_header Content-Security-Policy $content_security_policy_cmc always;
diff --git a/linux/nginx/etc/nginx/custom.d/security/content-security-policy-freshrss-report-only.conf b/linux/nginx/etc/nginx/custom.d/security/content-security-policy-freshrss-report-only.conf
new file mode 100644
index 0000000..bff2fd5
--- /dev/null
+++ b/linux/nginx/etc/nginx/custom.d/security/content-security-policy-freshrss-report-only.conf
@@ -0,0 +1,4 @@
+# rss.zerolabs.sh -- REPORT-ONLY. See the note in content_type_maps.conf:
+# the authenticated reading view cannot be exercised from the shell, and a
+# wrong img-src silently removes every article image.
+add_header Content-Security-Policy-Report-Only $csp_freshrss always;
diff --git a/linux/nginx/etc/nginx/custom.d/security/content-security-policy-inline.conf b/linux/nginx/etc/nginx/custom.d/security/content-security-policy-inline.conf
new file mode 100644
index 0000000..0ac43ab
--- /dev/null
+++ b/linux/nginx/etc/nginx/custom.d/security/content-security-policy-inline.conf
@@ -0,0 +1,9 @@
+# Content-Security-Policy for apps that ship inline <script>/<style>.
+#
+# Identity-neutral (names no host), so it is safe on any vhost -- but it allows
+# 'unsafe-inline' for scripts and styles, which is exactly what a strict CSP is
+# meant to prevent. Prefer custom.d/security/content-security-policy.conf
+# wherever the app does not need this.
+#
+# Used by: office.zerolabs.sh.
+add_header Content-Security-Policy $content_security_policy_inline always;
diff --git a/linux/nginx/etc/nginx/custom.d/security/content-security-policy-piped-report-only.conf b/linux/nginx/etc/nginx/custom.d/security/content-security-policy-piped-report-only.conf
new file mode 100644
index 0000000..5c76cd5
--- /dev/null
+++ b/linux/nginx/etc/nginx/custom.d/security/content-security-policy-piped-report-only.conf
@@ -0,0 +1,5 @@
+# piped.krz.sh -- REPORT-ONLY. Violations are reported to the browser console;
+# nothing is blocked. Promote to enforcing (rename the header to
+# Content-Security-Policy) only after loading a video, seeking, going
+# fullscreen and opening a channel page with a real browser console open.
+add_header Content-Security-Policy-Report-Only $csp_piped always;
diff --git a/linux/nginx/etc/nginx/custom.d/security/headers_in_location.conf b/linux/nginx/etc/nginx/custom.d/security/headers_in_location.conf
new file mode 100644
index 0000000..d0d4ecf
--- /dev/null
+++ b/linux/nginx/etc/nginx/custom.d/security/headers_in_location.conf
@@ -0,0 +1,27 @@
+# Re-includable copy of the server-level security headers.
+#
+# WHY THIS EXISTS -- nginx's add_header does NOT merge across levels.
+# A location block containing ANY add_header discards EVERY add_header
+# inherited from the server block. So a location that only wants to add
+# `Vary: Accept` silently loses the entire security header set.
+#
+# This bit cleberg.net hard: `location = /` (the homepage), `location /org/`
+# and `location ~ ^/(.+)\.html$` each set `add_header Vary`, and therefore
+# served NO Referrer-Policy, X-Content-Type-Options, X-Frame-Options, HSTS,
+# Permissions-Policy or CSP at all. Confirmed at the origin and end-to-end
+# over a real Tor circuit: the onion homepage returned zero security headers.
+#
+# It was invisible on the clearnet path because Cloudflare adds its own header
+# set at the edge, masking the gap -- but Tor visitors reach nginx directly,
+# so they got nothing. That is precisely the audience the onion exists for.
+#
+# Include this in ANY location that sets its own add_header.
+# Mirrors custom.d/basic.conf. CSP is NOT here -- it is per-vhost, so include
+# the right one alongside this file:
+#   cleberg.*        -> custom.d/security/content-security-policy-cmc.conf
+#   krz/zerolabs/etc -> custom.d/security/content-security-policy.conf
+add_header Referrer-Policy $referrer_policy always;
+add_header X-Content-Type-Options nosniff always;
+add_header X-Frame-Options $x_frame_options always;
+add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+add_header Permissions-Policy $permissions_policy always;
diff --git a/linux/nginx/etc/nginx/custom.d/security/headers_in_location_media.conf b/linux/nginx/etc/nginx/custom.d/security/headers_in_location_media.conf
new file mode 100644
index 0000000..5bba9d9
--- /dev/null
+++ b/linux/nginx/etc/nginx/custom.d/security/headers_in_location_media.conf
@@ -0,0 +1,11 @@
+# Security headers for media apps, for use INSIDE a location block.
+#
+# Same purpose as headers_in_location.conf, but uses
+# $permissions_policy_media, which permits fullscreen/autoplay/PiP on the
+# app's own origin. The standard policy sets fullscreen=(), which breaks
+# video players -- that is why piped.conf historically included no headers
+# at all rather than the wrong ones.
+add_header Referrer-Policy $referrer_policy always;
+add_header X-Content-Type-Options nosniff always;
+add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+add_header Permissions-Policy $permissions_policy_media always;
diff --git a/linux/nginx/etc/nginx/custom.d/security/headers_in_location_no_xfo.conf b/linux/nginx/etc/nginx/custom.d/security/headers_in_location_no_xfo.conf
new file mode 100644
index 0000000..6189aaf
--- /dev/null
+++ b/linux/nginx/etc/nginx/custom.d/security/headers_in_location_no_xfo.conf
@@ -0,0 +1,12 @@
+# Same as headers_in_location.conf but WITHOUT X-Frame-Options.
+#
+# For locations that set their own X-Frame-Options. Including the standard
+# bundle there would emit TWO conflicting X-Frame-Options headers (the shared
+# map's DENY plus the vhost's own SAMEORIGIN); browsers treat a conflicting
+# pair as invalid or apply the most restrictive, either way not what was meant.
+#
+# Used by: rss.zerolabs.sh, which needs SAMEORIGIN (FreshRSS frames itself).
+add_header Referrer-Policy $referrer_policy always;
+add_header X-Content-Type-Options nosniff always;
+add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+add_header Permissions-Policy $permissions_policy always;