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:
parent
285ff01303
commit
9d22d93361
7 changed files with 919 additions and 4 deletions
253
src/content/posts/en/local-webmention-avatars.md
Normal file
253
src/content/posts/en/local-webmention-avatars.md
Normal 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.
|
||||
183
src/content/posts/en/security-headers-astro-caddy.md
Normal file
183
src/content/posts/en/security-headers-astro-caddy.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue