Skip to content

fix: use config service for head.html, rewrite its CSP - #276

Merged
benpeter merged 80 commits into
mainfrom
headue
Aug 26, 2026
Merged

fix: use config service for head.html, rewrite its CSP#276
benpeter merged 80 commits into
mainfrom
headue

Conversation

@benpeter

@benpeter benpeter commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Description

Reapplies #270 (reverted in #275) + the CSP rewrite that makes it safe.

#270 got head.html from the config service instead of the preview host, so CSP-metas with 'nonce-aem' made it to the page and were enforced -> editor could not instrument the page loaded to the canvas

da-universal now rewrites the CSP when it builds the page

before

now

  • one read of the config service answers existence, head.html and the store
  • create a nonce per request, replace 'nonce-aem' in the policy and on head.html' elements, put it on the scripts we inject (UE scaffold and quick-edit import map)
  • policy stays a <meta> and is not moved to a response header, so frame-ancestors, sandbox and report-uri stay unused. (frame-ancestors 'self' prevent the page loading in the editor)
  • require-trusted-types-for is dropped from the policy. neither a nonce nor the editor's DOM writes can satisfy it.
  • a policy with no 'nonce-aem' is left as is
  • isUE also recognises the configured local host to make the editor path reachable in local development

the worker names the upstream that failed

The worker answers 503 with Retry-After and an x-error naming the upstream that failed: preview host, content store, editor config or site lookup. The worker answers a bodyless 500 for a throw in its own tree work. Tests pin both.

  • the worker names the upstream for three reads that previously produced a bodyless 500: the metadata sheet read while it builds the page, the document body read from the store, and the proxy read behind .css, .js, .json, font and .plain.html requests
  • the worker puts a 5s deadline on the metadata sheet read, matching the site lookup, so a preview host holding a connection open no longer holds the request
  • the worker resolves the site before it reads and parses the document, so a write to an unknown site costs one round trip instead of a full parse; both 415 checks still precede the 404
  • the worker answers 415 for a document that parses without a body element, instead of throwing
  • the worker logs whether HLX_CONFIG_SERVICE_TOKEN was set when the config service answers 401 or 403, separating a rotated token from an outage
  • the worker logs the status that refused a site token exchange, separating it from a site needing no site token

local development

The stand-in in dev/ serves a head.html with the policy and the placeholders, holds one site per store kind, and answers 401 without a token, matching the config service. A local run therefore exercises the nonce rewrite, the trusted-types strip and both store branches.

tested

Unit — 495 tests. src/render/csp.js, src/routes/da-admin.js, src/storage/site.js, src/routes/cookie.js and src/utils/upstream.js at 100% statements; 94.17% over all files.

Live on stage — 55 checks over HTTP, 0 failures, site behind Helix auth for the run.

The preview host answered 401 for all three direct reads without a site token: /index.html, styles.css, component-definition.json. The worker answered 200 on the page it builds, in the same run, with all four href/src values declared by head.html present. head.html source before #270: preview host. After: config service.

One nonce per request on the policy and on all six head scripts, four of them injected by the editor. Second request gives a different value. 'nonce-aem' gone. require-trusted-types-for and move-to-http-header declared at source, absent from the served policy, meta kept. No CSP response header. Quick-edit import map injected first in the document, same nonce. Plain preview host not UE-instrumented, policy still rewritten.

200 without policy and nonce for a non-existing branch — the headHtml ?? '' path.

404 with its own body for an unknown site, and a bare .mp4 on it is a 404 with no HTML. POST refused in order: non-html extension, non-html part content-type and missing data part each 415 ahead of the site lookup, lookup itself 404, none with a Retry-After. All eleven TRUSTED_ORIGINS entries and near-misses landed on the correct side, including the experience-stage.adobe.com entry this PR adds. da:401 recovery shell served, and HEAD without authorization is a bodyless 401 instead of the shell.

In the editor — the canvas loads the page and the editor initialises.

scripts/aem.js and scripts/scripts.js have the minted nonce and execute. They then request the block CSS and JS, nav.plain.html, footer.plain.html and delayed.js — requests the browser only makes if it ran the two scripts, so it accepted the nonce. POST /configuration answers 200 with the page url. The editor reads component-definition.json, component-models.json and component-filters.json, and the component tree fills.

Console: no Refused to execute, no Refused to load, no violates. One notice:

The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element.

The notice states the behaviour the rewrite depends on. applyCsp leaves the policy in a <meta> instead of moving it to a response header, so the browser drops frame-ancestors and the editor frames the page.

Open — browser only: editor boot and component tree, CSP violations in console, frame-ancestors not enforced from a meta, nonce accepted instead of only present in the bytes. Two HTTP checks also open: 405 for a source-bus write, and 415 for a body-less document, the only check that runs past a successful lookup.

Quick-edithttps://{branch}--da-live--adobe.aem.page/canvas#/{org}/{site}/{path} points the canvas at stage-preview.da.live and signs in against stage IMS.

The canvas edits and saves. Console: no Refused to execute, no Refused to load, no Failed to resolve module. The import map resolves da-lit and da-y-wrapper from da.live. The bootstrap the worker appends to scripts/scripts.js runs and calls addImportmap; the browser keeps the map the worker injected first and drops the duplicate rules:

An import map rule for specifier 'da-lit' was removed, as it conflicted with an existing rule.

A save leaves no nonce in the stored document.

…s the 503

red. head.html still decides whether a site exists, so a site not yet previewed
on the requested ref is refused at 404 with its document already read and
discarded. an upstream that cannot be reached still reports the failure in a
return value, and a preview host that throws still reaches the browser as a 500
with no body.

the full suite aborts on load, because src/storage/site.js does not exist yet.
…ce means

head.html decided whether a site exists, so a site not yet previewed on the
requested ref was refused at 404 with its document already read and discarded.
config.aem.page answers both questions in one read: whether there is such a
site, and which store holds its content. content.source.url is the field
helix-admin sets x-api-upgrade-available from, so it is the boolean /ping
conveyed. the /ping probe and HLX_ADMIN are gone.

three upstreams reported a failure by returning a value, and two swallowed one.
reach() names the upstream and rethrows a typed UpstreamError, and each route
entry point catches once and builds the 503 its method takes. x-error names the
upstream every time, not only on the site lookup.

a 401 or 403 cannot be retried, so it is not a 503. neither is a 404. the
preview host and the config store fall back on all three and log the refusal,
and only a status that means no answer becomes an UpstreamError. a throw that
is not an UpstreamError reaches the worker boundary, which logs it and answers
500.

Relates to #258
…repo pins

HLX_ADMIN goes out of all three environments: its only reader was the deleted
/ping probe. dev points at dev/config-shim.js on 4713, which stands in for
config.aem.page so the worker runs locally without the shared secret, and
.gitignore gains the glob because npm start runs --env dev and wrangler reads
.dev.vars.dev before .dev.vars.

the deploy job had no npm ci, so wrangler-action installed its own default,
3.90.0, which has no secrets key and would deploy past a missing
HLX_CONFIG_SERVICE_TOKEN. da-admin and da-collab install and run npm run deploy
instead of using the action; this matches them. node 24, above the floor
wrangler and miniflare declare.
…aration

v3 installs its own wrangler 3.90.0, which has no secrets key in its config
schema, so `secrets = { required = [...] }` is ignored and a deploy without
HLX_CONFIG_SERVICE_TOKEN goes out green. v4 defaults to wrangler 4.

same two lines as #214.
…nd the head

red. the head arrives with the existence answer, so one pipeline read replaces the admin scope, and the source-bus flag comes off admin.hlx.page/ping again.
…t exists

green. the pipeline scope answers existence and head.html in one read, so the admin scope goes, and with it the CDN token and api key metadata the worker was reading. a write asks /ping only.
red. header-absent-on-5xx read as legacy, which sends a source-bus write to da-admin where nothing serves it back.
the header is read ahead of the status, so the edge dictionary still answers for an origin that is rate limited. a refusal without it now throws.
red. /ping answers 200 with no header for a source-bus site in exactly that window, since helix-admin reads the same config and swallows the failure.
red. it answered with the store-did-not-answer text, and no store was asked.
the store answer comes from the same config, so an outage that hides one hides the other. a wrong store cannot be walked back from, and the 503 now names the destination as undetermined rather than the store as unreachable.
Wrap the composed page's preview-host read and the store body that arrives after the status so both answer 503 with a body naming the upstream, give the metadata sheet the same deadline the site lookup has, refuse a document parse5 leaves without a body, and resolve the site before a write parses anything.
Run the real compose, csp and UE modules through the route so the nonce applyCsp mints is asserted where it has to land, on the injected UE scripts and the quick-edit import map, with no placeholder left in the response.
Pin the log line an unset HLX_CONFIG_SERVICE_TOKEN owes before the lookup goes out, that the lookup still goes out, that a set token stays quiet, and that a 401 or 403 names whether the token was there.
An unset HLX_CONFIG_SERVICE_TOKEN goes out as the string "undefined" and comes back a refusal that looks exactly like an upstream outage, so log the misconfiguration before the lookup and name the token's presence on a 401 or 403. The lookup still goes out and still fails the same way, since short-circuiting it would change behaviour where only the log was missing, and the token value never reaches either line.
Assert that a policy asking to move to a header keeps its meta in the head, carrying the rewritten nonce and neither move attribute, so the surviving element is covered rather than only the attributes being gone.
The pipeline moves a policy carrying move-to-http-header to a response header and drops the meta, while this branch keeps the meta on purpose, since frame-ancestors delivered as a header would stop the editor framing the page and is ignored in a meta element; note as well that rewriting the head here is what supersedes the preview-host head.html read on UE.
Serve the policy and its placeholders from the stand-in head, add a site on the source bus, and refuse a request that carries no token.
Composing walks the stored document as well as reading the metadata sheet, so pin that a throw from that work still reaches the boundary, and name the deadline test after what it checks.
Wrapping the whole compose step answered a bug in the worker's own tree work as a retryable preview-host outage, so the wrap now sits on the metadata read alone.
The comment named two branch commits that a squash merge would leave unreachable, so it now states the decision itself: the rewrite is what lets the config service answer head.html for the editor, and reading it from the preview host was the alternative it replaced.
A stylesheet, script or sheet the preview host cannot answer reaches the worker boundary as a bodyless 500, where every other upstream read on the branch answers 503 with Retry-After and an x-error naming what failed.
The proxy read now names the preview host, and the two handlers that call it directly answer 503 with Retry-After instead of letting the throw reach the worker boundary as a bodyless 500. The refusal carries no body, since an HTML shell would corrupt a stylesheet or a sheet; the asset paths still fall back through allSettled unchanged.
A site that needs no site token and an exchange the service refused both end with no site token and no log, so an author on an authenticated site loses the token with nothing anywhere saying why.
The non-ok branch folded a 400, a 401, a 404 and any 5xx into the same silent null the public-site answer produces, so the refusal now names the site and the status and the fail-open behaviour is unchanged.
@benpeter benpeter changed the title fix: on UE, read head.html from the preview host fix: reapply the config service lookup, with the composed CSP rewritten Aug 21, 2026
@benpeter

benpeter commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

live test on stage

Branch deployed to stage. Site behind Helix auth for the run.

H="Authorization: Bearer <stage IMS token>"
U=https://main--{site}--{org}.stage-ue.da.live
P=https://main--{site}--{org}.stage-preview.da.live
N=https://main--{nosuchsite}--{org}.stage-ue.da.live
B=https://nobuild--{site}--{org}.stage-ue.da.live

code(){ curl -so/dev/null -w'%{http_code}\n' "$@"; }
size(){ curl -so/dev/null -w'%{http_code} %{size_download}\n' "$@"; }
body(){ curl -s "$@"; }
head_(){ curl -sD- -o/dev/null "$@"; }
printf '<p>x</p>' > x.html
printf '<html><head></head><frameset cols="50%%,50%%"><frame src="/a"></frameset></html>' > f.html

