Skip to content

Commit 16d580e

Browse files
committed
fix: opt out of edge html transformation
1 parent 89964f7 commit 16d580e

6 files changed

Lines changed: 118 additions & 6 deletions

File tree

defaults/public/_headers

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data:; connect-src 'self' https://api.github.com; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
1010

1111
/
12-
Cache-Control: no-store
12+
Cache-Control: no-store, no-transform
1313

1414
/index.html
15-
Cache-Control: no-store
15+
Cache-Control: no-store, no-transform
1616

1717
/v8s-style.css
1818
Cache-Control: no-store
@@ -26,11 +26,11 @@
2626
Content-Type: text/markdown; charset=utf-8
2727

2828
/lookup/*
29-
Cache-Control: no-store
29+
Cache-Control: no-store, no-transform
3030
X-Robots-Tag: noindex, nofollow
3131

3232
/:lang/_stats/*
33-
Cache-Control: no-store
33+
Cache-Control: no-store, no-transform
3434
X-Robots-Tag: noindex, nofollow
3535
X-Content-Type-Options: nosniff
3636

defaults/public/llms-full.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ The localized `_stats` dashboard exposes routing inventory and should be protect
5252

5353
The default policy rejects risky destinations such as credentialed URLs, non-HTTP(S) protocols, private IP ranges, known shortener chains, phishing lures, and high-risk executable downloads. Instance owners can replace that source policy with `custom/v8s-policies.json` and must not use the redirect engine for phishing, malware, undisclosed tracking, or other harmful activity.
5454

55+
Public HTML responses use strict CSP and `Cache-Control: no-store, no-transform` so Cloudflare edge features do not rewrite or inject JavaScript into repository-owned pages. Keep challenge-style or page-rewriting dashboard controls disabled for public redirect, lookup, and status pages unless the instance deliberately accepts Cloudflare-owned script injection.
56+
5557
## Instance Maintenance
5658

57-
Product defaults live in `defaults/`. Instance-owned files live in `custom/` and are not overwritten automatically during updates. The build copies defaults first, overlays `custom/public` only when copied public files exist, and then generates runtime files.
59+
Product defaults live in `defaults/`. Instance-owned files live in `custom/` and are not overwritten automatically during updates. The build copies defaults first, overlays `custom/public` only when copied public files exist, and then generates runtime files. Product-managed public runtime assets use `v8s-` filenames and should normally stay in `defaults/public/`; custom copies under `custom/public/` shadow the release asset.
5860

59-
Use `npm run doctor` to detect stale copied public assets, stale copied product pages, unsupported copied language folders, stale branding, and missing shared head assets. Use `./scripts/v8s-fix --assets`, `--head-assets`, `--product-pages`, `--languages`, `--branding`, or `--all` to apply explicit maintenance fixes to copied public files.
61+
Use `npm run doctor` to detect managed `v8s-` asset shadows, stale copied product pages, unsupported copied language folders, stale branding, and missing shared head assets. Use `./scripts/v8s-fix --assets`, `--head-assets`, `--product-pages`, `--languages`, `--branding`, or `--all` to apply explicit maintenance fixes to copied public files. The `--assets` fix removes `custom/public/v8s-*` shadows so the build uses the release assets from `defaults/public/`.
6062

6163
## Useful Files
6264

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# 0018. Keep managed public assets in defaults
2+
3+
Date: 2026-06-15
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
The build publishes public files by copying `defaults/public/` first and then overlaying `custom/public/`. That makes
10+
custom HTML pages and instance-owned assets easy to maintain without editing upstream defaults.
11+
12+
Product-owned runtime assets, however, also live in the public surface. Files such as `v8s-style.css`, `v8s-script.js`,
13+
`v8s-lookup.js`, `v8s-stats.js`, `v8s-status.css`, `v8s-tests.js`, and `v8s-theme.js` are part of the release package.
14+
Copying them into `custom/public/` turns them into local shadows. Those shadows can keep serving stale product
15+
JavaScript or CSS after an upgrade, and they make routine upgrades look like instance customization work.
16+
17+
## Decision
18+
19+
Product-managed public runtime assets with `v8s-` filenames stay in `defaults/public/`. Instance repositories should not
20+
copy them into `custom/public/` as part of normal maintenance. The build already includes the default asset from
21+
`defaults/public/` when no custom shadow exists.
22+
23+
`custom/public/` remains the right place for instance-owned HTML, CSS, JavaScript, fonts, images, manifests, and page
24+
replacements. Instance-specific assets should use instance-owned filenames instead of `v8s-` product names.
25+
26+
When `npm run doctor` sees `custom/public/v8s-*`, it treats the file as a managed asset shadow. The recommended
27+
`./scripts/v8s-fix --assets` action removes the shadow so the next build uses the product asset from `defaults/public/`.
28+
29+
If an operator deliberately forks a `v8s-` runtime asset, that file becomes a maintained local fork. The operator should
30+
document the exception in `custom/v8s-custom-overrides.json` with a narrow ignore and accept that upgrades will not
31+
automatically update the fork.
32+
33+
## Consequences
34+
35+
- Product CSS and JavaScript fixes reach existing instances during upgrade without copying files into `custom/public/`
36+
- `custom/public/` stays focused on instance-owned pages and assets
37+
- `v8s-fix --assets` removes stale managed shadows instead of syncing copies from defaults
38+
- Deliberate product-asset forks remain possible, but they are explicit local maintenance decisions
39+
- Custom pages can still reference same-host instance CSS, JavaScript, fonts, images, and manifests under non-product
40+
filenames
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# 0019. Opt out of edge HTML transformation
2+
3+
Date: 2026-06-15
4+
5+
Status: Accepted
6+
7+
## Context
8+
9+
vanityURLs ships strict Content Security Policy headers for product HTML. The default CSP blocks inline scripts and
10+
styles so the generated redirector pages remain deterministic and easy to audit.
11+
12+
Some Cloudflare dashboard features can rewrite or inject content into HTML responses after the Worker or static asset
13+
policy has produced the page. JavaScript Detections, Bot Fight Mode, Managed Challenge, Zaraz, Rocket Loader, Snippets,
14+
and similar features can add scripts or rewrite markup at the edge. That creates a mismatch between repository-owned
15+
HTML/CSP and the final response observed by browsers. In strict-CSP instances, the result is often a browser console
16+
error for an injected inline script.
17+
18+
Cloudflare JavaScript Detections documents its injected script path under `/cdn-cgi/challenge-platform/` and notes that
19+
`Cache-Control: no-transform` prevents that injection on responses where JavaScript Detections would otherwise run.
20+
21+
## Decision
22+
23+
vanityURLs HTML responses opt out of intermediary transformation. The Worker appends `no-transform` to the
24+
`Cache-Control` header for HTML responses, and the static `_headers` fallback uses
25+
`Cache-Control: no-store, no-transform` on HTML routes.
26+
27+
The operator recommendation remains to keep challenge-style or page-rewriting Cloudflare features disabled for public
28+
redirect, lookup, and status HTML unless the instance intentionally accepts Cloudflare-owned script injection.
29+
30+
Do not weaken the default CSP with `unsafe-inline` or Cloudflare-generated hashes just to support dashboard-injected
31+
JavaScript. If an operator wants JavaScript Detections on a separate application surface, that surface should carry its
32+
own policy instead of changing the redirector baseline.
33+
34+
## Consequences
35+
36+
- Public HTML remains closer to the repository-built artifact that operators review and deploy
37+
- Cloudflare JavaScript Detections should not inject its challenge-platform script into vanityURLs HTML responses that
38+
honor `no-transform`
39+
- Strict CSP can remain strict without inline script allowances
40+
- Operators can still enable Cloudflare blocking, rate limiting, Browser Integrity Check, Access, managed AI bot
41+
controls, and WAF rules that do not rewrite public HTML
42+
- Challenge-based controls may still be useful on separate apps or protected admin surfaces, but they are not the
43+
vanityURLs public HTML baseline

scripts/workers/worker.mjs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ async function withSecurityHeaders(response, context) {
7575
headers.set("content-security-policy", CUSTOM_HTML_CONTENT_SECURITY_POLICY);
7676
}
7777

78+
if (isHtmlResponse(response)) {
79+
appendCacheControlDirective(headers, "no-transform");
80+
}
81+
7882
return new Response(response.body, {
7983
status: response.status,
8084
statusText: response.statusText,
@@ -94,6 +98,20 @@ function isHtmlResponse(response) {
9498
return contentType.toLowerCase().startsWith("text/html");
9599
}
96100

101+
function appendCacheControlDirective(headers, directive) {
102+
const current = headers.get("cache-control") || "";
103+
const directives = current
104+
.split(",")
105+
.map((entry) => entry.trim())
106+
.filter(Boolean);
107+
108+
if (!directives.some((entry) => entry.toLowerCase() === directive.toLowerCase())) {
109+
directives.push(directive);
110+
}
111+
112+
if (directives.length) headers.set("cache-control", directives.join(", "));
113+
}
114+
97115
async function loadCustomAssets(request, env) {
98116
customAssetsPromise ||= (async () => {
99117
const response = await fetchAsset(request, env, "/v8s-custom-assets.json");

scripts/workers/worker.test.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,15 @@ await run("applies security headers across response classes and preserves explic
583583
assert(response.headers.get("content-security-policy"), `${name} csp`);
584584
}
585585

586+
const html = await worker.fetch(request("/"), env(), mockCtx());
587+
assert(html.headers.get("cache-control").includes("no-transform"), "html opts out of edge transformation");
588+
589+
const css = await worker.fetch(request("/v8s-style.css"), env(), mockCtx());
590+
assert(
591+
!String(css.headers.get("cache-control") || "").includes("no-transform"),
592+
"non-html cache control is unchanged"
593+
);
594+
586595
const custom = await worker.fetch(request("/custom-header.html"), env(), mockCtx());
587596
assert(custom.headers.get("content-security-policy") === "default-src 'none'", "explicit csp preserved");
588597
assert(custom.headers.get("x-frame-options") === "DENY", "other security headers still added");

0 commit comments

Comments
 (0)