al_folio_distill 1.0.2 → 1.0.3

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: e85ca554399308d28af93a3d025b0d70c46b9d057563236682ce17382eecf8cd
4
- data.tar.gz: bf90c691657352a5c90b0d74381a73b37cfcc00ede4fc459aefe7d8e437eacb7
3
+ metadata.gz: 43e18fcf0822a90ad412005985178d449d7267f6924b38b3181aba3edf176cff
4
+ data.tar.gz: 1d2a83960addd942b34e39891854356b906d9380ff748252fab0a15cf6eb90fc
5
5
  SHA512:
6
- metadata.gz: df094843f4a0943e5e621b1346700580e7b030f1b12a85e5c739fd81760e9405ab395eea913ce7b170dfaf8b2c22aa094ba339f7f0085f91a18cc5fa801ed44d
7
- data.tar.gz: 25fc0a770e18e5c31c57bb22727061dc69a0262d998ff465ece6acc73489499a8c90fba065d47e225dc6aa1d981c682c8d833b6c931ff88eeea40f07e907f7f0
6
+ metadata.gz: e75ede64f8075366893d771808ba5840c957ffb75d92f328a166f2361caf8ad63af727c41e94f4e7398443bd62438451e9ca1f82be2d40d1c70394c137c927cd
7
+ data.tar.gz: cbfd8b4eabf21748c1a5267566f675bbc49c5bde027cb2867614dcfc7b317456a85a941fdcbd0288cda91608968ce565d2bd5148f0ced73652e97851765bb7e4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.3 - 2026-07-27
4
+
5
+ - Security: removed the unpinned remote Distill runtime loader. The vendored `assets/js/distillpub/transforms.v2.js` shipped upstream's `Polyfills` transform, which strips the page's `template.v2.js` tag and re-injects it from a hard-coded `https://distill.pub/template.v2.js` with no subresource integrity — discarding the vendored, hash-pinned copy this gem exists to ship and handing arbitrary JS execution on every Distill page to whoever controls that origin. The transform now re-injects `window.alFolioDistill.templateLoader`, falling back to the same-origin `src` of the tag it just removed, and never falls back to a remote origin. Recorded under `local_patches` and flipped `remote_loader_patched` to `true` (port it to the upstream distill-template so a future re-sync preserves it).
6
+ - `templates/distill/render.liquid` now emits the runtime via the new `{% al_folio_distill_runtime_scripts %}` tag, which serves `template.v2.js`/`transforms.v2.js` from the vendored copy with `integrity` pinned to the SHA-256 digests committed in `provenance.json`.
7
+ - Remote loading is now explicit opt-in via `al_folio.distill.allow_remote_loader` (default `false`), with `al_folio.distill.remote_loader_url` and `al_folio.distill.remote_loader_integrity`. When an SRI hash is configured the injected tag carries `integrity` + `crossorigin="anonymous"`; the build warns when opting in without one.
8
+ - Added a build-time check that the vendored runtime on disk matches the digests pinned in `provenance.json`, so drift is reported at build time instead of failing subresource integrity in visitors' browsers.
9
+ - `scripts/distill/sync_distill.sh` now derives `remote_loader_patched` from the synced bytes instead of hard-coding `false`, preserves `local_patches`, and exits non-zero if a re-sync reintroduces the remote loader.
10
+
3
11
  ## 1.0.2 - 2026-06-01
4
12
 
5
13
  - De-jQueried the vendored `assets/js/distillpub/overrides.js`. Its top-level `$(window).on("load", ...)` threw `ReferenceError: $ is not defined` on every distill page (jQuery was removed in al-folio v1), so the footnote/citation dark-theme overrides never applied and the page logged a console error. Switched to `window.addEventListener("load", ...)`; the handler body was already vanilla. Updated the pinned SHA-256 in `provenance.json` and the runtime contract test, and recorded the change under `local_patches` (port it to the upstream distill-template so a future re-sync preserves it).
data/README.md CHANGED
@@ -42,6 +42,37 @@ Refresh assets:
42
42
  ./scripts/distill/sync_distill.sh <commit-sha>
43
43
  ```
44
44
 
45
+ The sync script records the real digests in `provenance.json` and fails if the
46
+ synced ref reintroduces the hard-coded remote Distill loader (see below).
47
+
48
+ ## Runtime loading policy
49
+
50
+ Distill pages load the runtime from the vendored copy under
51
+ `/assets/js/distillpub/`, with `integrity` pinned to the SHA-256 digests
52
+ committed in `provenance.json`. Upstream's `Polyfills` transform removes the
53
+ page's template tag and re-adds it; the vendored copy is patched so it re-adds
54
+ the vendored URL rather than a hard-coded `distill.pub` one.
55
+
56
+ Loading the runtime from a third-party origin is opt-in:
57
+
58
+ ```yaml
59
+ al_folio:
60
+ distill:
61
+ engine: distillpub-template
62
+ source: al-org-dev/distill-template#al-folio
63
+ # Default false. When false, the runtime is only ever loaded from the
64
+ # vendored, integrity-pinned copy.
65
+ allow_remote_loader: false
66
+ # Only used when allow_remote_loader is true.
67
+ remote_loader_url: https://distill.pub/template.v2.js
68
+ # Optional SRI for remote_loader_url. When set, the injected tag carries
69
+ # integrity + crossorigin="anonymous". The build warns when it is missing.
70
+ remote_loader_integrity: sha256-...
71
+ ```
72
+
73
+ Keep `allow_remote_loader: false` unless you control the remote origin: whoever
74
+ serves `remote_loader_url` executes arbitrary JavaScript on every Distill page.
75
+
45
76
  ## Contributing
46
77
 
47
78
  Distill runtime/template behavior belongs here. Starter-only docs/demo changes belong in `al-folio`.
@@ -7,13 +7,14 @@
7
7
  "toolchain": {
8
8
  "sync_mode": "copy-dist-artifacts"
9
9
  },
10
- "remote_loader_patched": false,
10
+ "remote_loader_patched": true,
11
11
  "local_patches": [
12
- "overrides.js: de-jQuery the load handler ($(window).on('load', ...) -> window.addEventListener('load', ...)); jQuery is not loaded in al-folio v1. Port this upstream so the next sync preserves it."
12
+ "overrides.js: de-jQuery the load handler ($(window).on('load', ...) -> window.addEventListener('load', ...)); jQuery is not loaded in al-folio v1. Port this upstream so the next sync preserves it.",
13
+ "transforms.v2.js: the Polyfills transform re-injected the runtime from a hard-coded remote distill.pub template URL with no SRI, discarding the vendored copy. It now re-injects window.alFolioDistill.templateLoader (vendored + integrity-pinned by default), falling back to the same-origin src of the tag it removed. Port this upstream so the next sync preserves it."
13
14
  ],
14
15
  "assets": {
15
16
  "template.v2.js": "4790831ced02f7c4f2009b2cdf6978ceda8351f0060d3b59dd9b3aab132e271a",
16
- "transforms.v2.js": "70e3f488e23ec379d33a10a60311ec60b570b3b2d5f1823e9159f661c315184e",
17
+ "transforms.v2.js": "5d85590f5652b910ab2411019749c83ef5a5a3fbb9b739adc92b4557b6bf3d39",
17
18
  "overrides.js": "41ce4a3744a9083e8913df1256432d2233ba16ff9b40c4878bdb3f35d73c0e8e"
18
19
  }
19
20
  }
@@ -14060,11 +14060,31 @@
14060
14060
  //
14061
14061
  // `;
14062
14062
 