53 pass, 0 fail, 1 tbd.

config service decides

code  -H"$H" $U/index                                        # 1  -> 200
code  -H"$H" $N/index                                        # 2  -> 404
body  -H"$H" $N/index | grep -c 'no site at this address'    # 3  -> 1
code  -I -H"$H" $N/index                                     # 4  -> 404
size  -H"$H" $N/probe.mp4                                    # 5  -> 404 0
code  -I -H"$H" $U/index                                     # 6  -> 200
body  -H"$H" $U/index | grep -c styles.css                   # 7  -> 1
code  -H"$H" $U/index                                        # 8  -> 200   site is behind Helix auth
code  -H"$H" $U/no-such-doc                                  # 9  -> 200
body  -H"$H" $U/no-such-doc | grep -c '<header'              # 10 -> 1
body  -H"$H" $U/no-such-doc | grep -c styles.css             # 11 -> 1
code  -I -H"$H" $U/no-such-doc                               # 12 -> 404
  1. composed page 200
  2. unknown site GET 404
  3. unknown site GET names the site, not the store
  4. unknown site HEAD 404
  5. unknown site .mp4 GET is a bare 404, no HTML body
  6. HEAD on the site 200
  7. head.html content reached the page, 4 of 4 href/src
  8. site behind Helix auth still renders a head
  9. missing document GETs 200 with the starter template
  10. starter template is what came back
  11. missing-document page still carries the head
  12. same path HEADs 404

head.html source before #270: preview host. After: config service. Checks 7 and 8 hold while the preview host answers 401 — see the resource route below.

write refusals

code    -H"$H" -X POST -F 'data=@x.html;type=text/html'        $N/x.json   # 13 -> 415
code    -H"$H" -X POST -F 'data=@x.html;type=application/json' $N/index    # 14 -> 415
code    -H"$H" -X POST -F 'payload=@x.html;type=text/html'     $N/index    # 15 -> 415
code    -H"$H" -X POST -F 'data=@f.html;type=text/html'        $N/index    # 16 -> 404
body    -H"$H" -X POST -F 'data=@f.html;type=text/html'        $N/index | grep -c 'nothing was written'   # 17 -> 1
head_   -H"$H" -X POST -F 'data=@f.html;type=text/html'        $N/index | grep -ci retry-after            # 18 -> 0
  1. non-html extension 415, ahead of the lookup
  2. non-html part content-type 415, ahead of the lookup
  3. POST with no data part 415, ahead of the lookup
  4. unknown site write 404, not sent to a store
  5. refused write says nothing was written
  6. settled write refusal carries no Retry-After

resource route

code -H"$H" $U/styles/styles.css              # 19 -> 401
code -H"$H" $U/component-definition.json      # 20 -> 401
code -H"$H" $N/styles/styles.css              # 21 -> 404
code -H"$H" $U/index.html                     # 22 -> 401
  1. stylesheet answered by the preview host, not by a lookup
  2. component-definition.json answered by the preview host, not by a lookup
  3. stylesheet on an unknown site answered by the preview host, not by a lookup
  4. /index.html is the preview host's copy, not the composed page

da:401 shell

code $U/index                            # 23 -> 401
body $U/index | grep -c 'da:401'         # 24 -> 1
body $U/index | grep -c cors.js          # 25 -> 1
body $U/index | grep -c authorbus        # 26 -> 1
size -I $U/index                         # 27 -> 401 0
  1. no Authorization 401
  2. 401 body carries the da:401 meta
  3. 401 body carries cors.js
  4. 401 body names the authorbus extension
  5. no Authorization on HEAD is a bodyless 401, not the shell

trusted origins

code -H"$H" -H'Origin: https://evil.example' $U/gimme_cookie   # 28 -> 403

for O in https://da.live https://experience.adobe.com https://experience-stage.adobe.com \
         https://localhost.corp.adobe.com:8080 http://localhost:3000 \
         https://main--da-live--adobe.aem.page
