abqari 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd411b1e1b18a824d5cee74ee6e557c7135fc973f6fc9edb4f4c806edff7cadf
4
- data.tar.gz: bb34137c4de0ffb733d1b911f6e523df2cde7b933d196e07d0e4ca4a4fb91460
3
+ metadata.gz: ac44a7f76fcb30cb218104f9e002a0cd958518542912393d7681fceab92abdf0
4
+ data.tar.gz: 8a0cc5fe35425ca6571dbc76549df5105106bf8ff672f67ea9101c26e48af550
5
5
  SHA512:
6
- metadata.gz: b1c8ea8aa8cad1f13f9dc3eddd81fbbedf891d024b6998f8a1f7aacf7c45c66ce783bea05174d983973c46eac5318372addd0d8b590e29871723f29f1d71a40c
7
- data.tar.gz: 2820827176cec3ecaaf08ad2ceb70c15b506435368154e24d2afc7ea544f2fe959613da696e2bb05eb81eff3011d4c37e07081565e1c4fa2ae2e901073b4e5c8
6
+ metadata.gz: 9050a56ac582b3cdb3f4590bf90f6543ee57fa0dd6e1c4b56afc7274a937dfbb805400869d157473019af1a9802da7e88af97e30961f436cbed8a2472f2bb6cb
7
+ data.tar.gz: db4d7cb9c339676e76cc794b8e205fb0000d50266d8b28ae6a53770b7d701a44fd3664d3f3d2076e3fc78cd2f08745ab442a4abd85a5bcbdbb0ef14586a8f26b
data/CHANGELOG.md CHANGED
@@ -9,6 +9,160 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  _Nothing yet._
11
11
 
