Add security headers and caching for webmention avatars

- Introduced a new post detailing the implementation of security headers for an Astro site behind Caddy, focusing on Content Security Policy, HSTS, and Referrer Policy.
- Added a new post on caching webmention avatars locally at build time to enhance privacy and comply with GDPR.
- Implemented a helper function to download and deduplicate webmention avatars, storing them in a cache directory.
- Created an Astro integration for garbage collection of orphaned avatar files after the build process.
This commit is contained in:
Adrian Altner 2026-04-22 21:48:17 +02:00
parent 285ff01303
commit 9d22d93361
7 changed files with 919 additions and 4 deletions

View file

@ -4,6 +4,7 @@ import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import { defineConfig, fontProviders } from 'astro/config';
import { loadEnv } from 'vite';
import avatarCacheSweep from './src/integrations/avatar-cache-sweep';
const envMode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
const envVars = loadEnv(envMode, process.cwd(), '');
@ -40,6 +41,7 @@ export default defineConfig({
locales: { de: 'de-DE', en: 'en-US' },
},
}),
avatarCacheSweep(),
],
fonts: [

View file

@ -0,0 +1,183 @@
---
title: 'Security-Header für eine Astro-Seite hinter Caddy'
description: 'Wie ich meine Seite mit einer strikten Content Security Policy, sauberen Response-Headern und einer DSGVO-konformen Konfiguration gehärtet habe — und den Astro-Inline-Script-Stolperstein dabei gelöst.'
pubDate: 'Apr 22 2026'
heroImage: '../../../assets/blog-placeholder-1.jpg'
category: de/technik
tags:
- security
- caddy
- astro
- csp
- privacy
- dsgvo
translationKey: security-headers-astro-caddy
---
Nachdem die Seite produktiv lief, habe ich sie durch ein paar Scanner laufen lassen:
- **[Webbkoll](https://webbkoll.5july.net/de/check)** von der schwedischen Bürgerrechtsorganisation [Dataskydd.net](https://dataskydd.net/).
- **[securityheaders.com](https://securityheaders.com/)** für einen kompakten Grade-Report (A+ bis F).
- **[Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory)** für einen tiefergehenden CSP-Check.
Das Ergebnis war gemischt: null Cookies, null Drittanbieter-Requests, HTTPS als Default — aber **keine Content Security Policy**, **kein HSTS**, **keine Referrer-Policy**. Das Fundament ist gut, der Helm fehlte.
Dieser Beitrag ist chronologisch in Problem → Umsetzung → Lösung aufgebaut: pro Abschnitt beschreibe ich, was der Scan fand, wie ich es angegangen bin, und was am Ende rauskam.
## Warum gerade Webbkoll
Webbkoll ist kein generischer Security-Scanner. Die Seite erklärt zu jedem geprüften Punkt, **welcher Artikel der DSGVO** dahintersteht:
- **Art. 5.1.c (Datenminimierung)** — rechtfertigt die Forderung nach einer restriktiven Referrer-Policy und dem Verzicht auf Drittanbieter-Requests.
- **Art. 25 (Privacy by Design / by Default)** — der komplette Gedanke hinter "null Cookies, null Third-Party-Calls ohne Einwilligung".
- **Art. 32 (Sicherheit der Verarbeitung)** — deckt die klassischen Security-Header ab: HSTS, CSP, X-Content-Type-Options, X-Frame-Options.
Das macht Webbkoll zum Scanner meiner Wahl, wenn es nicht nur darum geht, "sicher" zu sein, sondern **belegen zu können, dass die Seite DSGVO-konform gebaut ist**. Ein Grade von securityheaders.com ist ein hübscher Marketing-Badge; ein grüner Webbkoll-Scan ist ein Indiz für einen sauberen Artikel-25-Nachweis.
## Warum das überhaupt etwas bringt
Eine statische Seite ohne Login, ohne Formulare, ohne User-Input wirkt auf den ersten Blick wie ein uninteressantes Angriffsziel. Die relevanten Schutzmaßnahmen lassen sich trotzdem klar benennen:
- **Content Security Policy** ist die Versicherung gegen den Tag, an dem doch mal fremder HTML-Code auf der Seite landet — durch eine Supply-Chain-Kompromittierung einer Dependency, eine Fehlkonfiguration, einen Tippfehler in einem Template. Ohne CSP führt der Browser beliebiges eingeschleustes JavaScript aus.
- **HSTS** schützt Leser vor Downgrade-Angriffen. Wer im offenen WLAN `adrian-altner.de` ohne Protokoll in die Adressleiste tippt, würde ohne HSTS zuerst über HTTP anfragen und wäre in dem Moment angreifbar.
- **Referrer-Policy** ist reiner Datenschutz für meine Leser. Ohne sie übermittelt ihr Browser beim Klick auf einen externen Link standardmäßig die URL, auf der sie gerade waren. Das kann hochsensibel sein.
## Das Setup
- **VPS** bei Hetzner, Debian.
- **Caddy** als Reverse-Proxy vor einem Container (nginx serviert statische Astro-Builds auf `127.0.0.1:4321`).
- **Astro 6** als statischer Site-Generator, deutsch/englisch lokalisiert.
## Security-Header im Caddyfile
**Problem:** Caddy liefert out-of-the-box nur die absolut nötigen Header aus — kein CSP, kein HSTS, keine Referrer-Policy. Der Browser arbeitet also mit seinen laxesten Defaults.
**Umsetzung:** Alle relevanten Direktiven kommen in einen `header`-Block in der Site-Config:
```caddy
adrian-altner.de, www.adrian-altner.de {
encode zstd gzip
@www host www.adrian-altner.de
redir @www https://adrian-altner.de{uri} permanent
header /fonts/* Cache-Control "public, max-age=31536000, immutable"
header /_astro/* Cache-Control "public, max-age=31536000, immutable"
header /images/* Cache-Control "public, max-age=604800"
header {
Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
Referrer-Policy "no-referrer"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "0"
Permissions-Policy "geolocation=(), microphone=(), camera=(), interest-cohort=()"
Cross-Origin-Opener-Policy "same-origin"
-Server
}
reverse_proxy 127.0.0.1:4321
}
```
Validieren und neu laden, ohne Caddy wirklich neu zu starten:
```bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
**Lösung:** Ein `curl -I` auf die Seite zeigt jetzt den vollständigen Header-Satz. Ein paar Begründungen zu den weniger offensichtlichen Zeilen:
- `default-src 'none'` ist strenger als `'self'`. Es zwingt dazu, jede Direktive explizit zu whitelisten — nichts fällt mehr stillschweigend auf einen laxeren Default zurück.
- `X-XSS-Protection: 0` sieht komisch aus, ist aber aktuelle OWASP-Empfehlung: der alte XSS-Auditor in älteren Browsern hat mehr Lücken eingeführt als geschlossen. Explizit `0` deaktiviert ihn.
- `X-Frame-Options: DENY` ist technisch redundant zu `frame-ancestors 'none'` in der CSP, aber für alte Browser ohne CSP-Level-2-Support ein zweiter Schutz.
- `-Server` entfernt den `Server`-Header und damit einen kleinen Fingerprinting-Vektor.
- `interest-cohort=()` in der Permissions-Policy lehnt Googles FLoC ausdrücklich ab, auch wenn das Feature praktisch tot ist.
## Die CSP und der Astro-Inline-Script-Stolperstein
**Problem:** Der erste Recheck zeigte einen frischen Fehler: `/kontakt/` funktionierte nicht mehr. Auf der Kontakt-Seite wird die E-Mail-Adresse obfuskiert (als base64 im `data-obf`-Attribut) und clientseitig decodiert. Das decode-Skript lag **inline** in der `.astro`-Datei:
```astro
<script>
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);
</script>
```
Astro hat es in der Produktion als `<script type="module">...</script>` direkt ins HTML gerendert. Meine strikte `script-src 'self'` blockiert das — zurecht. Drei Optionen standen zur Wahl:
1. **`'unsafe-inline'` freigeben** — macht die CSP praktisch wertlos, fiel aus.
2. **SHA-256-Hash des Skripts in die CSP hängen** — muss bei jedem Build geprüft und ggf. aktualisiert werden, fragil.
3. **Skript in eine eigene Datei auslagern und importieren** — Astro bündelt Module-Imports immer als externe JS-Datei, die `script-src 'self'` problemlos erlaubt.
**Umsetzung:** Option 3. Neue Datei `src/scripts/decode-obf.ts`:
```ts
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);
```
Und in der Kontakt-Seite:
```astro
<script>
import '~/scripts/decode-obf';
</script>
```
**Lösung:** Nach dem Build landet das Skript als gehashte `_astro/...js`-Datei im Output, referenziert per `<script type="module" src="...">` — CSP-konform und mit korrektem `Cache-Control`-Header.
### Was mit `style-src 'unsafe-inline'` ist
Das bleibt. Astro rendert pro Komponente scoped `<style>`-Tags inline, dazu die `@font-face`-Deklaration und View-Transition-Keyframes. Ohne SSR-Middleware, die pro Request einen Nonce injiziert, lässt sich das bei einer statischen Seite nicht sauber lösen. Der Tradeoff ist vertretbar: Style-Injection erlaubt keine Code-Ausführung — sie ist deutlich weniger gefährlich als Script-Injection. Die meisten Astro-Sites bleiben an genau diesem Punkt hängen.
## Der Referrer-Header
**Problem:** Meine erste Version setzte `Referrer-Policy: strict-origin-when-cross-origin` — ein sinnvoller Default, der nur die Origin (nicht die vollständige URL) weitergibt. Webbkoll bewertet das trotzdem als gelben Hinweis: bei HTTPS-zu-HTTPS-Requests wird die Origin übermittelt.
**Umsetzung:** Da die Seite ohnehin keine Analytics hat und niemand den Referrer braucht, habe ich auf `no-referrer` umgestellt:
```caddy
Referrer-Policy "no-referrer"
```
**Lösung:** Browser schicken bei Klicks auf externe Links (Mastodon, GitHub, Webmention-Profile) gar keinen `Referer`-Header mehr. Strengstmögliche Einstellung, keine Nachteile für eine Content-Site — und ein sauberer Art.-5.1.c-Nachweis (Datenminimierung).
## Das Ergebnis
Der finale Scan bei [Webbkoll](https://webbkoll.5july.net/de/check):
- **HTTPS als Voreinstellung**: ✓
- **Content Security Policy**: ✓ "Gute Richtlinie"
- **Referrer Policy**: ✓ "Referrer werden nicht übermittelt"
- **Cookies**: 0
- **Drittanbieter-Requests**: 0
Bei [securityheaders.com](https://securityheaders.com/) gibt's ein **A+**. Der einzige rot markierte CSP-Check ist `style-src 'unsafe-inline'` — der Astro-Trade-off, den ich oben beschrieben habe.
## Was noch offen war
Ein Scanner-Befund blieb ungelöst: externe Avatar-Bilder aus Webmentions wurden von `img-src 'self' data:` blockiert. Der Fix hat ein eigenes kleines Unterkapitel verdient, weil er nicht nur ein CSP-Problem löst, sondern die Leser auch vor Drittanbieter-Requests schützt — [Webmention-Avatare zur Build-Zeit lokal cachen](/webmention-avatare-lokal-cachen/).
## Was du dir mitnimmst
- **Security-Header in Caddy sind vier Zeilen.** Es gibt keinen guten Grund, sie nicht zu setzen.
- **`default-src 'none'` statt `'self'`** zwingt dich, jede Direktive bewusst zu whitelisten. Du lernst dabei, was deine Seite eigentlich lädt.
- **Inline-Scripts vermeiden.** Sobald CSP strikt wird, musst du sie sowieso auslagern — Astro bündelt Module-Imports automatisch als externe Datei.
- **`no-referrer` ist für Content-Sites eine vernünftige Default-Einstellung.** Wer keine Analytics oder Conversion-Tracking betreibt, verliert dadurch nichts — und minimiert Daten im Sinne der DSGVO.
- **Scanner sind kein Luxus, sondern Nachweis-Werkzeuge.** [Webbkoll](https://webbkoll.5july.net/de/check) liefert dir pro Befund gleich den passenden DSGVO-Artikel mit; [securityheaders.com](https://securityheaders.com/) ist der schnelle Sanity-Check; [Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory) geht bei der CSP am tiefsten.

View file

@ -0,0 +1,253 @@
---
title: 'Webmention-Avatare zur Build-Zeit lokal cachen'
description: 'Ein kleiner Astro-Helper, der Autor-Fotos von Webmentions beim Build runterlädt, dedupliziert und lokal ausliefert — für eine strikte CSP, mehr Privatsphäre und bessere Verfügbarkeit.'
pubDate: 'Apr 22 2026'
heroImage: '../../../assets/blog-placeholder-5.jpg'
category: de/technik
tags:
- astro
- webmentions
- privacy
- csp
- indieweb
- dsgvo
translationKey: local-webmention-avatars
---
Dieser Beitrag knüpft an die [Security-Header-Umstellung meiner Seite](/security-header-astro-caddy/) an. Nachdem eine strikte Content Security Policy mit `img-src 'self' data:` live war, zeigte der nächste Scan ein neues Problem: die Mention-Fotos auf den Post-Seiten waren weg.
Der naheliegende Fix — `img-src` auf `'self' data: https:` öffnen — löst das Symptom, aber nicht das eigentliche Problem. Dieser Beitrag zeigt den Weg, der beides gleichzeitig erledigt: CSP bleibt strikt, und die Leser schicken beim Aufruf keine einzige Anfrage mehr an den externen Avatar-Host.
## Problem
Meine `Webmentions.astro`-Komponente rendert die Facepile und Replies mit externen Avatar-URLs direkt aus der webmention.io-API:
```astro
<img src="https://avatars.webmention.io/files.mastodon.social/0ec46...jpg" alt="" loading="lazy" />
```
Drei Probleme auf einmal:
1. **CSP-Konflikt.** `img-src 'self' data:` blockt externe Bilder. Die Alternative `img-src 'self' data: https:` macht die Direktive praktisch wertlos.
2. **Datenschutz für meine Leser.** Bei jedem Aufruf einer Seite mit Mentions machen ihre Browser Anfragen an `avatars.webmention.io` — ein Tracking-Vektor, den meine Leser nie bewusst gewählt haben. Der Avatar-Host sieht IP-Adresse, User-Agent, Referer und den Pfad jedes Posts mit Mentions. Unter Art. 5.1.c DSGVO (Datenminimierung) ist das unnötige Weitergabe personenbezogener Daten.
3. **Verfügbarkeit.** Ist webmention.io down oder langsam, fehlen die Avatare oder verzögern das Rendering.
Alle drei Probleme lösen sich, wenn die Avatare nicht mehr vom Browser geladen werden, sondern vom eigenen Server aus.
## Idee
Die Webmentions werden sowieso **zur Build-Zeit** geholt, nicht zur Lesezeit. Heißt: Jede Avatar-URL ist bereits beim Rendern des Posts bekannt. Also: die Bilder gleich mit runterladen, lokal speichern, und im HTML auf den lokalen Pfad umschreiben.
Die Umsetzung braucht vier Bausteine:
- Einen Cache-Ordner unter `public/`, der zwischen Builds überlebt.
- Einen Mirror nach `dist/`, weil Astro `public/` **vor** dem Rendering nach `dist/` kopiert — spätere Downloads in `public/` würden sonst im aktuellen Build-Output fehlen.
- Eine Deduplizierung, damit derselbe Avatar bei mehreren Mentions (ein Autor liked und repostet den gleichen Post) nicht mehrfach runtergeladen wird.
- Eine Garbage-Collection am Build-Ende, die verwaiste Avatare aus dem Cache entfernt — sonst bleiben Dateien ewig liegen, wenn ein Nutzer seinen Like oder Kommentar später löscht.
## Umsetzung
Die Hilfsdatei `src/lib/avatar-cache.ts`:
```ts
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { copyFile, mkdir, readdir, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
const PUBLIC_DIR = path.resolve(process.cwd(), 'public', 'images', 'webmention');
const DIST_DIR = path.resolve(process.cwd(), 'dist', 'images', 'webmention');
const URL_PATH = '/images/webmention';
const EXT_BY_MIME: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/avif': 'avif',
'image/svg+xml': 'svg',
};
const memo = new Map<string, Promise<string | null>>();
const usedFilenames = new Set<string>();
async function mirrorToDist(filename: string, srcPath: string) {
try {
await mkdir(DIST_DIR, { recursive: true });
const dest = path.join(DIST_DIR, filename);
if (!existsSync(dest)) await copyFile(srcPath, dest);
} catch {
// dist/ gibt's in `astro dev` nicht — ignorieren
}
}
async function download(url: string): Promise<string | null> {
const hash = createHash('sha1').update(url).digest('hex').slice(0, 16);
await mkdir(PUBLIC_DIR, { recursive: true });
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const mime = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
const ext = EXT_BY_MIME[mime] ?? 'jpg';
const filename = `${hash}.${ext}`;
const filepath = path.join(PUBLIC_DIR, filename);
if (!existsSync(filepath)) {
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(filepath, buf);
}
usedFilenames.add(filename);
await mirrorToDist(filename, filepath);
return `${URL_PATH}/${filename}`;
} catch {
return null;
}
}
export function cacheAvatar(url: string | undefined): Promise<string | null> {
if (!url) return Promise.resolve(null);
if (!/^https?:\/\//i.test(url)) return Promise.resolve(url);
let pending = memo.get(url);
if (!pending) {
pending = download(url);
memo.set(url, pending);
}
return pending;
}
async function sweepDir(dir: string): Promise<number> {
if (!existsSync(dir)) return 0;
const entries = await readdir(dir);
let removed = 0;
await Promise.all(
entries.map(async (name) => {
if (!usedFilenames.has(name)) {
await unlink(path.join(dir, name));
removed++;
}
}),
);
return removed;
}
export async function sweepCache(): Promise<{ removed: number }> {
const publicRemoved = await sweepDir(PUBLIC_DIR);
await sweepDir(DIST_DIR);
return { removed: publicRemoved };
}
```
Vier Details, die leicht untergehen:
- **SHA1 der URL als Dateiname** dedupliziert deterministisch. Der gleiche Avatar bekommt immer denselben Dateinamen, egal in wie vielen Builds er auftaucht.
- **MIME-Type aus dem Response-Header** bestimmt die Extension. Sich auf die URL-Endung zu verlassen ist fragil — webmention.io serviert einige Avatare hinter URLs ohne Dateiendung.
- **`memo`-Map** verhindert Doppel-Downloads in einem Build. `Webmentions.astro` ruft `cacheAvatar` über `Promise.all` auf; ohne Memoization würde derselbe Autor, der eine Seite liked und repostet, zweimal parallel runtergeladen.
- **`usedFilenames`-Set plus `sweepCache`** macht aus dem Cache einen Mark-and-Sweep-Cache. Während des Builds markiert jeder Treffer den Dateinamen als "benutzt"; am Build-Ende löscht der Sweep alles, was nicht markiert wurde. Ohne das würden Avatare von gelöschten Mentions (Like zurückgenommen, Kommentar entfernt) für immer im Ordner liegen bleiben.
In `Webmentions.astro` läuft der Cache-Schritt direkt nach dem `fetchMentions`:
```astro
---
import { cacheAvatar } from '~/lib/avatar-cache';
// ...
const all = await fetchMentions(targetStr);
await Promise.all(
all.map(async (m) => {
if (m.author?.photo) {
const local = await cacheAvatar(m.author.photo);
if (local) m.author.photo = local;
else delete m.author.photo;
}
}),
);
---
```
Wenn der Download scheitert (Timeout, 404, was auch immer), wird `author.photo` entfernt — die Komponente fällt dann automatisch auf den Avatar-Fallback mit den Initialen des Autors zurück.
### Garbage Collection als Astro-Integration
`sweepCache` muss erst laufen, wenn alle Seiten gerendert sind — sonst löscht er Avatare, die eine später gerenderte Seite noch gebraucht hätte. Astros `astro:build:done`-Hook ist genau der richtige Zeitpunkt.
Die Integration `src/integrations/avatar-cache-sweep.ts`:
```ts
import type { AstroIntegration } from 'astro';
import { sweepCache } from '../lib/avatar-cache';
export default function avatarCacheSweep(): AstroIntegration {
return {
name: 'avatar-cache-sweep',
hooks: {
'astro:build:done': async ({ logger }) => {
const { removed } = await sweepCache();
if (removed > 0) {
logger.info(`swept ${removed} orphaned webmention avatar${removed === 1 ? '' : 's'}`);
}
},
},
};
}
```
Registriert in `astro.config.mjs`:
```js
import avatarCacheSweep from './src/integrations/avatar-cache-sweep';
export default defineConfig({
integrations: [
mdx(),
sitemap({ /* ... */ }),
avatarCacheSweep(),
],
});
```
Ein wichtiger Nebeneffekt: Im `astro dev`-Modus läuft `astro:build:done` nicht. Der Sweep greift also nur bei `astro build` — genau dort, wo es drauf ankommt. Während der Entwicklung bleibt der Cache unangetastet, und es kann nicht passieren, dass ein Dev-Server versehentlich Avatare wegräumt, die beim nächsten Prod-Build wieder gebraucht werden.
### Warum der Pfad `/images/webmention/`
Mein erster Versuch war `/webmention-avatars/` unter dem Site-Root. Das funktioniert, passt aber nicht zu den Caddy-Regeln, die ich schon für Caching habe:
```caddy
header /images/* Cache-Control "public, max-age=604800"
```
Unter `/images/webmention/...` greift diese Regel automatisch — eine Woche Browser-Cache für Avatare, ohne extra Config.
### .gitignore
Der Cache-Ordner gehört nicht ins Repo — er wird bei jedem Build neu befüllt, wenn er leer ist, und bleibt sonst aus einem vorigen Lauf bestehen:
```
public/images/webmention/
```
In CI-Umgebungen ohne persistentes Caching werden die Avatare bei jedem Build einmal neu geholt. Da es nur ein paar kleine JPEGs sind, ist das unkritisch.
## Lösung
Im gebauten HTML steht jetzt:
```html
<img src="/images/webmention/556ae159c81f2fc1.jpg" alt="" loading="lazy" />
```
Vier Dinge auf einmal gelöst:
1. **CSP kann strikt bleiben.** `img-src 'self' data:` reicht aus — kein Freibrief für beliebige HTTPS-Quellen.
2. **Null Drittanbieter-Requests beim Aufruf.** Leser laden die Avatare vom eigenen Server der Seite, der Mention-Host sieht sie nie. Art. 5.1.c DSGVO erfüllt.
3. **Verfügbarkeit entkoppelt.** Das Rendering der Avatare hängt nur noch davon ab, ob der Build erfolgreich war, nicht davon, ob webmention.io gerade erreichbar ist.
4. **Selbstaufräumender Cache.** Zurückgezogene Likes und gelöschte Kommentare nehmen ihren Avatar beim nächsten Build mit. Der Ordner bleibt schlank und es liegen keine Datei-Leichen von Leuten rum, die nicht mehr verlinkt sind — auch das ist gelebte Datenminimierung.
Der Caddy-`/images/*`-Cache-Header greift out of the box. Bei jedem Build, der etwas Neues bringt, liegen die Avatare in `dist/images/webmention/` — unverändert für bereits gesehene URLs, frisch geladen für neue.
## Was du dir mitnimmst
- **Externe Ressourcen, die zur Build-Zeit bekannt sind, gehören gecached.** Das ist eine alte Empfehlung für Google Fonts — dasselbe Argument gilt für Webmention-Avatare, externe Icons, Mastodon-Badges und alles, was an einer statischen URL hängt.
- **CSP-Compliance und Datenschutz zeigen auf dasselbe Fix-Pattern.** Wer die Avatare nicht mehr extern lädt, muss die CSP nicht aufweichen **und** verrät die IP seiner Leser nicht mehr.
- **Deduplizierung per URL-Hash ist trivial und robust.** Keine Datenbank, keine externe Config — ein Cache-Ordner plus SHA1 reicht.
- **Verfügbarkeit ist ein oft übersehener Privacy-Bonus.** Selbstgehostete Ressourcen fallen nicht aus, wenn der externe Anbieter seine Preise erhöht, die API ändert oder komplett abschaltet.
- **Ein Cache ohne Garbage Collection ist ein Datenfriedhof.** Mark-and-Sweep ist fünf Zeilen Code und verhindert, dass sich Altlasten ansammeln — egal ob aus Plattenplatz- oder aus Datenschutzsicht.

View file

@ -0,0 +1,253 @@
---
title: 'Caching webmention avatars locally at build time'
description: 'A small Astro helper that downloads webmention author photos during the build, dedupes them, and serves them locally — for a strict CSP, stronger privacy, and better availability.'
pubDate: 'Apr 22 2026'
heroImage: '../../../assets/blog-placeholder-5.jpg'
category: en/tech
tags:
- astro
- webmentions
- privacy
- csp
- indieweb
- gdpr
translationKey: local-webmention-avatars
---
This post follows up on my [security-header rollout](/en/security-headers-astro-caddy/). Once a strict Content Security Policy with `img-src 'self' data:` was live, the next scan surfaced a new issue: mention photos on the post pages were gone.
The obvious fix — opening `img-src` to `'self' data: https:` — treats the symptom, not the actual problem. This post describes the approach that solves both at once: the CSP stays strict, and readers never make a single request to the external avatar host.
## Problem
My `Webmentions.astro` component renders the facepile and replies with external avatar URLs pulled straight from the webmention.io API:
```astro
<img src="https://avatars.webmention.io/files.mastodon.social/0ec46...jpg" alt="" loading="lazy" />
```
Three problems in one:
1. **CSP conflict.** `img-src 'self' data:` blocks external images. The alternative `img-src 'self' data: https:` would water the directive down to near-uselessness.
2. **Reader privacy.** Every visit to a page with mentions causes the reader's browser to hit `avatars.webmention.io` — a tracking vector they never opted into. The avatar host sees IP address, user agent, referer, and the path of every post with mentions. Under GDPR Art. 5.1.c (data minimisation) that's unnecessary disclosure of personal data.
3. **Availability.** If webmention.io is down or slow, avatars are missing or delay rendering.
All three disappear if the browser never loads the avatars from the third party in the first place.
## The idea
Webmentions are already fetched **at build time**, not at read time. Which means every avatar URL is known while the post is being rendered. So: download the images in the same pass, store them locally, and rewrite the HTML to point at the local path.
The implementation needs four pieces:
- A cache directory under `public/` that survives across builds.
- A mirror into `dist/`, because Astro copies `public/` to `dist/` **before** rendering — files added to `public/` later would be missing from the current build's output.
- Deduplication so the same avatar doesn't get downloaded twice when one author has multiple mentions on the same page (liked and reposted, for example).
- A garbage-collection pass at build end that removes orphaned avatars — otherwise files pile up forever once a user deletes their like or comment.
## Implementation
The helper file `src/lib/avatar-cache.ts`:
```ts
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { copyFile, mkdir, readdir, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
const PUBLIC_DIR = path.resolve(process.cwd(), 'public', 'images', 'webmention');
const DIST_DIR = path.resolve(process.cwd(), 'dist', 'images', 'webmention');
const URL_PATH = '/images/webmention';
const EXT_BY_MIME: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/avif': 'avif',
'image/svg+xml': 'svg',
};
const memo = new Map<string, Promise<string | null>>();
const usedFilenames = new Set<string>();
async function mirrorToDist(filename: string, srcPath: string) {
try {
await mkdir(DIST_DIR, { recursive: true });
const dest = path.join(DIST_DIR, filename);
if (!existsSync(dest)) await copyFile(srcPath, dest);
} catch {
// dist/ doesn't exist during `astro dev` — ignore
}
}
async function download(url: string): Promise<string | null> {
const hash = createHash('sha1').update(url).digest('hex').slice(0, 16);
await mkdir(PUBLIC_DIR, { recursive: true });
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const mime = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
const ext = EXT_BY_MIME[mime] ?? 'jpg';
const filename = `${hash}.${ext}`;
const filepath = path.join(PUBLIC_DIR, filename);
if (!existsSync(filepath)) {
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(filepath, buf);
}
usedFilenames.add(filename);
await mirrorToDist(filename, filepath);
return `${URL_PATH}/${filename}`;
} catch {
return null;
}
}
export function cacheAvatar(url: string | undefined): Promise<string | null> {
if (!url) return Promise.resolve(null);
if (!/^https?:\/\//i.test(url)) return Promise.resolve(url);
let pending = memo.get(url);
if (!pending) {
pending = download(url);
memo.set(url, pending);
}
return pending;
}
async function sweepDir(dir: string): Promise<number> {
if (!existsSync(dir)) return 0;
const entries = await readdir(dir);
let removed = 0;
await Promise.all(
entries.map(async (name) => {
if (!usedFilenames.has(name)) {
await unlink(path.join(dir, name));
removed++;
}
}),
);
return removed;
}
export async function sweepCache(): Promise<{ removed: number }> {
const publicRemoved = await sweepDir(PUBLIC_DIR);
await sweepDir(DIST_DIR);
return { removed: publicRemoved };
}
```
Four details that are easy to miss:
- **SHA1 of the URL as filename** dedupes deterministically. The same avatar always gets the same filename, regardless of how many builds it appears in.
- **MIME type from the response header** drives the extension. Relying on the URL suffix is fragile — webmention.io serves some avatars behind URLs without an extension.
- **`memo` map** prevents duplicate downloads within a build. `Webmentions.astro` calls `cacheAvatar` via `Promise.all`; without memoisation the same author who liked and reposted the same page would be fetched twice in parallel.
- **`usedFilenames` set plus `sweepCache`** turns this into a mark-and-sweep cache. Every cache hit during the build marks a filename as "used"; at build end the sweep deletes anything unmarked. Without it, avatars from deleted mentions (a like taken back, a comment removed) would linger in the folder forever.
In `Webmentions.astro` the cache step runs right after `fetchMentions`:
```astro
---
import { cacheAvatar } from '~/lib/avatar-cache';
// ...
const all = await fetchMentions(targetStr);
await Promise.all(
all.map(async (m) => {
if (m.author?.photo) {
const local = await cacheAvatar(m.author.photo);
if (local) m.author.photo = local;
else delete m.author.photo;
}
}),
);
---
```
If the download fails (timeout, 404, whatever), `author.photo` is removed — the component then falls back to the initial-based avatar automatically.
### Garbage collection as an Astro integration
`sweepCache` can only run once every page has been rendered — any earlier and it would delete avatars a later page still needs. Astro's `astro:build:done` hook is exactly the right moment.
The integration `src/integrations/avatar-cache-sweep.ts`:
```ts
import type { AstroIntegration } from 'astro';
import { sweepCache } from '../lib/avatar-cache';
export default function avatarCacheSweep(): AstroIntegration {
return {
name: 'avatar-cache-sweep',
hooks: {
'astro:build:done': async ({ logger }) => {
const { removed } = await sweepCache();
if (removed > 0) {
logger.info(`swept ${removed} orphaned webmention avatar${removed === 1 ? '' : 's'}`);
}
},
},
};
}
```
Registered in `astro.config.mjs`:
```js
import avatarCacheSweep from './src/integrations/avatar-cache-sweep';
export default defineConfig({
integrations: [
mdx(),
sitemap({ /* ... */ }),
avatarCacheSweep(),
],
});
```
A useful side effect: `astro:build:done` never fires in `astro dev`. The sweep only runs on `astro build` — exactly where it matters. During development the cache stays untouched, so a dev server can't accidentally evict an avatar that the next production build still needs.
### Why the `/images/webmention/` path
My first attempt used `/webmention-avatars/` at the site root. That works, but it doesn't match the Caddy rule I already had for caching:
```caddy
header /images/* Cache-Control "public, max-age=604800"
```
Under `/images/webmention/...` this rule applies automatically — a week of browser caching for avatars, no extra config needed.
### .gitignore
The cache folder doesn't belong in the repo — it gets populated during each build if empty, and otherwise persists from a previous run:
```
public/images/webmention/
```
On CI without persistent caching the avatars are re-fetched every build. Since they're tiny JPEGs, that's not a problem.
## Solution
The built HTML now contains:
```html
<img src="/images/webmention/556ae159c81f2fc1.jpg" alt="" loading="lazy" />
```
Four wins at once:
1. **CSP stays strict.** `img-src 'self' data:` is enough — no free pass for arbitrary HTTPS sources.
2. **Zero third-party requests on page load.** Readers fetch the avatars from the site's own server; the mention host never sees them. GDPR Art. 5.1.c satisfied.
3. **Availability decoupled.** Rendering the avatars only depends on the build having succeeded, not on webmention.io being reachable at view time.
4. **Self-cleaning cache.** Retracted likes and deleted comments take their avatar with them on the next build. The folder stays lean, no file carcasses from people no longer linked — data minimisation in practice, not just on paper.
The Caddy `/images/*` caching rule applies out of the box. On every build that produces something new, the avatars sit in `dist/images/webmention/` — unchanged for previously seen URLs, freshly fetched for new ones.
## What to take away
- **External resources known at build time belong in the cache.** It's an old recommendation for Google Fonts — the same argument applies to webmention avatars, external icons, Mastodon badges, and anything else tied to a stable URL.
- **CSP compliance and privacy point at the same fix.** Stop loading the avatars externally and the CSP doesn't have to be relaxed **and** readers' IPs don't leak.
- **URL-hash dedup is trivial and robust.** No database, no external config — a cache folder plus SHA1 is all you need.
- **Availability is an underrated privacy bonus.** Self-hosted resources don't disappear when the external provider raises prices, changes the API, or shuts down.
- **A cache without garbage collection is a data graveyard.** Mark-and-sweep is five lines of code and stops the pile-up — whether you care about disk space or data minimisation.

View file

@ -0,0 +1,183 @@
---
title: 'Security headers for an Astro site behind Caddy'
description: 'How I hardened my site with a strict Content Security Policy, clean response headers, and a GDPR-compliant configuration — and solved the Astro inline-script gotcha along the way.'
pubDate: 'Apr 22 2026'
heroImage: '../../../assets/blog-placeholder-1.jpg'
category: en/tech
tags:
- security
- caddy
- astro
- csp
- privacy
- gdpr
translationKey: security-headers-astro-caddy
---
Once the site was live, I ran it through a few scanners:
- **[Webbkoll](https://webbkoll.5july.net/en/check)** by the Swedish digital rights organisation [Dataskydd.net](https://dataskydd.net/).
- **[securityheaders.com](https://securityheaders.com/)** for a compact grade report (A+ to F).
- **[Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory)** for a deeper CSP check.
The results were mixed: zero cookies, zero third-party requests, HTTPS by default — but **no Content Security Policy**, **no HSTS**, **no Referrer-Policy**. The foundation was good; the helmet was missing.
This post is structured chronologically as Problem → Implementation → Solution: each section describes what the scan found, how I approached it, and what came out the other end.
## Why Webbkoll specifically
Webbkoll isn't a generic security scanner. For every check, it explains **which article of the GDPR** backs it up:
- **Art. 5.1.c (data minimisation)** — the basis for demanding a restrictive Referrer-Policy and no third-party requests.
- **Art. 25 (Privacy by Design and by Default)** — the entire rationale behind "zero cookies, zero third-party calls without consent".
- **Art. 32 (security of processing)** — covers the classic security headers: HSTS, CSP, X-Content-Type-Options, X-Frame-Options.
That makes Webbkoll my scanner of choice when the goal isn't just "secure" but **being able to demonstrate that the site was built GDPR-compliant**. A securityheaders.com grade is a pretty marketing badge; a green Webbkoll scan is evidence for a clean Art. 25 paper trail.
## Why any of this matters
A static site with no login, no forms, no user input looks like an uninteresting target at first glance. The relevant protections are still easy to name:
- **Content Security Policy** is insurance against the day foreign HTML ends up on the site — through a supply-chain compromise in a dependency, a misconfiguration, a typo in a template. Without CSP the browser executes any injected JavaScript. With a strict CSP it blocks it.
- **HSTS** protects readers from downgrade attacks. Someone on open Wi-Fi typing `adrian-altner.de` without a protocol into the address bar would otherwise hit HTTP first and be exposed in that moment.
- **Referrer-Policy** is pure privacy hygiene for readers. Without it, their browser transmits the URL they were on when they click an external link. That can be highly sensitive.
## The setup
- **VPS** at Hetzner, Debian.
- **Caddy** as a reverse proxy in front of a container (nginx serving the static Astro build on `127.0.0.1:4321`).
- **Astro 6** as the static site generator, localised in German and English.
## Security headers in the Caddyfile
**Problem:** Caddy ships only the bare-minimum headers out of the box — no CSP, no HSTS, no Referrer-Policy. The browser runs with its most permissive defaults.
**Implementation:** All relevant directives go into a `header` block in the site config:
```caddy
adrian-altner.de, www.adrian-altner.de {
encode zstd gzip
@www host www.adrian-altner.de
redir @www https://adrian-altner.de{uri} permanent
header /fonts/* Cache-Control "public, max-age=31536000, immutable"
header /_astro/* Cache-Control "public, max-age=31536000, immutable"
header /images/* Cache-Control "public, max-age=604800"
header {
Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
Referrer-Policy "no-referrer"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "0"
Permissions-Policy "geolocation=(), microphone=(), camera=(), interest-cohort=()"
Cross-Origin-Opener-Policy "same-origin"
-Server
}
reverse_proxy 127.0.0.1:4321
}
```
Validate and reload without a full restart:
```bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
**Solution:** A `curl -I` against the site now shows the complete set of headers. Some notes on the less obvious lines:
- `default-src 'none'` is stricter than `'self'`. It forces you to whitelist every directive explicitly — nothing falls back silently to a looser default.
- `X-XSS-Protection: 0` looks odd but is current OWASP guidance: the legacy XSS auditor in older browsers introduced more holes than it patched. Explicitly setting `0` disables it.
- `X-Frame-Options: DENY` is technically redundant next to CSP `frame-ancestors 'none'`, but provides a second layer for older browsers without CSP Level 2 support.
- `-Server` removes the `Server` header and with it a small fingerprinting vector.
- `interest-cohort=()` in the Permissions-Policy explicitly opts out of Google's FLoC, even though the feature is effectively dead.
## The CSP and Astro's inline-script gotcha
**Problem:** The first re-check revealed a fresh break: `/contact/` no longer worked. On the contact page the email address is obfuscated (base64-encoded in a `data-obf` attribute) and decoded client-side. The decode snippet lived **inline** in the `.astro` file:
```astro
<script>
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);
</script>
```
In production Astro inlined it as `<script type="module">...</script>`. My strict `script-src 'self'` blocked it — correctly. Three options:
1. **Allow `'unsafe-inline'`** — makes the CSP practically worthless, out.
2. **Add a SHA-256 hash of the script to the CSP** — has to be verified after every build, fragile.
3. **Move the script into its own file and import it** — Astro always bundles module imports as external JS files, which `script-src 'self'` allows without issue.
**Implementation:** Option 3. New file `src/scripts/decode-obf.ts`:
```ts
function decode() {
document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
el.textContent = atob(el.dataset.obf!);
el.removeAttribute('data-obf');
});
}
decode();
document.addEventListener('astro:page-load', decode);
```
And in the contact page:
```astro
<script>
import '~/scripts/decode-obf';
</script>
```
**Solution:** After the build the script lands in the output as a hashed `_astro/...js` file, referenced via `<script type="module" src="...">` — CSP-compliant and with the correct `Cache-Control` header.
### About `style-src 'unsafe-inline'`
That one stays. Astro renders scoped `<style>` tags per component, the `@font-face` declaration, and view-transition keyframes all inline. Without SSR middleware injecting a per-request nonce, there's no clean way around it on a static site. The tradeoff is acceptable: style injection doesn't allow code execution — it's meaningfully less dangerous than script injection. Most Astro sites hit this exact wall.
## The referrer header
**Problem:** My first iteration set `Referrer-Policy: strict-origin-when-cross-origin` — a reasonable default that only sends the origin (not the full URL). Webbkoll still flags it as a yellow warning: on HTTPS-to-HTTPS requests the origin is transmitted.
**Implementation:** Since the site has no analytics and nobody needs the referrer, I switched to `no-referrer`:
```caddy
Referrer-Policy "no-referrer"
```
**Solution:** Browsers won't send a `Referer` header at all when clicking external links (Mastodon, GitHub, webmention profiles). Strictest possible setting, no downside for a content site — and a clean Art. 5.1.c trail (data minimisation).
## The outcome
The final [Webbkoll](https://webbkoll.5july.net/en/check) scan:
- **HTTPS by default**: ✓
- **Content Security Policy**: ✓ "Good policy"
- **Referrer Policy**: ✓ "Referrers are not transmitted"
- **Cookies**: 0
- **Third-party requests**: 0
On [securityheaders.com](https://securityheaders.com/) the grade is an **A+**. The only red CSP check is `style-src 'unsafe-inline'` — the Astro tradeoff discussed above.
## What was still open
One finding stayed unresolved: external avatar images from webmentions were blocked by `img-src 'self' data:`. The fix deserves its own short post because it doesn't just solve a CSP problem but also protects readers from third-party requests — [caching webmention avatars locally at build time](/en/local-webmention-avatars/).
## What to take away
- **Security headers in Caddy are four lines.** There's no good reason not to set them.
- **`default-src 'none'` instead of `'self'`** forces you to whitelist every directive deliberately. You learn what your site actually loads in the process.
- **Avoid inline scripts.** Once CSP gets strict, you need to extract them anyway — Astro bundles module imports as external files automatically.
- **`no-referrer` is a sensible default for content sites.** If you don't run analytics or conversion tracking, you lose nothing — and you minimise data in line with GDPR.
- **Scanners aren't a luxury, they're evidence.** [Webbkoll](https://webbkoll.5july.net/en/check) gives you the matching GDPR article for every finding; [securityheaders.com](https://securityheaders.com/) is the quick sanity check; [Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory) is the deepest CSP analysis.

View file

@ -0,0 +1,16 @@
import type { AstroIntegration } from 'astro';
import { sweepCache } from '../lib/avatar-cache';
export default function avatarCacheSweep(): AstroIntegration {
return {
name: 'avatar-cache-sweep',
hooks: {
'astro:build:done': async ({ logger }) => {
const { removed } = await sweepCache();
if (removed > 0) {
logger.info(`swept ${removed} orphaned webmention avatar${removed === 1 ? '' : 's'}`);
}
},
},
};
}

View file

@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import { copyFile, mkdir, readdir, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
const PUBLIC_DIR = path.resolve(process.cwd(), 'public', 'images', 'webmention');
@ -18,6 +18,7 @@ const EXT_BY_MIME: Record<string, string> = {
};
const memo = new Map<string, Promise<string | null>>();
const usedFilenames = new Set<string>();
function extFromUrl(url: string): string | null {
const m = url.match(/\.(jpe?g|png|webp|gif|avif|svg)(?:\?|#|$)/i);
@ -41,10 +42,12 @@ async function download(url: string): Promise<string | null> {
await mkdir(PUBLIC_DIR, { recursive: true });
if (urlExt) {
const cached = path.join(PUBLIC_DIR, `${hash}.${urlExt}`);
const filename = `${hash}.${urlExt}`;
const cached = path.join(PUBLIC_DIR, filename);
if (existsSync(cached)) {
await mirrorToDist(`${hash}.${urlExt}`, cached);
return `${URL_PATH}/${hash}.${urlExt}`;
usedFilenames.add(filename);
await mirrorToDist(filename, cached);
return `${URL_PATH}/${filename}`;
}
}
@ -59,6 +62,7 @@ async function download(url: string): Promise<string | null> {
const buf = Buffer.from(await res.arrayBuffer());
await writeFile(filepath, buf);
}
usedFilenames.add(filename);
await mirrorToDist(filename, filepath);
return `${URL_PATH}/${filename}`;
} catch {
@ -76,3 +80,24 @@ export function cacheAvatar(url: string | undefined): Promise<string | null> {
}
return pending;
}
async function sweepDir(dir: string): Promise<number> {
if (!existsSync(dir)) return 0;
const entries = await readdir(dir);
let removed = 0;
await Promise.all(
entries.map(async (name) => {
if (!usedFilenames.has(name)) {
await unlink(path.join(dir, name));
removed++;
}
}),
);
return removed;
}
export async function sweepCache(): Promise<{ removed: number }> {
const publicRemoved = await sweepDir(PUBLIC_DIR);
await sweepDir(DIST_DIR);
return { removed: publicRemoved };
}