do code -H"Origin: $O" $U/gimme_cookie; done                   # 29,31 -> 401 x6, trusted

for O in https://experience-stage.adobe.com.evil.example https://experience-stage.adobe.com:443 \
         https://my-branch--da-live--adobe.aem.page https://main--da-live--adobe.aem.dev \
         http://localhost.evil.example
do code -H"Origin: $O" $U/gimme_cookie; done                   # 29 -> 403 x5, untrusted

body -H"Origin: https://da.live" $U/gimme_cookie                # 30 -> bare 401 body
body -H'Origin: https://evil.example' $U/gimme_cookie           # 30 -> bare 403 body
  1. gimme_cookie refuses an untrusted origin 403
  2. all 11 TRUSTED_ORIGINS entries and near-misses on the correct side
  3. bodies are the bare 401 and the bare 403
  4. experience-stage.adobe.com trusted, the entry this PR adds

A trusted origin reaches the auth check and answers 401 without a bearer. An untrusted one answers 403 before the auth check.

policy rewrite

head_ -H"$H" $U/index | grep -ci '^content-security-policy'                  # 32 -> 0
body  -H"$H" $U/index | grep -ci 'http-equiv="Content-Security-Policy"'      # 33 -> 1
body  -H"$H" $U/index | grep -c "nonce-aem"                                  # 34 -> 0
body  -H"$H" $U/index | grep -o "nonce-[A-Za-z0-9+/]\{24\}" | head -1        # 35,36 -> nonce-<24 chars>
body  -H"$H" $U/index | grep -c require-trusted-types-for                    # 37 -> 0
body  -H"$H" $U/index | grep -c move-to-http-header                          # 38 -> 0
  1. no CSP response header on the composed page
  2. CSP meta present in head
  3. placeholder 'nonce-aem' gone from the policy
  4. policy carries a minted nonce
  5. nonce is 24 base64 chars
  6. require-trusted-types-for declared at source, gone from the served policy
  7. move-*-header attribute gone, meta kept — 33 shows the meta survives

nonce reaches the injected scripts

body -H"$H" $U/index | grep -cE 'cors\.js|component-(definition|models|filters)\.json'   # 39 -> 4
body -H"$H" $U/index | grep -c 'urn:adobe:aue:system:ab'                                 # 40 -> 1
body -H"$H" $U/index | grep -o 'nonce="[A-Za-z0-9+/]\{24\}"' | sort -u                   # 41,42 -> one line
body -H"$H" $U/index | grep -c 'nonce="aem"'                                             # 43 -> 0
body -H"$H" $U/index | grep -o "nonce-[A-Za-z0-9+/]\{24\}" | head -1                     # 44 -> run twice, two values
  1. all four injected editor scripts present
  2. UE instrumentation applied
  3. all four injected scripts carry the script-src nonce
  4. no head script carries a stale nonce
  5. no head script left with nonce="aem"
  6. nonce differs between two requests

One line out of 41 means the policy and all six head scripts share one value: aem.js, scripts.js, cors.js, three component-*.json.

quick-edit

body -H"$H" "$P/index?quick-edit=on" | grep -c 'type="importmap"'                          # 45 -> 1
body -H"$H" "$P/index?quick-edit=on" | grep -o '<script[^>]*>' | head -1                   # 46 -> the importmap tag
body -H"$H" "$P/index?quick-edit=on" | grep -c da-y-wrapper                                # 47 -> 1
body -H"$H" "$P/index?quick-edit=on" | grep -o 'nonce="[A-Za-z0-9+/]\{24\}"' | sort -u     # 48 -> one line
  1. import map injected
  2. import map first in the document
  3. quick-edit imports in the map: da-lit, da-y-wrapper
  4. import map carries the policy nonce

preview host

body -H"$H" $P/index | grep -c 'urn:adobe:aue:system:ab'                 # 49 -> 0
body -H"$H" $P/index | grep -o "nonce-[A-Za-z0-9+/]\{24\}" | head -1     # 50 -> nonce-<24 chars>
  1. plain preview not UE-instrumented
  2. preview page policy also rewritten