12
+ ## [1.3.0] — 2026-10-11
13
+
14
+ If CI runs `bin/audit --strict`, run `bin/audit` locally before
15
+ upgrading: a new Security check can fail an audit that passed on
16
+ 1.2.0. Every finding is inline code that is already blocked on any host
17
+ that enforces the site's CSP, so the fix is in the site, not the
18
+ policy.
19
+
20
+ ### Added
21
+
22
+ - **`bin/audit` now fails on inline code the site's own CSP would
23
+ block.** The generated policy has `style-src 'self'` and pins inline
24
+ scripts by hash, so a template that adds `style="…"`, a `<style>`
25
+ block, an `onclick=`, or an unhashed `<script>` breaks on any host
26
+ that enforces the CSP. It hid because nothing local enforces it: the
27
+ dev server doesn't apply `_headers`, the page renders, and the
28
+ browser only logs a console error. One site had 52 inline
29
+ `style="--ratio: …"` attributes that would have collapsed every photo
30
+ pane the moment its CSP went live.
31
+
32
+ The new **Security** category (`Audit::Csp`) checks every page
33
+ against the CSP in the built `_headers`, so a `headers:` override or
34
+ `csp:` extension is what's checked. It reports one error per page per
35
+ kind of inline code, with an example and how to fix it. It follows
36
+ the spec: hashes, `'unsafe-inline'` (ignored once a hash or nonce is
37
+ present), `'unsafe-hashes'` for attributes, the `-elem`/`-attr`
38
+ directives and the `default-src` fallback. `bin/audit --strict` fails
39
+ on these, so a site that passed yesterday can fail today; every
40
+ finding is code that is already broken wherever the policy is
41
+ applied.
42
+
43
+ ## [1.2.0] — 2026-10-11
44
+
45
+ Upgrade if you serve the generated CSP: Chrome has been blocking
46
+ Abqari's own speculation rules, so nothing was prerendered and every
47
+ page logged a CSP violation, plus a Permissions-Policy error about
48
+ `interest-cohort`. Upgrade too if you deploy to Render, which ignores
49
+ `_headers` (`abqari headers` prints the rules it needs), or through the
50
+ shipped GitHub Actions workflows with `fonts:` set, which deployed
51
+ sites without their fonts.
52
+
53
+ On a host that ignores `_headers`, rebuild after upgrading and re-copy
54
+ the CSP and Permissions-Policy: both changed. Workflow files are copied
55
+ into each site at `abqari new`, so the font fix needs adding to existing
56
+ sites by hand.
57
+
58
+ ### Added
59
+
60
+ - **`speculation_rules.href_matches`** sets the URL patterns to
61
+ prerender: one pattern or a list. The default is unchanged: the posts
62
+ collection (`/posts/*`, or `/<slug>/*` with a custom `slug:`). A site
63
+ whose articles live in another collection prerendered nothing, which
64
+ is how Sanad's `/news/*` surfaced it. Changing it changes the CSP
65
+ hash (see Fixed).
66
+
67
+ - **`abqari headers`** prints the built `_site/_headers` as Render
68
+ header rules, ready to paste into `render.yaml` or the dashboard's
69
+ Settings → Headers. Render static sites don't read `_headers`, so
70
+ without this the CSP, the security headers and the year-long
71
+ `immutable` cache on `/assets/*` were missing, and Render served
72
+ assets with `max-age=0` (fonts revalidated on every page, so text
73
+ flashed). It reads the built file rather than computing headers, so
74
+ the CSP hashes are the ones the site's bundled engine and config
75
+ produced. Run it with `bundle exec` from the site root.
76
+ `docs/deploying.md` now covers Render's headers, the git-ignored
77
+ `vendor/fonts/` and Render's missing AVIF encoder, and no longer
78
+ tells Render users to `apt-get` packages.
79
+
80
+ ### Fixed
81
+
82
+ - **Chrome logged a CSP violation on every page, and nothing was
83
+ prerendered.** "Applying inline speculation rules violates the
84
+ following Content Security Policy directive". The generated CSP
85
+ allowed the head's `<script type="speculationrules">` block only
86
+ through `'inline-speculation-rules'`, and Chrome ignores that keyword
87
+ once `script-src` also lists hashes, which it always does (the theme
88
+ bootstrap and the data-saver guard). It hid because nothing visibly
89
+ breaks: the page works, the dev server doesn't apply `_headers`, and
90
+ the unit tests checked the keyword and the hashes separately.
91
+
92
+ The block's body now comes from `Abqari::SpeculationRules`, which the
93
+ head partial emits and `HeadersFile` hashes, so the two can't drift.
94
+ The keyword stays for browsers that honour it. With unchanged config
95
+ the body is byte-identical to 1.1.x, so a hash already pasted into a
96
+ host's settings keeps working. A site that overrides `_head.html.erb`
97
+ keeps matching too, until it sets `href_matches`; then its block
98
+ needs to emit `<%== Abqari::SpeculationRules.script(site) %>`.
99
+ `test/csp_inline_scripts_test.rb` now checks every inline script on a
100
+ built page against the built policy.
101
+
102
+ - **Chrome logged a Permissions-Policy error about `interest-cohort`
103
+ on every page.** The generated `Permissions-Policy` still opted out
104
+ of FLoC, which Google has dropped. It's gone; `camera`, `microphone`
105
+ and `geolocation` stay off.
106
+
107
+ - **Builds on Render logged dozens of false "Slow page" warnings, each
108
+ blaming a "plugin filter".** One build flagged about 45 pages at
109
+ 500ms+, while the same 270 pages render in about 5ms each locally.
110
+ There were three causes. The render pool was sized by
111
+ `Etc.nprocessors`, which in a container reports the host's CPUs
112
+ rather than the quota, so dozens of threads queued for one GVL.
113
+ Pages were timed by wall-clock, which counted that queueing and any
114
+ CPU throttling as the page's own time. And the plugin-filter cause
115
+ was attached to every slow page whenever any `:post_process` hook was
116
+ registered, without being timed. It hid because a developer machine
117
+ has the CPUs it reports.
118
+
119
+ Pages are now timed by their thread's CPU time, and the warning says
120
+ "of CPU". The pool follows the cgroup CPU quota when there is one,
121
+ capped at 4 (`Renderer::MAX_RENDER_THREADS`): rendering holds the
122
+ GVL, and a 270-page site built no faster on 8 threads than on 1. A
123
+ plugin filter is named only when it used at least a quarter of the
124
+ page's time, and with that time ("plugin filter 340ms"). Time a page
125
+ spends blocked on I/O no longer counts toward the threshold.
126
+
127
+ - **Sites deployed through the shipped GitHub Actions workflows had no
128
+ custom fonts.** The scaffold git-ignores `vendor/fonts/`, so a CI
129
+ checkout has no font files until `bin/fetch-fonts` runs, and
130
+ `deploy-cloudflare.yml`, `deploy-github-pages.yml` and
131
+ `deploy-render.yml` went straight to `bin/build`. The build passed,
132
+ the head (correctly) omitted the missing font stylesheet, and the
133
+ site shipped in fallback fonts. It hid because the developer's own
134
+ checkout has the fonts, so local builds look right.
135
+
136
+ The workflows now run `bundle exec bin/fetch-fonts` before building;
137
+ it's a no-op for a site with no `fonts:`. **Existing sites keep their
138
+ own copies of the workflows and must add the step themselves**, or
139
+ commit `vendor/fonts/`. Render's own build doesn't use these
140
+ workflows: its build command needs `bin/fetch-fonts` too (see
141
+ `docs/deploying.md`).
142
+
143
+ - **Editing a plugin while `bin/serve` was running broke every page with
144
+ an unexplained `NoMethodError`.** Add a helper in `plugins/`, save a
145
+ layout that calls it, and the next rebuild failed every page with
146
+ `undefined method '…' for an instance of Abqari::RenderContext` until
147
+ the server was restarted. Plugins load once, at `Site.new`, and the
148
+ dev server builds its Site once, so neither a plugin nor anything it
149
+ `require`s from the site's `lib/` is ever reloaded. `plugins/` wasn't
150
+ even watched, so the server had no idea anything had changed. It hid
151
+ because the plugin itself was fine: `bin/build` and a freshly started
152
+ `bin/serve` both load it and work.
153
+
154
+ `bin/serve` now watches `plugins/`. When a plugin, or a `lib/` file a
155
+ plugin required, changes, it logs a one-line warning naming the file
156
+ and saying to restart. A build that fails while that Ruby is stale
157
+ ends its error output with the same reminder, because by then the
158
+ first warning has usually scrolled away. The server deliberately
159
+ doesn't reload or re-exec itself: a half-saved plugin would then take
160
+ it down instead of logging an error. Tracking lives in
161
+ `Site#stale_ruby_sources`.
162
+
163
+ `docs/plugins.md` also said a stale plugin's old *and* new callbacks
164
+ both fire on each rebuild. Only the old ones do.
165
+
12
166
  ## [1.1.2] — 2026-10-09
