Advanced Webweaving: Auditing Against Specification.Website (brennan.day)

During my time on the omg.lol IRC channel (a place I find inspiration for many posts, it seems!), Skippy shared an interesting website, The Website Specification. Now, I have to admit the site has a certain questionable aesthetic, but the checklist is valuable nonetheless. I've always tried to make sure my web development follows good practices and good hygiene, but there were plenty of things I've never really heard of or come across before. It's a big list. Foundations, SEO, accessibility, security, well-known URIs, "agent readiness" (bleh), performance, privacy, resilience, internationalisation. There's around 140 items to check off, each with a one-line justification for why it matters and a `Required` / `Recommended` / `Optional` / `Avoid` tag. Being the maximalist I am (in other words, I enjoy implementing functionality to my site as a means of procrastinating writing) I decided to go through, one-by-one, and see what I would need to change or add to my site. ## A Deliberate Skip Before anything else, I skipped the entire "Agent Readiness" category. I'm not going to add a `llms.txt` to my codebase, or per-page markdown endpoints. It's quite a cruel joke that the generative AI gets simple, readable markdown while the rest of us are stuck with far more complex HTML, isn't it? Instead of figuring out a way to get web browsers to display Markdown directly, we've duct-taped a solution for robots. Anyways, that's enough ranting about that for now. In addition, as I've already written about, I have no AI-crawler-specific `robots.txt` rules. The game of cat-and-mouse writing elaborate blockades is something I'm not interested in. The only winning move is not to play. That said, I did actually add some fun honeypots that you can find if you try to visit high-risk application paths, default CMS/root paths, secrets & configuration file locations, or API & discovery endpoints. Maybe I'll make another post for that if there's interest. Agent readiness, as a category, assumes you _want_ agents reading your site efficiently. Unlike search engine optimization (which is usually snake oil itself), optimization for LLMs just makes your content easier to steal, and doesn't help getting more humans to your website. ## A Silly Bug When you go over an audit like this, it makes you re-examine things you've written ages ago. In the case of my `site.webmanifest`, it made me realize it had empty `name` and `short_name` fields: {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png", ... Any Android user who tried to "Add to Home Screen" would've gotten a shortcut with no label. Even worse, the icon paths were root-relative (`/android-chrome-192x192.png`) when the actual files live at `/assets/images/favicon/android-chrome-192x192.png`. The manifest has been silently erroring and pointing at 404s since whenever I first generated it with some online favicon tool and never looked at the output again. I fixed both: { "name": "brennan.day", "short_name": "brennan.day", "start_url": "/", "scope": "/", "icons": [ {"src": "/assets/images/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"}, {"src": "/assets/images/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"} ], "theme_color": "#fbf1c7", "background_color": "#fbf1c7", "display": "standalone" } In addition, the `<meta name="theme-color">` was embarrassingly set to `#65312fff`. I have no idea when I wrote out this typo—8-digit hex values do actually have utility, with the extra two digits adding alpha channel for transparency, but that wasn't what was happening here. Anyways, I split the hex into a light/dark pair instead, matching my site's palette: <meta name="theme-color" content="#fbf1c7" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)"> ## Security Headers, or: What I'd Been Putting Off A lot of work went into security headers. My `netlify.toml` initially had `X-Frame-Options`, which is an outdated `X-XSS-Protection` header that I removed, as it does nothing in any current browser, and the spec says to avoid using it. I had no Content Security Policy, no HSTS, and nothing about cross-origin isolation. Writing a CSP for a site that embeds webring widgets and uses KaTeX, Mermaid, and has an IndieAuth comment flow took a bit of work: Content-Security-Policy = default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://cloud.umami.is https://neatstats.net https://cdn.jsdelivr.net https://webring.fun https://silly.possiblyaxolotl.com https://bubbles.town https://status.lol; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://silly.possiblyaxolotl.com; img-src 'self' data: https:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://cloud.umami.is https://*.supabase.co https://webring.fun https://neatstats.net https://bubbles.town; frame-src https://www.youtube.com; object-src 'none'; base-uri 'self'; form-action 'self' https://indieauth.com https://buttondown.com https://*.supabase.co; frame-ancestors 'none'; upgrade-insecure-requests; report-to default; I'm not going to pretend that's a _strict_ CSP. It still has `'unsafe-inline'` for both scripts and styles, because my site has inline `<style>` blocks for critical CSS (which is a debateable practice) and quite a few one-off inline `<script>` tags (theme toggle, scroll progress, the IndieAuth PKCE flow) scattered across templates. Doing this properly would mean having nonces or hashes on every single script, which frankly isn't a refactor I'm interested in. What I have now still improves security if there was something like a XSS bug, by locking down which _domains_ can be a script/style/connect/frame source at all. For `Strict-Transport-Security` and `Permissions-Policy`, there was nothing complicated to add: Strict-Transport-Security = "max-age=63072000; includeSubDomains" Permissions-Policy = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" `Cross-Origin-Embedder-Policy` made me stop and think, the spec's default recommendation is `require-corp`, which blocks any cross-origin resource that doesn't explicitly opt in with its own `Cross-Origin-Resource-Policy` header. For instance, my website ~~hotlinks~~ loads badge images from `aaronparecki.com`, `indieweb.org`, and `mirrors.creativecommons.org`, none of which I control. `require-corp` would've broken these images. I used `credentialless` instead, isolating the page the same way but only strips credentials from cross-origin requests rather than blocking them outright. I also wrote a `Reporting-Endpoints` header pointing at a tiny new Netlify Function that logs whatever the browser sends it: // netlify/functions/csp-report.js exports.handler = async (event) => { if (event.httpMethod !== "POST") return { statusCode: 405, body: "" }; console.warn("[reporting-api] " + (event.body || "").slice(0, 4000)); return { statusCode: 204, body: "" }; }; This isn't an actual observability pipeline, as it only `console.warn` into logs through Netlify's log drains. But if my CSP is breaking something for a visitor, I'll be able to find out. ## The CSP Learning Curve Of course, implementing a CSP wasn't as simple as copying the spec's recommendations. My initial CSP broke several things on my site: * The webring widgets from `webring.fun` and `silly.possiblyaxolotl.com` stopped loading * The bubbles.town upvote button failed to load * The status.lol widget was blocked * NeatStats analytics stopped working The issue was that these third-party services make API calls to their own domains for data, and I hadn't included those domains in my `connect-src` directive. Additionally, some services load their own CSS files that I hadn't whitelisted in `style-src`. I had to iteratively add missing domains: script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://cloud.umami.is https://neatstats.net https://cdn.jsdelivr.net https://webring.fun https://silly.possiblyaxolotl.com https://bubbles.town https://status.lol; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://silly.possiblyaxolotl.com; connect-src 'self' https://cloud.umami.is https://*.supabase.co https://webring.fun https://neatstats.net https://bubbles.town; form-action 'self' https://indieauth.com https://buttondown.com https://*.supabase.co; The lesson here is that CSPs require knowing exactly which domains your third-party services use—not just for loading their scripts, but also for any API calls they make in the background. Browser console errors were invaluable for debugging this. I also had to update my service worker to skip cross-origin requests entirely. The service worker was trying to handle requests to external domains (webring scripts, badge images, etc.) but couldn't properly cache them due to CORS restrictions, which was causing "undefined" response errors. By adding a check to only handle same-origin requests, the service worker now focuses on my own content while letting external resources load normally. ## Terrible, Terrible Cookies The checklist's Privacy category flags cookie consent as `Required`. But guess what? My website has zero cookies, hurray! Umami, my analytics, is documented as cookie-free and I already say so in my privacy policy, and nothing else on my website creates cookies either. Not every item deserves a "yes" just because the checklist says **Required**. Read the justification, check if it applies to _your_ site. Beyond this, there are better alternatives to cookies you can use. For saving user preferences and settings, use `localStorage`: // Set it localStorage.setItem('theme', 'dark'); // Get it const theme = localStorage.getItem('theme'); // 'dark' // Remove it localStorage.removeItem('theme'); Local storage also has a 10MB storage limit vs. 4KB limit for cookies. The catch is data persists forever unless cleared. `sessionStorage` works exactly like localStorage, but automatically clears the data when the user closes the browser tab. sessionStorage.setItem('formStep', '3'); For large, complex data (e.g., offline images, large cached JSON), you can use `localForage` (a library that yes, rhymes). Native IndexedDB is powerful but complicated (uses callbacks, cursors, and databases). `localForage` is a tiny library that gives you a localStorage-style API but stores data in IndexedDB behind the scenes. // Include the library, then: localforage.setItem('shoppingCart', { items: 200, total: '$500' }); localforage.getItem('shoppingCart').then((cart) => console.log(cart)); This handles binary data (blobs/files) and holds over 50MB, and uses Promises (async) so it doesn't freeze your webpage. ## A Useful Service Worker My old service worker cached `/assets/css/style.css`, a file that hasn't existed since I split my CSS into multiple files, so it was actually caching nothing. I rewrote it so a failed navigation falls back to a real offline page instead of the browser's own "no internet" screen, and images load cache-first instead of network-first: if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request).catch(() => caches.match(event.request).then(response => response || caches.match(OFFLINE_URL)) ) ); return; } I also added a check to skip cross-origin requests entirely, which was causing CORS-related errors. The service worker now only handles same-origin requests, letting external resources (webring scripts, badge images, etc.) load normally: // Skip cross-origin requests to avoid CORS issues if (!event.request.url.startsWith(self.location.origin)) { return; } ## Finally Fixing the Annoying Flash While testing all these changes, I finally dealt with the flashing on page load—elements in the main content and sidebar would resize visibly on page loads, regardless if it was your first visit or not. This was because I was using the deferring technique `media="print" onload="this.media='all'"`. This is a known pattern for loading CSS asynchronously to improve performance, but it has the annoying downside of causing flashes of Unstyled Content and layout shifts when the deferred CSS finally loads and applies. I just removed the deferred loading and let CSS load normally. The tradeoff is slightly slower initial render, but no more flashing. Sometimes the performance optimization causes the problem it's supposed to solve. ## Other Small Fixes (Rapid Fire Round!) There were a handful of things that were quick, once I knew to look for them: **`text-wrap: balance` / `text-wrap: pretty`** on headings and body copy, so the browser handles orphaned last-line words nicely. h1, h2, h3, h4, h5, h6 { text-wrap: balance; } p, li, dd, figcaption { text-wrap: pretty; } **`scrollbar-gutter: stable`** so pages that scroll (nearly every post) and pages that don't aren't shifting width against each other. html { scrollbar-gutter: stable; } **`scroll-margin-top` / `scroll-padding-top`** on headings and anchor targets, so jumping to `#comments` or a table-of-contents link doesn't land the heading directly underneath my sticky header (something that's been bothering me forever). html { scroll-padding-top: 5rem; } h1, h2, h3, h4, h5, h6, :target, [id] { scroll-margin-top: 5rem; } **A`@view-transition` block** with `navigation: auto`, so Chrome (for now) animates between page loads. Unsupported browsers just navigate normally, but it's a one-line opt-in. @view-transition { navigation: auto; } **Forced colours mode** support, for people running accessibility options, like Windows High Contrast. My rainbow nav underline and scroll-progress bar are pure decoration with no border to fall back on, so this snippet allows them to remain: @media (forced-colors: active) { .site-header::after, nav.site-nav a::after { background: CanvasText; forced-color-adjust: none; } button, .hero-btn, input[type="submit"] { border: 1px solid ButtonText; } } **`:user-valid` / `:user-invalid`** on form fields, real-time validation styling in CSS instead of JS class-toggling, and it only triggers after you've interacted with the field, so a required-but-empty input doesn't pop up before you've typed anything. input:user-invalid, textarea:user-invalid { border-color: var(--link-hover); } input:user-valid, textarea:user-valid { border-color: var(--nav-green); } **A container query** on the sidebar modules, this way the badge grid responds to the module's rendered width instead of just the viewport. .sidebar .module { container-type: inline-size; container-name: sidebar-module; } @container sidebar-module (max-width: 220px) { .badges-grid { grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); } } **`contain: layout paint`** on post cards and sidebar modules, telling the browser those subtrees can't affect anything outside themselves. a.post-card { contain: layout paint style; } **Speculation Rules** for same-origin prefetching, skipping honeypot paths and anything marked `rel="nofollow"`: <script type="speculationrules"> { "prefetch": [{ "source": "document", "where": { "and": [ { "href_matches": "/*" }, { "not": { "href_matches": "/honeypot/*" } }, { "not": { "selector_matches": "[rel~=\"nofollow\"]" } } ]}, "eagerness": "moderate" }] } </script> **Visible breadcrumbs** on posts and pages, matching the `BreadcrumbList` JSON-LD I already wrote but never rendered on the site: <nav class="breadcrumbs" aria-label="Breadcrumb"> <ol> <li><a href="/">Home</a></li> <li aria-current="page">Advanced Webweaving: Auditing Against Specification.Website</li> </ol> </nav> **`inputmode`, `enterkeyhint`, and `autocomplete`** on the comment sign-in, newsletter, contact, and guestbook forms, so the correct mobile keyboard shows up: <input type="url" name="me" inputmode="url" enterkeyhint="go" autocomplete="url" required> <input type="email" name="email" inputmode="email" enterkeyhint="send" autocomplete="email" required> **A`/500.html`** page and a `/maintenance.html` page, the latter being a documented, one-line redirect flip in `netlify.toml` for if ever I need to put the site into maintenance mode. (Some future unforseeable disaster.): # Commented out until I actually need it # [[redirects]] # from = "/*" # to = "/maintenance.html" # status = 503 # force = true **IndexNow**, a protocol I'd never heard of before this audit. Bing, Yandex, and a couple others let you push a "changes made" ping instead of waiting for a crawl. Generate a key, and drop it at the site root, and then I added a post-deploy hook next to the WebSub ping I already had: curl -s -X POST "https://api.indexnow.org/indexnow" \ -H "Content-Type: application/json; charset=utf-8" \ -d "{\"host\": \"brennan.day\", \"key\": \"...\", \"urlList\": [...]}" A **`webfinger`** endpoint at `/.well-known/webfinger`, which is how the Fediverse resolves `acct:brennan@brennan.day`-style identifiers to profile links. Mine is a static file instead of query-aware endpoint, since I only have the one identity to resolve (mine): { "subject": "acct:brennan@brennan.day", "links": [ { "rel": "self", "type": "application/activity+json", "href": "https://social.lol/@brennan" } ] } A **`/.well-known/traffic-advice`** file, which is Chrome-specific and tells its private prefetch proxy whether it's allowed to send prefetch traffic to my origin: [{ "user_agent": "prefetch-proxy", "google_prefetch_proxy_eap": { "fraction": 1.0 } }] Adding a Syndication module (`sy:updatePeriod`, `sy:updateFrequency`) in my RSS feed, so feed readers with the ability can now display how frequently my blog is updated: <rss version="2.0" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"> <channel> <sy:updatePeriod>daily</sy:updatePeriod> <sy:updateFrequency>1</sy:updateFrequency> </channel> </rss> `Redirect-By: Netlify`, a header that states which system issued a redirect, in case I'm debugging a redirect chain: [[headers]] for = "/*.html" [headers.values] No-Vary-Search = '''params=("utm_source" "utm_medium" "utm_campaign" "fbclid" "gclid")''' [[headers]] for = "/*" [headers.values] Redirect-By = "Netlify" [[headers]] for = "/assets/*" [headers.values] Cross-Origin-Resource-Policy = "cross-origin" A few more additions: * `No-Vary-Search` on HTML responses, listing out the usual tracking parameters (`utm_source`, `fbclid`, `gclid`, etc.) so a cache treats `/post/` and `/post/?utm_source=mastodon` as the same page instead of two (I try my best to avoid any tracking params, but other sites don't). * `Cross-Origin-Resource-Policy: cross-origin` on my asset headers, so nothing about the other origin isolation changes stops someone else from embedding my own images. * Core Web Vitals as a category is just reviewing what real users experience, which I did with PageSpeed Insights. The current assessment is **Passed** : **Largest Contentful Paint (LCP) 1.8 s** , **Interaction to Next Paint (INP) 68 ms** , **Cumulative Layout Shift (CLS) 0**. Other metrics: **First Contentful Paint (FCP) 1.6 s** , **Time to First Byte (TTFB) 1.6 s**. These were taken from the latest 28-day Chrome UX Report for mobile. ## Things Still Unaddressed There were also several other things I didn't get to from the long, detailed 140-item checklist: * **DNS CAA records and DNSSEC** are controlled by CDN, and not in this repo, so I'll need to review those. * **Trusted Types** would require auditing every `innerHTML`-style sink across my templates _and_ the third-party widgets I don't control. * **SRI on the webring/reaction-button scripts** isn't happening until they're pinned to a version instead of `@main`. Subresource Integrity on a moving target just means the hash breaks the next time I push a commit. I did add it to my pinned Mermaid script, where it made sense. * **Actual uptime monitoring** means picking and paying for a third-party service, which I don't think is necessary for my site. * **Strict CSP with nonces/hashes** would require refactoring all my inline scripts and styles to use proper CSP mechanisms, which is more work than I'm willing to invest right now. ## The Actual Point This is the part where I say none of this was really about the checklist. Don't get me wrong, checklists _are_ good for finding the things you've stopped seeing because you've been looking at the same code every day for months. Fixing the broken web manifest, the theme-colour meta tag doing nothing, and the breadcrumb schema I wasn't actually using. But the value wasn't in checking every box, it was about learning what my site was lacking due to my ignorance, and making meaningful changes to improve the experience for my readers and visitors. If you run your own site through the checklist, I'm curious what it turns up for you! (And please let me know if any of these changes actually ended up breaking something.)

Advanced Webweaving: Auditing Against Specification.Website https://brennan.day/advanced-webweaving-auditing-against-specification-website/

0 comments — live from bluesky

No comments yet.