branch that does not exist

code -H"$H" $B/index                                     # 51 -> 200
body -H"$H" $B/index | grep -c Content-Security-Policy   # 52 -> 0
body -H"$H" $B/index | grep -c 'nonce='                  # 53 -> 0
  1. 200
  2. no policy in <head>
  3. no nonce on head scripts

tbd

  • 405 for source-bus write
  • 415 for body-less document
  • browser: editor boot and component tree, CSP violations in console, frame-ancestors not enforced from a meta, nonce accepted

@benpeter benpeter changed the title fix: reapply the config service lookup, with the composed CSP rewritten fix: use config service for head.html, rewrite its CSP Aug 22, 2026
@benpeter

benpeter commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

two more:

sourcePost reads no Authorization header before the lookup, so neither check needs a bearer.

S=https://main--{sourcebus-site}--{org}.stage-ue.da.live
U=https://main--{site}--{org}.stage-ue.da.live
printf '<html><head></head><frameset cols="50%%,50%%"><frame src="/a"></frameset></html>' > f.html

curl -sD- -o/dev/null -X POST -F 'data=@f.html;type=text/html' $S/x   # 54 -> 405, allow: GET, HEAD, OPTIONS
curl -s              -X POST -F 'data=@f.html;type=text/html' $S/x   # 54 -> "This site is on the source bus, which
                                                                     #        this proxy only reads. Nothing was
                                                                     #        written, and retrying will not help."
curl -so/dev/null -w'%{http_code}\n' -X POST -F 'data=@f.html;type=text/html' $U/x   # 55 -> 415
  1. source-bus write 405 with an Allow header, refused before getStore
  2. body-less document 415, the one check that runs past the lookup

55 pass, 0 fail.

The config service classifies the store the same way on stage as on production, so a source-bus site under any environment covers 54. The 405 lands before getStore, so the request reaches no store.

@benpeter

Copy link
Copy Markdown
Contributor Author

editor test on stage

The canvas host decides which worker is tested. Open the editor on experience.adobe.com, point it at a stage canvas host, and mint the cookie with a stage token — the stage worker binds to da-admin-stage.

1. open the editor, stage canvas host

https://experience.adobe.com/#/@{org-slug}/aem/editor/canvas/main--{site}--{org}.stage-ue.da.live/index

2. get a stage token

Second tab, https://experience-stage.adobe.com. Copy an Authorization value from any request. Check it:

fetch('https://ims-na1-stg1.adobelogin.com/ims/profile/v1',
      { headers: { Authorization: 'Bearer STAGE_TOKEN' } }).then(r => console.log(r.status))
// 200

3. mint the cookie, in the editor tab console, then reload

fetch('https://main--{site}--{org}.stage-ue.da.live/gimme_cookie', {
  headers: { Authorization: 'Bearer STAGE_TOKEN' },
  credentials: 'include'
}).then(r => r.text()).then(console.log)
// cookie set

result

Canvas loads the page. aem.js and scripts.js have the minted nonce and execute. They then request the block CSS and JS, nav.plain.html, footer.plain.html and delayed.js — requests the browser only makes if it ran the two scripts, so it accepted the nonce.

POST /configuration answers 200 with the page url. Editor reads component-definition.json, component-models.json, component-filters.json. Component tree fills.

Console: no Refused to execute, no Refused to load, no violates. One notice:

The Content Security Policy directive 'frame-ancestors' is ignored when delivered via a <meta> element.

The notice confirms that applyCsp leaves the policy in a <meta> instead of moving it to a response header, so the browser drops frame-ancestors and the editor frames the page.

@benpeter

Copy link
Copy Markdown
Contributor Author

quick-edit test on stage

Open da-live from aem.page, so canvas uses stage-preview.da.live, da-live signs in via stage IMS.

1. open the canvas

https://{branch}--da-live--adobe.aem.page/canvas#/{org}/{site}/{path}

Sign in. Canvas edits and saves.

2. confirm the host runs this branch