13
167
 
14
168
  ### Fixed
@@ -987,7 +1141,9 @@ run a real working author / publisher site.
987
1141
  commonmarker ≥ 2.4 ships precompiled 4.0 binaries, which is what
988
1142
  the `~> 2.4` floor bought.
989
1143
 
990
- [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.1.2...HEAD
1144
+ [Unreleased]: https://github.com/grantrayner/abqari/compare/v1.3.0...HEAD
1145
+ [1.3.0]: https://github.com/grantrayner/abqari/compare/v1.2.0...v1.3.0
1146
+ [1.2.0]: https://github.com/grantrayner/abqari/compare/v1.1.2...v1.2.0
991
1147
  [1.1.2]: https://github.com/grantrayner/abqari/compare/v1.1.1...v1.1.2
992
1148
  [1.1.1]: https://github.com/grantrayner/abqari/compare/v1.1.0...v1.1.1
993
1149
  [1.1.0]: https://github.com/grantrayner/abqari/compare/v1.0.0...v1.1.0
data/README.md CHANGED
@@ -11,7 +11,7 @@ optional overrides), while the engine — layouts, themes, helpers, asset
11
11
  pipeline — lives in the dependency. Forking the engine is also
12
12
  supported for users who want to own all the code.
13
13
 
14
- > **Status:** v1.1.2 — see
14
+ > **Status:** v1.3.0 — see
15
15
  > [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md).
16
16
  > Distributed via RubyGems (`gem install abqari`) and as a Bundler