14063
- const addBackIn = `
14063
+ // al-folio local patch: upstream re-injects the Distill runtime from a
14064
+ // hard-coded remote distill.pub template URL with no subresource integrity,
14065
+ // which silently discards the vendored, hash-pinned copy this gem ships.
14066
+ // Re-inject the loader al-folio declared instead
14067
+ // (`window.alFolioDistill.templateLoader`, emitted by
14068
+ // templates/distill/render.liquid), falling back to the same-origin src of the
14069
+ // tag we just removed. Never fall back to a remote origin.
14070
+ const addBackIn = (templateSrc) => `
14064
14071
  window.addEventListener('WebComponentsReady', function() {
14065
14072
  console.warn('WebComponentsReady');
14066
- const loaderTag = document.createElement('script');
14067
- loaderTag.src = 'https://distill.pub/template.v2.js';
14073
+ var loader = (window.alFolioDistill && window.alFolioDistill.templateLoader) || {};
14074
+ var fallbackSrc = ${JSON.stringify(templateSrc)};
14075
+ var loaderTag = document.createElement('script');
14076
+ if (loader.url) {
14077
+ loaderTag.src = loader.url;
14078
+ if (loader.integrity) {
14079
+ loaderTag.integrity = loader.integrity;
14080
+ loaderTag.crossOrigin = 'anonymous';
14081
+ }
14082
+ } else if (fallbackSrc) {
14083
+ loaderTag.src = fallbackSrc;
14084
+ } else {
14085
+ console.error('al_folio_distill: no Distill template URL configured; refusing to load the runtime from a remote origin.');
14086
+ return;
14087
+ }
14068
14088
  document.head.insertBefore(loaderTag, document.head.firstChild);
14069
14089
  });
14070
14090
  `;
@@ -14072,7 +14092,9 @@ window.addEventListener('WebComponentsReady', function() {
14072
14092
  function render(dom) {
14073
14093
  // pull out template script tag
14074
14094
  const templateTag = dom.querySelector('script[src*="template.v2.js"]');
14095
+ let templateSrc = null;
14075
14096
  if (templateTag) {
14097
+ templateSrc = templateTag.getAttribute("src");
14076
14098
  templateTag.parentNode.removeChild(templateTag);
14077
14099
  } else {
14078
14100
  console.debug("FYI: Did not find template tag when trying to remove it. You may not have added it. Be aware that our polyfills will add it.");
@@ -14085,7 +14107,7 @@ window.addEventListener('WebComponentsReady', function() {
14085
14107
 
14086
14108
  // add loader event listener to add tempalrte back in
14087
14109
  const addTag = dom.createElement("script");
14088
- addTag.innerHTML = addBackIn;
14110
+ addTag.innerHTML = addBackIn(templateSrc);
14089
14111
  dom.head.insertBefore(addTag, dom.head.firstChild);
14090
14112
 
14091
14113
  // create polyfill script tag
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AlFolioDistill
4
- VERSION = "1.0.2"
4
+ VERSION = "1.0.3"
5
5
  end
@@ -1,13 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "jekyll"
4
+ require "digest"
5
+ require "json"
4
6
  require_relative "al_folio_distill/version"
5
7
 
6
8
  module AlFolioDistill
7
9
  PLUGIN_ROOT = File.expand_path("..", __dir__)
8
10
  TEMPLATES_ROOT = File.join(PLUGIN_ROOT, "templates")
9
11
  ASSETS_ROOT = File.join(PLUGIN_ROOT, "assets")
12
+ RUNTIME_ROOT = File.join(ASSETS_ROOT, "js", "distillpub")
13
+ RUNTIME_URL_PREFIX = "/assets/js/distillpub"
14
+ PROVENANCE_PATH = File.join(RUNTIME_ROOT, "provenance.json")
10
15
  DISTILL_REMOTE_LOADER_PATTERN = %r{https://distill\.pub/template\.v2\.js}
16
+ DEFAULT_REMOTE_LOADER_URL = "https://distill.pub/template.v2.js"
17
+ RUNTIME_SCRIPTS = ["template.v2.js", "transforms.v2.js"].freeze
18
+ SHA256_HEX_PATTERN = /\A[0-9a-f]{64}\z/.freeze
11
19
 
12
20
  class PluginStaticFile < Jekyll::StaticFile; end
13
21
 
@@ -21,6 +29,51 @@ module AlFolioDistill
21
29
  site.config.dig("al_folio", "distill", "allow_remote_loader") == true
22
30
  end
23
31
 
32
+ def remote_loader_url(site)
33
+ configured = site.config.dig("al_folio", "distill", "remote_loader_url")
34
+ configured.is_a?(String) && !configured.empty? ? configured : DEFAULT_REMOTE_LOADER_URL
35
+ end
36
+
37
+ def remote_loader_integrity(site)
38
+ configured = site.config.dig("al_folio", "distill", "remote_loader_integrity")
39
+ configured if configured.is_a?(String) && !configured.empty?
40
+ end
41
+
42
+ # Committed, known-good digests for the vendored runtime. Refreshed by
43
+ # `scripts/distill/sync_distill.sh`; asserted by the runtime contract test.
44
+ def provenance
45
+ @provenance ||= File.file?(PROVENANCE_PATH) ? JSON.parse(File.read(PROVENANCE_PATH)) : {}
46
+ rescue JSON::ParserError => e
47
+ Jekyll.logger.warn("al_folio_distill:", "could not parse provenance.json (#{e.message}); runtime integrity pinning is disabled")
48
+ @provenance = {}
49
+ end
50
+
51
+ def pinned_digest(asset)
52
+ digest = provenance.dig("assets", asset)
53
+ digest if digest.is_a?(String) && digest.match?(SHA256_HEX_PATTERN)
54
+ end
55
+
56
+ # Subresource Integrity wants base64, provenance.json pins hex. Same bytes.
57
+ def integrity_for(asset)
58
+ digest = pinned_digest(asset)
59
+ digest && "sha256-#{[[digest].pack("H*")].pack("m0")}"
60
+ end
61
+
62
+ def runtime_url(site, asset)
63
+ "#{site.config["baseurl"].to_s.chomp("/")}#{RUNTIME_URL_PREFIX}/#{asset}"
64
+ end
65
+
66
+ # Describes where the Distill runtime may be re-injected from after the
67
+ # polyfill transform strips the original tag. Local + pinned unless a site
68
+ # explicitly opts into a remote origin.
69
+ def template_loader(site)
70
+ if remote_loader_allowed?(site)
71
+ { "url" => remote_loader_url(site), "integrity" => remote_loader_integrity(site), "remote" => true }.compact
72
+ else
73
+ { "url" => runtime_url(site, "template.v2.js"), "integrity" => integrity_for("template.v2.js"), "remote" => false }.compact
74
+ end
75
+ end
76
+
24
77
  class AssetsGenerator < Jekyll::Generator
25
78
  safe true
26
79
  priority :low
@@ -48,9 +101,48 @@ module AlFolioDistill
48
101
  )
49
102
  end
50
103
  end
104
+
105
+ # Emits the vendored Distill runtime with integrity pinned to the committed
106
+ # provenance digests, plus the loader descriptor the patched polyfill
107
+ # transform reads when it re-injects the template tag.
108
+ class RuntimeScriptsTag < Liquid::Tag
109
+ def render(context)
110
+ site = context.registers[:site]
111
+ return "" unless site && AlFolioDistill.enabled?(site)
112
+
113
+ lines = [
114
+ "<script>",
115
+ " window.alFolioDistill = window.alFolioDistill || {};",
116
+ " window.alFolioDistill.templateLoader = #{json_literal(AlFolioDistill.template_loader(site))};",
117
+ "</script>",
118
+ ]
119
+ AlFolioDistill::RUNTIME_SCRIPTS.each do |asset|
120
+ lines << script_tag(AlFolioDistill.runtime_url(site, asset), AlFolioDistill.integrity_for(asset))
121
+ end
122
+ lines.join("\n")
123
+ end
124
+
125
+ private
126
+
127
+ def script_tag(url, integrity)
128
+ attributes = %(src="#{escape(url)}")
129
+ attributes += %( integrity="#{escape(integrity)}") if integrity
130
+ "<script #{attributes}></script>"
131
+ end
132
+
133
+ # Inline JSON must never be able to close the surrounding <script> element.
134
+ def json_literal(value)
135
+ JSON.generate(value).gsub("</", '<\/')
136
+ end
137
+
138
+ def escape(value)
139
+ value.to_s.gsub("&", "&amp;").gsub('"', "&quot;").gsub("<", "&lt;").gsub(">", "&gt;")
140
+ end
141
+ end
51
142
  end
52
143
 
53
144
  Liquid::Template.register_tag("al_folio_distill_render", AlFolioDistill::RenderTag)
145
+ Liquid::Template.register_tag("al_folio_distill_runtime_scripts", AlFolioDistill::RuntimeScriptsTag)
54
146
 
55
147
  Jekyll::Hooks.register :site, :after_init do |site|
56
148
  next unless site.respond_to?(:includes_load_paths)
@@ -61,12 +153,36 @@ end
61
153
 
62
154
  Jekyll::Hooks.register :site, :post_read do |site|
63
155
  next unless AlFolioDistill.enabled?(site)
64
- next if AlFolioDistill.remote_loader_allowed?(site)
65
156
 
66
- transforms_path = File.join(AlFolioDistill::ASSETS_ROOT, "js", "distillpub", "transforms.v2.js")
157
+ # The served bytes must match the digests we pin in the emitted `integrity`
158
+ # attributes, otherwise every Distill page fails SRI in the browser.
159
+ AlFolioDistill::RUNTIME_SCRIPTS.each do |asset|
160
+ pinned = AlFolioDistill.pinned_digest(asset)
161
+ asset_path = File.join(AlFolioDistill::RUNTIME_ROOT, asset)
162
+ next unless pinned && File.file?(asset_path)
163
+ next if Digest::SHA256.file(asset_path).hexdigest == pinned
164
+
165
+ Jekyll.logger.warn(
166
+ "al_folio_distill:",
167
+ "vendored #{asset} does not match the digest pinned in provenance.json; Distill pages will fail subresource integrity"
168
+ )
169
+ end
170
+
171
+ if AlFolioDistill.remote_loader_allowed?(site)
172
+ unless AlFolioDistill.remote_loader_integrity(site)
173
+ Jekyll.logger.warn(
174
+ "al_folio_distill:",
175
+ "`al_folio.distill.allow_remote_loader` is true without `al_folio.distill.remote_loader_integrity`; " \
176
+ "the Distill runtime will be loaded from #{AlFolioDistill.remote_loader_url(site)} without subresource integrity"
177
+ )
178
+ end
179
+ next
180
+ end
181
+
182
+ transforms_path = File.join(AlFolioDistill::RUNTIME_ROOT, "transforms.v2.js")
67
183
  next unless File.file?(transforms_path)
68
184
 
69
- content = File.read(transforms_path)
185
+ content = File.read(transforms_path, encoding: "UTF-8")
70
186
  if content.match?(AlFolioDistill::DISTILL_REMOTE_LOADER_PATTERN)
71
187
  Jekyll.logger.warn("al_folio_distill:", "remote Distill template loader detected while `al_folio.distill.allow_remote_loader` is false")
72
188
  end
@@ -49,6 +49,16 @@ TEMPLATE_SHA256="$(shasum -a 256 "${OUT_DIR}/template.v2.js" | awk '{print $1}')
49
49
  TRANSFORMS_SHA256="$(shasum -a 256 "${OUT_DIR}/transforms.v2.js" | awk '{print $1}')"
50
50
  SYNCED_AT_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
51
51
 
52
+ # The upstream Polyfills transform re-injects the Distill runtime from a
53
+ # hard-coded remote origin with no subresource integrity, which discards the
54
+ # vendored copy we just pinned. Report the real state instead of assuming it.
55
+ REMOTE_LOADER_URL="https://distill.pub/template.v2.js"
56
+ if grep -qF "${REMOTE_LOADER_URL}" "${OUT_DIR}/transforms.v2.js"; then
57
+ REMOTE_LOADER_PATCHED="false"
58
+ else
59
+ REMOTE_LOADER_PATCHED="true"
60
+ fi
61
+
52
62
  cat > "${OUT_DIR}/provenance.json" <<JSON
53
63
  {
54
64
  "upstream_repo": "${UPSTREAM_REPO}",
@@ -59,7 +69,11 @@ cat > "${OUT_DIR}/provenance.json" <<JSON
59
69
  "toolchain": {
60
70
  "sync_mode": "copy-dist-artifacts"
61
71
  },
62
- "remote_loader_patched": false,
72
+ "remote_loader_patched": ${REMOTE_LOADER_PATCHED},
73
+ "local_patches": [
74
+ "overrides.js: de-jQuery the load handler (\$(window).on('load', ...) -> window.addEventListener('load', ...)); jQuery is not loaded in al-folio v1. Port this upstream so the next sync preserves it.",
75
+ "transforms.v2.js: the Polyfills transform re-injected the runtime from a hard-coded remote distill.pub template URL with no SRI, discarding the vendored copy. It now re-injects window.alFolioDistill.templateLoader (vendored + integrity-pinned by default), falling back to the same-origin src of the tag it removed. Port this upstream so the next sync preserves it."
76
+ ],
63
77
  "assets": {
64
78
  "template.v2.js": "${TEMPLATE_SHA256}",
65
79
  "transforms.v2.js": "${TRANSFORMS_SHA256}",
@@ -70,3 +84,17 @@ JSON
70
84
 
71
85
  echo "Synced Distill runtime from ${UPSTREAM_REPO}@${SOURCE_COMMIT}"
72
86
  echo "Updated assets in ${OUT_DIR}"
87
+
88
+ if [ "${REMOTE_LOADER_PATCHED}" = "false" ]; then
89
+ cat >&2 <<MSG
90
+
91
+ ERROR: ${OUT_DIR}/transforms.v2.js still hard-codes ${REMOTE_LOADER_URL}.
92
+
93
+ The synced ref reintroduced the unpinned remote Distill loader, so Distill pages
94
+ would fetch the runtime from a third-party origin with no subresource integrity
95
+ instead of the vendored copy. Land the loader patch listed under "local_patches"
96
+ in ${UPSTREAM_REPO} (${UPSTREAM_BRANCH}) and re-sync, or re-apply it by hand and
97
+ refresh the digests in provenance.json.
98
+ MSG
99
+ exit 1
100
+ fi
@@ -4,9 +4,8 @@
4
4
  {% include head.liquid %}
5
5
  <link rel="stylesheet" href="{{ '/assets/css/al-folio-distill.css' | relative_url | bust_file_cache }}">
6
6
 
7
- <!-- Distill js -->
8
- <script src="{{ '/assets/js/distillpub/template.v2.js' | relative_url }}"></script>
9
- <script src="{{ '/assets/js/distillpub/transforms.v2.js' | relative_url }}"></script>
7
+ <!-- Distill js (vendored + integrity-pinned; see assets/js/distillpub/provenance.json) -->
8
+ {% al_folio_distill_runtime_scripts %}
10
9
  {% if page._styles %}
11
10
  <!-- Page/Post style -->
12
11
  <style type="text/css">
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: al_folio_distill
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.2
4
+ version: 1.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - al-folio maintainers
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: jekyll
@@ -127,6 +128,7 @@ metadata:
127
128
  allowed_push_host: https://rubygems.org
128
129
  homepage_uri: https://github.com/al-org-dev/al-folio-distill
129
130
  source_code_uri: https://github.com/al-org-dev/al-folio-distill
131
+ post_install_message:
130
132
  rdoc_options: []
131
133
  require_paths:
132
134
  - lib
@@ -141,7 +143,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
143
  - !ruby/object:Gem::Version
142
144
  version: '0'
143
145
  requirements: []
144
- rubygems_version: 4.0.6
146
+ rubygems_version: 3.5.22
147
+ signing_key:
145
148
  specification_version: 4
146
149
  summary: Distill rendering plugin for al-folio v1.x
147
150
  test_files: []