printf '<html><head></head><frameset cols="50%,50%"><frame src="/a"></frameset></html>' > f.html
curl -sw'|%{http_code}' -X POST -F 'data=@f.html;type=text/html' \
  https://main--{nosuchsite}--{org}.stage-preview.da.live/index

404 and There is no site at this address come from this branch. main would answer 500

3. check the stored document after a save

fetch('https://main--{site}--{org}.stage-preview.da.live/{path}?quick-edit=on',
      { credentials: 'include' })
  .then(r => r.text())
  .then(t => console.log('nonce in body:', /nonce=/.test(t.split('</head>')[1] || '')))
// false

result

Console: no Refused to execute, no Refused to load, no Failed to resolve module.
import map resolves da-lit and da-y-wrapper from da.live.

bootstrap appended to scripts/scripts.js runs and calls addImportmap. the browser keeps the first injected map and drops duplicate rules:

An import map rule for specifier 'da-lit' was removed, as it conflicted with an existing rule.

no nonce in the stored document after save

@benpeter
benpeter marked this pull request as ready for review August 24, 2026 08:56
@benpeter
benpeter requested a review from andreituicu August 24, 2026 08:56
Comment thread src/responses/index.js Outdated

// a refused stylesheet, script or sheet carries no body: nothing renders CSS, so the HTML shell a
// page gets would only corrupt what the browser is parsing.
export function resource503(error = '') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a get503 and a post503 helper can't these be used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get503 and post503 have a body and here we're sending a 503 for a resource that couldn't be fetched. I don't think sending a body makes sense here.
resource503 and head503 do the same, I'll consolidate that

Comment thread src/utils/constants.js
export const PREVIEW_FAILED_HTML_MESSAGE = '<html><body><h1>503: Preview host failed</h1><p>The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.</p></body></html>';

export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry.';
export const EDITOR_CONFIG_FAILED_HTML_MESSAGE = '<html><body><h1>503: Editor config failed</h1><p>The editor configuration for this site could not be read. Please retry, or contact your project admin if it persists.</p></body></html>';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parts of them have been here before, but should we maybe align on either sending only HTML messages or only text messages?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would need changes in UE though.
for GETs the message is what users see in the browser instead of whatever failed, so HTML seems right
for POSTs, UE embeds the message in problem+json so it needs to be plain-text. or at least if it's HTML we'd show HTML markup to the human in front of the screen

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok aggreed

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@benpeter
benpeter merged commit 804c112 into main Aug 26, 2026
3 checks passed
@benpeter
benpeter deleted the headue branch August 26, 2026 07:15
@benpeter

Copy link
Copy Markdown
Contributor Author

live test on production

H="Authorization: Bearer <prod IMS token>"
U=https://main--{site}--{org}.ue.da.live             # legacy DA site
S=https://main--{sourcebussite}--{org}.ue.da.live    # source-bus site
N=https://main--{nosuchsite}--{org}.ue.da.live       # unknown to the config service
B=https://nobuild--{site}--{org}.ue.da.live          # ref never previewed
A=https://main--{site}--{org}.aem.page               # the preview host
P=/da-ue-livetest-276

code(){ curl -so/dev/null -w'%{http_code}\n' "$@"; }
size(){ curl -so/dev/null -w'%{http_code} %{size_download}\n' "$@"; }
body(){ curl -s "$@"; }
head_(){ curl -sD- -o/dev/null "$@"; }
printf '<body><header></header><main><div><p>livetest</p></div></main><footer></footer></body>' > ok.html
printf '<html><head></head><frameset cols="50%%,50%%"><frame src="/a"></frameset></html>' > f.html

33 pass, 0 fail.

the deployed worker runs this change

body -X POST -F 'data=@f.html;type=text/html' $N/index | grep -c 'no site at this address'   # 1 -> 1
for h in ue stage-ue preview stage-preview; do
  body -X POST -F 'data=@f.html;type=text/html' https://main--{nosuchsite}--{org}.$h.da.live/index
done                                                                                          # 2 -> same body x4
  1. the refusal is this change's, not Unable to retrieve AEM branch
  2. all four tiers run it