17
17
  > path/git source. The API follows [semver](https://semver.org/) from
@@ -174,7 +174,7 @@ Test that it worked:
174
174
  abqari version
175
175
  ```
176
176
 
177
- You should see a version number, e.g. `abqari 1.1.2`. If you get
177
+ You should see a version number, e.g. `abqari 1.3.0`. If you get
178
178
  `command not found`, see [Troubleshooting](#troubleshooting) below.
179
179
 
180
180
  ### Step 3 — Create your first site
@@ -460,7 +460,7 @@ Edit `Gemfile`:
460
460
  ```ruby
461
461
  source 'https://rubygems.org'
462
462
 
463
- gem 'abqari', '~> 1.1'
463
+ gem 'abqari', '~> 1.3'
464
464
 
465
465
  group :development do
466
466
  gem 'listen', '~> 3.8' # optional, for the dev-server file watcher
@@ -470,7 +470,7 @@ end
470
470
  Or — pin to a git ref while tracking pre-release work:
471
471
 
472
472
  ```ruby
473
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.1.2'
473
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.0'
474
474
  ```
475
475
 
476
476
  Install the libvips C library once (hard dep for the image pipeline):
@@ -1631,6 +1631,8 @@ bin/fetch-fonts
1631
1631
 
1632
1632
  Abqari downloads the woff2 files to `vendor/fonts/`, generates a `fonts.css` with rewritten URLs, and serves everything from `/assets/fonts/` at build time. The stylesheet is fingerprinted like any other asset (`/assets/fonts/fonts.<hash>.css`), so changing `fonts:` and re-running `bin/fetch-fonts` busts the immutable cache. The head partial links it whenever `fonts:` is configured and the fetch has run.
1633
1633
 
1634
+ `vendor/fonts/` is git-ignored, so every deploy has to fetch the fonts too, or the site ships in fallback fonts with a green build. The shipped GitHub Actions workflows run `bin/fetch-fonts` before `bin/build`; on a host that builds from its own command (Render), add it there. Or commit `vendor/fonts/` instead. See [deploying.md → Fonts](https://github.com/grantrayner/abqari/blob/main/docs/deploying.md#fonts).
1635
+
1634
1636
  If you override the head partial, link the stylesheet with `<%= asset_path('fonts/fonts.css') %>`. An unhashed copy still ships at `/assets/fonts/fonts.css`, but `_headers` caches it for a year, so returning visitors won't see font changes.
1635
1637
 
1636
1638
  ## Icons
@@ -1763,7 +1765,7 @@ Every build emits the files a static host needs to serve a real site:
1763
1765
  | `feed.xml` | RSS 2.0 feed of latest posts |
1764
1766
  | `feed.json` | JSON Feed 1.1 |
1765
1767
  | `robots.txt` | Per-environment — `Disallow: /` in dev/staging, real one in production |
1766
- | `_headers` | Security headers (CSP, HSTS, Permissions-Policy, etc.) + asset cache-control. Read by Cloudflare Pages and Netlify |
1768
+ | `_headers` | Security headers (CSP, HSTS, Permissions-Policy, etc.) + asset cache-control. Read by Cloudflare Pages and Netlify. Render ignores it — `bundle exec abqari headers` prints the same rules to paste in (see [deploying.md](https://github.com/grantrayner/abqari/blob/main/docs/deploying.md#headers-render-ignores-_headers)) |
1767
1769
  | `_redirects` | 301 mappings for any post with `redirect_from:` |
1768
1770
  | `manifest.json` | Web app manifest, populated from your site title + locale. Override colours/names under `manifest:` in `site.yml` |
1769
1771
  | `/.well-known/security.txt` | Generated when `security_contact:` is set |
@@ -1784,7 +1786,7 @@ Abqari generates a stub HTML page at `/old-tutorial/` with a `<meta refresh>` an
1784
1786
 
1785
1787
  **SEO meta** — every page's `<head>` includes canonical URL, Open Graph tags, Twitter card tags, RSS/JSON Feed alternates, and (for posts) JSON-LD `Article` structured data. All driven from `site.config` and frontmatter — no manual tagging required.
1786
1788
 
1787
- **Security headers** — the default `_headers` ships with a strict CSP (`'self'` only — works because Abqari has no inline scripts/styles), HSTS (1 year), `Referrer-Policy`, `Permissions-Policy` (camera/mic/geolocation/FLoC all off), `X-Content-Type-Options`, and `frame-ancestors 'none'`.
1789
+ **Security headers** — the default `_headers` ships with a strict CSP (`'self'` only, with each of Abqari's few inline scripts pinned by SHA-256 rather than allowed by `'unsafe-inline'`), HSTS (1 year), `Referrer-Policy`, `Permissions-Policy` (camera/mic/geolocation off), `X-Content-Type-Options`, and `frame-ancestors 'none'`.
1788
1790
 
1789
1791
  The policy is *built* from the features you've configured — enabling Plausible adds `plausible.io`, setting `newsletter.action` adds that provider's origin, and so on. You never hand-write it. Two keys extend it when the engine can't infer what you need:
1790
1792
 
@@ -1879,8 +1881,14 @@ for readers on slow or data-saving connections. Tune or disable:
1879
1881
  speculation_rules:
1880
1882
  enabled: true # false to omit entirely
1881
1883
  eagerness: moderate # conservative / moderate / eager
1884
+ href_matches: /news/* # default: the posts collection (/posts/*); one pattern or a list
1882
1885
  ```
1883
1886
 
1887
+ Set `href_matches` when your main content isn't in `posts` — a site
1888
+ whose articles live in a `news` collection prerenders nothing until it
1889
+ does. The generated CSP pins the rules block by hash, so changing any
1890
+ of these changes the CSP; on a host that ignores `_headers`, re-copy it.
1891
+
1884
1892
  Run `bin/audit` after building — it checks every page for missing
1885
1893
  descriptions, broken links, missing alt text, and other SEO and
1886
1894
  accessibility problems before you deploy.
@@ -2323,6 +2331,7 @@ Rebuilds the site, walks the output, and writes a markdown report to `audit/<YYY
2323
2331
  | **Privacy** | External resource loads (URLs not on the site host) |
2324
2332
  | **Dependencies** | Outdated gems via `bundle outdated`, CVEs via `bundle-audit` (if installed) |
2325
2333
  | **Content** | Posts without descriptions, single-use tags, `series_position` problems that break prev/next silently (non-Integer, duplicate, or missing) |
2334
+ | **Security** | Inline code the built `_headers` CSP would block: `style="…"` attributes, `<style>` blocks, `on…=` handlers, and inline scripts whose hash isn't in `script-src`. Errors, because the page breaks on any host that enforces the policy |
2326
2335
 
2327
2336
  The report opens with a summary table, a **Suggested fixes** code block listing every `bundle update <gem>` command needed to clear the dependency findings, and then groups findings by category with severity (`error`, `warn`, `info`) and a suggestion where applicable. Reports accumulate in `audit/` — useful to commit so you have a diffable history of site quality over time.
2328
2337
 
@@ -2357,7 +2366,7 @@ GitHub Dependabot does three things abqari's audit does **not**:
2357
2366
 
2358
2367
  What `bin/audit` does that Dependabot doesn't:
2359
2368
 
2360
- - **Same report covers links, SEO, accessibility, privacy, and content.** Dependabot only watches dependencies.
2369
+ - **Same report covers links, SEO, accessibility, privacy, content, and the CSP.** Dependabot only watches dependencies.
2361
2370
  - **Uses the same `ruby-advisory-db`** Dependabot's Ruby ecosystem coverage uses, just at a single point in time.
2362
2371
  - **Outputs to git-trackable markdown.** A diffable `audit/` history of every check you've run.
2363
2372
 
@@ -2416,7 +2425,7 @@ Run a single test file: `bundle exec ruby -Ilib -Itest test/helpers_test.rb`. Ru
2416
2425
 
2417
2426
  ## Livereload
2418
2427
 
2419
- `bin/serve` watches `content/`, `app/`, `themes/`, `config/`, `lib/`, and `data/` for changes. On change:
2428
+ `bin/serve` watches `content/`, `app/`, `themes/`, `config/`, `lib/`, `data/`, and `plugins/` for changes. On change:
2420
2429
 
2421
2430
  1. The build runs incrementally (no `_site/` wipe; mtime-aware copies skip unchanged work).
2422
2431
  2. The build version bumps and the watcher classifies the change scope.
@@ -2424,6 +2433,8 @@ Run a single test file: `bundle exec ruby -Ilib -Itest test/helpers_test.rb`. Ru
2424
2433
  - **Swaps the `<link rel="stylesheet">` href** in place if only CSS files changed (preserves scroll position and form state), or
2425
2434
  - **Reloads the page** if anything else changed.
2426
2435
 
2436
+ **Ruby isn't reloaded.** Plugins, and anything they `require` from `lib/`, load once when `bin/serve` starts. When one of those files changes, the server logs a one-line warning naming it; restart `bin/serve` to load the new code. If a build fails while that code is stale, the failure output ends with the same reminder, because the usual symptom is `NoMethodError: undefined method '…' for an instance of Abqari::RenderContext` from a layout calling a helper the server hasn't loaded.
2437
+
2427
2438
  **Watcher backend.** When the `listen` gem is installed (it's a development-group dep), the watcher uses native FS events (FSEvents on macOS, inotify on Linux) — zero overhead between changes. Without `listen`, it falls back to 500ms mtime polling. Both modes debounce events by 100ms so editor tools that fire two events per save don't double-rebuild.
2428
2439
 
2429
2440
  **Transport.** Server-Sent Events (SSE) over plain HTTP — no WebSocket dep, works through any proxy. The browser opens a single persistent EventSource per tab; the server pushes events with sub-50ms median latency. Connection auto-reconnects if the dev server restarts. The script is only injected when `ABQARI_ENV=development`, so production builds are clean.
@@ -2444,6 +2455,8 @@ end
2444
2455
 
2445
2456
  Lifecycle events: `:before_build`, `:after_load_pages`, `:after_generate`, `:after_build`. Filters: `:post_process(html, site, page)`. Full reference in [`docs/plugins.md`](https://github.com/grantrayner/abqari/blob/main/docs/plugins.md).
2446
2457
 
2458
+ `bin/serve` doesn't reload plugins, so restart it after editing one. It warns you when it sees the change (see [Livereload](#livereload)).
2459
+
2447
2460
  ## Performance
2448
2461
 
2449
2462
  Build-time and runtime perf are already tuned for typical sites (~50–200 pages). What's baked in:
@@ -2546,13 +2559,14 @@ bin/audit # verify nothing regressed
2546
2559
  ```
2547
2560
 
2548
2561
  `abqari new` scaffolds the Gemfile pinned to the current major.minor
2549
- (`gem 'abqari', '~> 1.1'` as of this release), so `bundle update abqari`
2550
- picks up patch releases and bumping the pin is the deliberate action of
2551
- taking a feature release. To track a fork or an unreleased branch
2562
+ (`gem 'abqari', '~> 1.3'` as of this release). Bundler reads that as
2563
+ `>= 1.3, < 2.0`, so `bundle update abqari` takes minor and patch
2564
+ releases but never a major one, which is the release that can need
2565
+ changes to your site. To track a fork or an unreleased branch
2552
2566
  instead, swap the line for a git source:
2553
2567
 
2554
2568
  ```ruby
2555
- gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.1.2'
2569
+ gem 'abqari', git: 'https://github.com/grantrayner/abqari.git', tag: 'v1.3.0'
2556
2570
  ```
2557
2571
 
2558
2572
  The cadence: read [CHANGELOG.md](https://github.com/grantrayner/abqari/blob/main/CHANGELOG.md) before each upgrade,
@@ -325,12 +325,14 @@
325
325
  <% end %>
326
326
 
327
327
  <%# Speculation Rules — prerender posts the user is likely to click
328
- (any `/posts/<slug>/` link in the current document). Modern
329
- browsers (Chrome, Edge) act on this for measurably-faster
330
- navigations; other browsers safely ignore it. The generated
331
- `_headers` CSP adds `'inline-speculation-rules'` to script-src so
332
- this typed JSON block is allowed under strict CSP (see
333
- HeadersFile#apply_speculation_rules). Toggle off with
328
+ (any `/posts/<slug>/` link in the current document, or whatever
329
+ `speculation_rules.href_matches` names). Modern browsers (Chrome,
330
+ Edge) act on this for measurably-faster navigations; other
331
+ browsers safely ignore it. The body comes from
332
+ Abqari::SpeculationRules so the generated `_headers` CSP can pin
333
+ its exact SHA-256 — Chrome ignores `'inline-speculation-rules'`
334
+ once script-src carries hashes. Emit it byte-for-byte: any change
335
+ here (even whitespace) breaks the hash. Toggle off with
334
336
  `speculation_rules.enabled: false` in config.
335
337
 
336
338
  The CSP-pinned inline guard JS below honours `prefers-reduced-data`
@@ -339,18 +341,8 @@
339
341
  can act on it. This avoids burning bandwidth on metered users.
340
342
  The script gets its own SHA256 entry in HeadersFile so strict CSP
341
343
  stays clean. %>
342
- <% spec_cfg = site.config['speculation_rules'] || {} %>
343
- <% if spec_cfg.fetch('enabled', true) %>
344
- <%
345
- eagerness = %w[conservative moderate eager].include?(spec_cfg['eagerness'].to_s) ? spec_cfg['eagerness'].to_s : 'moderate'
346
- posts_slug = site.collection_config('posts')['slug'].to_s
347
- posts_path = posts_slug.empty? ? 'posts' : posts_slug
348
- %>
349
- <script type="speculationrules" id="abqari-spec-rules">
350
- { "prerender": [{ "source": "document",
351
- "where": { "href_matches": "/<%= posts_path %>/*" },
352
- "eagerness": "<%= eagerness %>" }] }
353
- </script>
344
+ <% if Abqari::SpeculationRules.enabled?(site) %>
345
+ <script type="speculationrules" id="abqari-spec-rules"><%== Abqari::SpeculationRules.script(site) %></script>
354
346
  <script><%== Abqari::SpeculationRulesGuard::SCRIPT %></script>
355
347
  <% end %>
356
348
 
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'cgi'
5
+ require 'digest'
6
+
7
+ module Abqari
8
+ class Audit
9
+ # CSP rule. Checks every page's inline code against the
10
+ # Content-Security-Policy the same build wrote to `_headers`: inline
11
+ # `<script>` and `<style>` blocks, `style="…"` attributes and `on…=`
12
+ # event handlers.
13
+ #
14
+ # The policy is strict on purpose (`style-src 'self'`, scripts pinned
15
+ # by hash), so anything a template adds inline is blocked wherever
16
+ # the CSP is enforced — and nothing else notices. The dev server
17
+ # doesn't apply `_headers`, the page still renders, and the browser
18
+ # only logs a console error. One site carried 52 inline
19
+ # `style="--ratio: …"` attributes that would have collapsed every
20
+ # photo pane, and the engine's own speculation-rules block shipped in
21
+ # 1.1.x allowed only by a keyword Chrome ignores.
22
+ #
23
+ # Reads the BUILT `_headers`, not the engine's defaults, so a
24
+ # `headers:` override or `csp:` extension in site.yml is what gets
25
+ # checked. Error-severity: on a host that enforces the policy the
26
+ # page is broken. A site on a host that ignores `_headers` (GitHub
27
+ # Pages) will see these too; the code is still blocked the day the
28
+ # policy is applied.
29
+ class Csp < Rule
30
+ # Script types the browser executes, so CSP governs them. Any other
31
+ # `type` is a data block (JSON-LD, templates) and CSP ignores it.
32
+ EXECUTABLE_TYPES = %w[
33
+ text/javascript application/javascript application/ecmascript text/ecmascript
34
+ module importmap speculationrules
35
+ ].freeze
36
+ HASH_ALGORITHMS = { 'sha256' => Digest::SHA256, 'sha384' => Digest::SHA384, 'sha512' => Digest::SHA512 }.freeze
37
+
38
+ # Most specific directive first, as browsers resolve them.
39
+ DIRECTIVES = {
40
+ script: %w[script-src-elem script-src default-src],
41
+ script_attr: %w[script-src-attr script-src default-src],
42
+ style: %w[style-src-elem style-src default-src],
43
+ style_attr: %w[style-src-attr style-src default-src]
44
+ }.freeze
45
+
46
+ LABELS = {
47
+ script: 'inline <script>',
48
+ script_attr: 'inline event handler',
49
+ style: '<style> block',
50
+ style_attr: 'style attribute'
51
+ }.freeze
52
+
53
+ ADVICE = {
54
+ script: 'Move it to a file under app/assets/js/, or add its sha256 to the CSP. ' \
55
+ 'For the engine\'s own scripts, upgrade abqari and re-copy the CSP.',
56
+ script_attr: 'Attach the handler from a script file with addEventListener instead.',
57
+ style: 'Move the rules into app/assets/css/site.css.',
58
+ style_attr: 'Move the styles into app/assets/css/site.css as a class, or a data ' \
59
+ 'attribute matched by an attribute selector for per-element values.'
60
+ }.freeze
61
+
62
+ def run
63
+ policies = csp_policies
64
+ return if policies.empty?
65
+
66
+ html_files.each do |file|
67
+ url = page_url(file)
68
+ applicable = policies.select { |prefix, _| url.start_with?(prefix) }.map(&:last)
69
+ next if applicable.empty?
70
+
71
+ check_page(url, File.read(file, encoding: 'UTF-8'), applicable)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # `[[path_prefix, { directive => [sources] }]]` for every CSP rule in
78
+ # the built `_headers`. More than one can apply to a page; the
79
+ # browser enforces all of them, so inline code has to pass each.
80
+ def csp_policies
81
+ path = File.join(@site.output_dir, '_headers')
82
+ return [] unless File.file?(path)
83
+
84
+ HeadersFile.parse(File.read(path, encoding: 'UTF-8'))
85
+ .select { |rule| rule['name'].casecmp?('Content-Security-Policy') }
86
+ .map { |rule| [rule['path'].delete_suffix('*'), parse_policy(rule['value'])] }
87
+ end
88
+
89
+ def parse_policy(value)
90
+ value.split(';').each_with_object({}) do |directive, policy|
91
+ name, *sources = directive.strip.split(/\s+/)
92
+ policy[name.downcase] = sources if name
93
+ end
94
+ end
95
+
96
+ def check_page(url, html, policies)
97
+ blocked = Hash.new { |h, k| h[k] = [] }
98
+ inline_code(html).each do |kind, content, type|
99
+ policy = policies.find { |pol| !allowed?(pol, kind, content, type) }
100
+ blocked[kind] << [content, policy] if policy
101
+ end
102
+
103
+ blocked.each do |kind, hits|
104
+ content, policy = hits.first
105
+ directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
106
+ count = hits.size
107
+ add(category: 'security', severity: 'error', location: url,
108
+ message: "#{count} #{LABELS[kind]}#{'s' unless count == 1} blocked by the CSP " \
109
+ "(#{directive} #{policy[directive].join(' ')}), e.g. #{example(kind, content)}",
110
+ suggestion: ADVICE[kind])
111
+ end
112
+ end
113
+
114
+ # Each piece of inline code as `[kind, content, script_type]`, where
115
+ # content is exactly what the browser hashes.
116
+ def inline_code(html)
117
+ html = html.gsub(/<!--.*?-->/m, '')
118
+ code = []
119
+
120
+ html.scan(%r{<script\b([^>]*)>(.*?)</script>}mi).each do |attrs, body|
121
+ next if attribute(attrs, 'src')
122
+
123
+ type = attribute(attrs, 'type')&.strip&.downcase
124
+ next unless type.nil? || type.empty? || EXECUTABLE_TYPES.include?(type)
125
+
126
+ code << [:script, body, type]
127
+ end
128
+ html.scan(%r{<style\b[^>]*>(.*?)</style>}mi).each { |(body)| code << [:style, body] }
129
+
130
+ # Attributes, with script and style bodies removed first so a
131
+ # string inside a script that looks like markup isn't counted.
132
+ tags_only = html.gsub(%r{<(script|style)\b[^>]*>.*?</\1>}mi, '')
133
+ tags_only.scan(/<[a-zA-Z][^>]*>/).each do |tag|
134
+ tag.scan(/\s(on[a-z]+|style)\s*=\s*(?:"([^"]*)"|'([^']*)')/i).each do |name, dq, sq|
135
+ code << [name.casecmp?('style') ? :style_attr : :script_attr, CGI.unescapeHTML(dq || sq)]
136
+ end
137
+ end
138
+ code
139
+ end
140
+
141
+ def attribute(attrs, name)
142
+ match = attrs.match(/\b#{name}\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i)
143
+ match && (match[1] || match[2] || match[3])
144
+ end
145
+
146
+ # Mirrors the CSP spec's inline checks: a hash or nonce in the list
147
+ # switches `'unsafe-inline'` off; attribute hashes count only with
148
+ # `'unsafe-hashes'`. `'inline-speculation-rules'` is honoured only
149
+ # when the list has no hash or nonce: Chrome reports a block allowed
150
+ # by nothing but the keyword as a violation once hashes are present.
151
+ def allowed?(policy, kind, content, type = nil)
152
+ directive = DIRECTIVES[kind].find { |d| policy.key?(d) }
153
+ return true unless directive
154
+
155
+ sources = policy[directive]
156
+ hashed = sources.any? { |s| s.match?(/\A'(sha256|sha384|sha512|nonce)-/i) }
157
+ hashed ||= kind == :script && sources.include?("'strict-dynamic'")
158
+ return true if sources.include?("'unsafe-inline'") && !hashed
159
+ return true if type == 'speculationrules' && sources.include?("'inline-speculation-rules'") && !hashed
160
+ return false if %i[style_attr script_attr].include?(kind) && !sources.include?("'unsafe-hashes'")
161
+
162
+ HASH_ALGORITHMS.any? do |alg, digest|
163
+ sources.include?("'#{alg}-#{Base64.strict_encode64(digest.digest(content))}'")
164
+ end
165
+ end
166
+
167
+ def example(kind, content)
168
+ snippet = content.strip.gsub(/\s+/, ' ')
169
+ snippet = "#{snippet[0, 60]}…" if snippet.length > 60
170
+ case kind
171
+ when :style_attr then %(style="#{snippet}")
172
+ when :script_attr then %(handler "#{snippet}")
173
+ else %(`#{snippet}`)
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
data/lib/abqari/audit.rb CHANGED
@@ -12,6 +12,7 @@ require_relative 'audit/privacy'
12
12
  require_relative 'audit/performance'
13
13
  require_relative 'audit/dependencies'
14
14
  require_relative 'audit/content'
15
+ require_relative 'audit/csp'
15
16
 
16
17
  module Abqari
17
18
  # Site audit orchestrator. Each check lives in its own rule class
@@ -36,14 +37,15 @@ module Abqari
36
37
  'privacy' => 'Privacy',
37
38
  'perf' => 'Performance',
38
39
  'deps' => 'Dependencies',
39
- 'content' => 'Content'
40
+ 'content' => 'Content',
41
+ 'security' => 'Security'
40
42
  }.freeze
41
43
 
42
44
  # Rule classes, in execution order. Order matters only for the
43
45
  # output report — findings are grouped by category, not insertion
44
46
  # order — but Links first keeps the report scan-friendly: errors
45
47
  # appear before warnings.
46
- RULES = [Links, SEO, A11y, Privacy, Performance, Dependencies, Content].freeze
48
+ RULES = [Links, SEO, A11y, Privacy, Performance, Dependencies, Content, Csp].freeze
47
49
 
48
50
  attr_reader :findings
49
51