fix(core): re-host non-http(s) CORS iframe URLs instead of failing upload#2294
Conversation
…s (PER-8604, PER-8608) PER-8608 (CWE-829) — every third-party action across all 11 workflows was pinned to a mutable tag, allowing a hijacked/retagged action to inject code into CI (which handles signing keys and publish tokens). Pin every `uses:` to an immutable 40-char commit SHA (tag preserved in a trailing comment): checkout, setup-node, cache, upload-artifact, download-artifact, stale, github-script, action-regex-match, pull-request-comment-branch, gha-jobid-action, winterjung/split, trigger-workflow-and-wait, create-pull-request. PER-8604 (CWE-732) — workflows ran with the implicit write-all GITHUB_TOKEN. Add a top-level `permissions: contents: read` to every workflow and minimal job-level grants only where required: - executable.yml (build, notify): contents: write — upload release assets - stale.yml: issues: write, pull-requests: write - sdk-regression.yml: statuses: write + pull-requests: read Also re PER-8610: sdk-regression.yml is issue_comment-triggered but already gates execution on an author-permission check (write/admin collaborators only); the least-privilege block above further limits the token exposed to that flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ilure PR #2121 moved CORS iframe processing into core. For every CORS iframe, processCorsIframesInDomSnapshot() creates an upload resource keyed on the iframe's frameUrl. When an iframe src is a blob: URL (also data:, about:, etc.), that URL is sent to the API as a resource-url. The API rejects any resource URL whose scheme is not http/https (snapshot_service.rb: "Invalid URL, scheme must be http or https"), which fails the entire snapshot upload — a regression from pre-#2121 behavior. Skip CORS iframe entries whose frameUrl is not http(s): no bad resource is created and the iframe HTML is left untouched, so the snapshot uploads successfully instead of hard-failing. Percy cannot fetch/serve blob URLs anyway since they are ephemeral and browser-context specific. Fixes PER-9574 / #2285 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g it Upgrade the previous skip-based fix to preserve the iframe. @percy/dom already handles blob URLs for stylesheets by extracting the content and re-hosting it under a synthetic /__serialized__/ http(s) URL (serialize-cssom.js); the CORS iframe path never applied that pattern. For a non-http(s) frameUrl, mint a synthetic http(s) URL from the captured iframe HTML (buildSyntheticFrameResourceUrl), resolved against the page origin with localhost rewritten to render.percy.local, then use it for both the upload resource and the rewritten iframe src. The blob URL never reaches the API, the snapshot uploads, and the iframe content is preserved and renderable. Frames without a percyElementId (where the src cannot be rewritten) are still skipped so no resource is orphaned. http(s) frames are unchanged. PER-9574 / #2285 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| export function buildSyntheticFrameResourceUrl(rootUrl, percyElementId) { | ||
| if (!percyElementId) return null; | ||
|
|
||
| const path = `/__serialized__/cors-iframe-${encodeURIComponent(percyElementId)}.html`; | ||
| const base = rootUrl && isHttpOrHttpsUrl(rootUrl) ? rootUrl : 'https://render.percy.local'; | ||
|
|
||
| try { | ||
| return rewriteLocalhostURL(new URL(path, base).toString()); | ||
| } catch { | ||
| /* istanbul ignore next: base is always a valid absolute URL, kept as a defensive guard */ | ||
| return rewriteLocalhostURL(`https://render.percy.local${path}`); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Have we tested it? Please attach the logs of the build ran. As I want to know if the resource we are getting fetched properly. As this schema logic is written in snapshot_service. from there this error is coming.
There was a problem hiding this comment.
@prklm10 Tested end-to-end against prod by driving @percy/core with the exact SDK payload that triggers the bug — a domSnapshot whose corsIframes entry has a blob: frameUrl. Ran it twice against the same project: once on pre-fix master, once with this branch. Logs below.
Before the fix (pre-#2294 utils.js) — fails
[percy:core] Encountered an error uploading snapshot: PR2294 blob iframe repro
[percy:core] Error: Invalid URL, scheme must be http or https: blob:http://localhost:8000/3f1c0a2e-9b7d-4e21-9c44-blob-iframe-demo
[percy:core] Failure: Upload snapshot failure
[percy:core] Failure Reason: Some snapshot or component uploads failed.
[percy:core] Finalized build #918: .../builds/51128638
Build state (admin API): state: failed, total-snapshots: 0, failure-reason: no_snapshots. This is the exact error from #2285 — the blob: URL is sent to the API as a resource-url and snapshot_service.rb rejects the non-http(s) scheme, failing the whole snapshot.
With the fix — re-hosted, fetched, and accepted
[percy:core:utils] Re-hosting non-http(s) corsIframes frameUrl blob:http://localhost:8000/3f1c0a2e-... as http://render.percy.local/__serialized__/cors-iframe-percy-blob-iframe-1.html
[percy:core:discovery] Handling request: http://render.percy.local/__serialized__/cors-iframe-percy-blob-iframe-1.html
[percy:client] Uploading 312B resource: http://render.percy.local/__serialized__/cors-iframe-percy-blob-iframe-1.html
[percy:client] Uploaded resource http://render.percy.local/__serialized__/cors-iframe-percy-blob-iframe-1.html
[percy:client] Resources uploaded: PR2294 blob iframe repro...
[percy:client] Finalized snapshot: PR2294 blob iframe repro...
[percy:core] Finalized build #919: .../builds/51128663
Build state (admin API): state: finished, total-snapshots: 1, failure-reason: none. No Invalid URL error.
To your specific question — yes, the resource is fetched properly: the [percy:core:discovery] Handling request: …/__serialized__/cors-iframe-…html line shows discovery serving the re-hosted iframe content, and [percy:client] Uploaded resource …/__serialized__/…html confirms it passed snapshot_service's http(s) scheme validation. The blob: URL never reaches the API; the captured iframe HTML is preserved and renderable under the synthetic /__serialized__/ http URL (same pattern @percy/dom uses for blob stylesheets).
Claude Code PR ReviewPR: #2294 • Head: f53f510 • Reviewers: correctness/logic + testing/quality (independent fallback reviewers; no in-scope project reviewers found) SummaryFixes a regression (#2285) where a CORS iframe with a non-http(s) Review Table
Findings
Verdict: PASS |
Problem
Percy CLI fails snapshot upload when a page includes an iframe whose
srcis ablob:URL:Regression introduced by #2121, which moved CORS iframe processing into
@percy/core. Reported in #2285 (works on 1.31.7, fails on 1.31.14+).Root cause
processCorsIframesInDomSnapshot()inpackages/core/src/utils.jscreates an upload resource for every CORS iframe, keyed on the iframe'sframeUrl:A
blob:URL is an in-memory handle that only exists in the originating browser context — there is no endpoint to fetch, it is scoped to the creating document, and it is revoked on unload. Percy's renderer (a separate browser) can never resolve it. When the iframesrcisblob:(alsodata:,about:), that URL is sent to the API as aresource-url; the API rejects any non-http(s) scheme (snapshot_service.rb→Invalid URL, scheme must be http or https), failing the entire snapshot upload.Fix
@percy/domalready handles blob URLs correctly for stylesheets (serialize-cssom.js): it extracts the content and re-hosts it under a synthetic/__serialized__/http(s) URL viaresourceFromText. The CORS iframe path never applied that pattern — this PR brings it to parity.For a non-http(s)
frameUrl,buildSyntheticFrameResourceUrl()mints a synthetic http(s) URL from the captured iframe HTML:options.url), withlocalhost/127.0.0.1rewritten torender.percy.local— matching how serialized DOM resources are hosted;/__serialized__/cors-iframe-<percyElementId>.html(percyElementId URL-encoded);urland the rewritten iframesrc, so the renderer serves the captured content exactly as it does an ordinary cross-origin iframe.The blob URL never reaches the API, the snapshot uploads successfully, and the iframe content is preserved and renderable. Frames without a
percyElementId(where thesrccannot be rewritten to the synthetic URL) are skipped so no resource is orphaned. http(s) frames are completely unchanged.Tests
packages/core/test/utils.test.js:isHttpOrHttpsUrl,rewriteLocalhostURL,buildSyntheticFrameResourceUrlhelpers (origin resolution, localhost rewrite, render.percy.local fallback, id encoding, null when no percyElementId)processCorsIframesInDomSnapshot: re-hosts a blob frameUrl (resource + src rewritten, noblob:left), re-hosts data/about against render.percy.local fallback, skips a non-http(s) frame with no percyElementId, mixed http+blob arrayAll 121
utilsspecs and allSnapshot › CORS iframe processingintegration specs pass; lint clean; unreachable defensivecatchmarked withistanbul ignore.Fixes PER-9574 / closes #2285
🤖 Generated with Claude Code