config service decides

code  -H"$H" $U/index                                    # 3  -> 200
body  -H"$H" $U/index | grep -c styles.css               # 4  -> 1
code  -H"$H" $N/index                                    # 5  -> 404
body  -H"$H" $N/index | grep -c '404: Site not found'    # 6  -> 1
code  -H"$H" $B/index                                    # 7  -> 200
body  -H"$H" $B/index | grep -c 'nonce-'                 # 8  -> 0
  1. composed page 200
  2. head.html content reached the page
  3. unknown site 404
  4. and the body names the site, not the store
  5. a ref that was never previewed still composes
  6. it carries no head.html, so no policy and no nonces

CSP

body  -H"$H" $U/index | grep -o 'nonce="[^"]*"' | sort -u | wc -l           # 9  -> 1
body  -H"$H" $U/index | grep -o '<meta[^>]*Content-Security-Policy[^>]*>'   # 10 -> same nonce as 9
body  -H"$H" $U/index | grep -c 'nonce-aem'                                 # 11 -> 0
head_ -H"$H" $U/index | grep -ic '^content-security-policy'                 # 12 -> 0
head_ $A/head.html    | grep -ic '^content-security-policy'                 # 13 -> 1
  1. one nonce for the page, on all six script tags
  2. the meta carries that same nonce
  3. no nonce-aem anywhere
  4. no CSP response header on the composed page
  5. the preview host still sends the policy as a header

writes

code  -H"$H" -X POST -F 'data=@f.html;type=text/html'  $N/x.json                # 14 -> 415
code  -H"$H" -X POST -F 'data=@f.html;type=text/html'  $N/x.html                # 15 -> 404
code  -H"$H" -X POST -F 'data=@f.html;type=text/html'  $S$P                     # 16 -> 405
head_        -X POST -F 'data=@f.html;type=text/html'  $S$P | grep -i '^allow:' # 17 -> allow: GET, HEAD, OPTIONS
code  -I -H"$H" $S$P                                                            # 18 -> 404
code  -H"$H" -X POST -F 'data=@ok.html;type=text/html' $U$P.html                # 19 -> 201
body  -H"$H" $U$P | grep -c livetest                                            # 20 -> 1
code  -H"$H" https://admin.da.live/source/{org}/{site}$P.html                    # 21 -> 200
code  -H"$H" -X DELETE https://admin.da.live/source/{org}/{site}$P.html          # 22 -> 204
  1. .json refused 415, before any lookup
  2. unknown site refused 404, no store asked
  3. source-bus site refused 405
  4. with the methods it does take
  5. nothing was written there
  6. legacy site write 201
  7. read back through the worker
  8. and from DA directly
  9. scratch document removed

auth

code  -H"$H" -H'Origin: https://experience.adobe.com' $U/gimme_cookie   # 23 -> 200
head_ -H"$H" -H'Origin: https://experience.adobe.com' $U/gimme_cookie \
      | grep -io 'set-cookie: [a-z_]*'                                  # 24 -> auth_token
code  $U/gimme_cookie                                                   # 25 -> 403
code  -H'Origin: https://experience.adobe.com' $U/gimme_cookie          # 26 -> 401
code  $U/index                                                          # 27 -> 401
body  $U/index | grep -c 'da:401'                                       # 28 -> 1
body  $U/index | grep -c cors.js                                        # 29 -> 1
body  $U/index | grep -c authorbus                                      # 30 -> 1
size  -I $U/index                                                       # 31 -> 401 0
  1. trusted origin with a token 200
  2. one cookie, auth_token. the site needs no site token
  3. no Origin 403
  4. trusted origin, no token 401
  5. no Authorization on GET 401
  6. the 401 body carries the da:401 meta
  7. and cors.js
  8. and the authorbus extension
  9. no Authorization on HEAD is a bodyless 401

preview host passthrough

code -H"$H" $U/index.html   # 32 -> 404
code        $A/index.html   # 33 -> 404
  1. .html proxies to the preview host
  2. which answers the same, so the 404 is the site's state and not the worker's

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants