Skip to content

Isolate the Playwright trace viewer from the admin origin - #133

Merged
lewispb merged 10 commits into
mainfrom
security/isolate-trace-viewer
Aug 20, 2026
Merged

Isolate the Playwright trace viewer from the admin origin#133
lewispb merged 10 commits into
mainfrom
security/isolate-trace-viewer

Conversation

@lewispb

@lewispb lewispb commented Aug 20, 2026

Copy link
Copy Markdown
Member

Upright vendored the Playwright Trace Viewer and served it from the same origin as the admin application. A trace is untrusted input and the viewer renders its contents, so a trace could run as script with the admin session.

Details are on the card (H-01).

What changes

Upright no longer ships or serves the viewer. A trace artifact shows a download link and the command to open it locally:

npx playwright show-trace trace.zip

To open traces in a browser, set config.trace_viewer_url to a viewer you host outside config.hostname. Upright links to it in a new tab. Leave it unset and traces stay download-only. A URL under config.hostname raises Upright::ConfigurationError.

Serving a trace to that viewer

The viewer fetches the trace from its own origin, so the request arrives without a session. Upright::TracesController serves it and:

  • Sends Access-Control-Allow-Origin for the configured viewer's origin only.
  • Returns 404 when no viewer is configured.
  • Takes a signed ID in place of the session. The ID is scoped to a purpose, expires after 24 hours, and resolves only to a .zip blob attached to an Upright::ProbeResult.
  • Answers range requests, which the viewer needs to read a ZIP archive.

That is narrower than the Active Storage URL it replaces, whose signed ID names any blob and doesn't expire by default.

Upgrading

Opening a trace in a browser stops working until you set config.trace_viewer_url. The changelog records this.

Tests

340 runs, 788 assertions, no failures. RuboCop and Brakeman are clean.

test/integration/traces_controller_test.rb covers the CORS header, a range request, and four refusals: no viewer configured, an artifact that isn't a trace, a signed ID for another purpose, and a tampered ID.

The card also asks for a test that exercises a crafted trace. There is nothing left to exercise here, because Upright no longer renders trace contents.

🤖 Generated with Claude Code

The engine mounted a second ActionDispatch::Static middleware over
public/trace-viewer and embedded the viewer in an unsandboxed iframe on the
artifact page. The viewer's service worker serialises a trace's tags and
attributes into text/html, so a crafted trace ZIP executed script on the admin
origin, where it could read admin pages and CSRF tokens. The viewer also
accepted arbitrary ?trace= URLs and postMessage blobs from any origin, and its
static responses carried none of the Rails security headers.

Remove the vendored viewer and the static middleware. A trace artifact now
renders a download link and the `npx playwright show-trace` command. Operators
who want to open traces in the browser set config.trace_viewer_url to a viewer
hosted outside config.hostname; Upright links to it in a new tab with
rel="noopener noreferrer". Assigning a URL on the configured hostname, or a
relative one, raises Upright::ConfigurationError, and the check runs again when
the hostname is assigned so initializer order can't skip it.
Copilot AI balanced review requested due to automatic review settings August 20, 2026 07:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Isolates Playwright trace rendering from Upright’s admin origin to prevent crafted traces from executing with admin privileges.

Changes:

  • Removes the vendored trace viewer, static middleware, and synchronization task.
  • Adds download-only trace handling with an optional validated external viewer.
  • Adds configuration, integration tests, fixtures, and operator documentation.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 19 out of 26 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
app/assets/stylesheets/upright/artifact.css Replaces embedded-viewer styling with trace instructions styling.
app/helpers/upright/artifacts_helper.rb Builds external trace-viewer links.
app/views/upright/artifacts/show.html.erb Replaces the iframe with download and external-viewer options.
CHANGELOG.md Documents the security fix and upgrade step.
README.md Documents trace viewing and revised Playwright upgrades.
lib/generators/upright/install/templates/upright.rb Documents the optional viewer configuration.
lib/tasks/upright_tasks.rake Removes the viewer synchronization task.
lib/upright/configuration.rb Adds and validates trace_viewer_url.
lib/upright/engine.rb Stops mounting the engine’s public directory.
public/trace-viewer/codeMirrorModule.DYBRYzYX.css Removes vendored CodeMirror styles.
public/trace-viewer/defaultSettingsView.B4dS75f0.css Removes vendored viewer styles.
public/trace-viewer/index.C5466mMT.js Removes the viewer application bundle.
public/trace-viewer/index.CzXZzn5A.css Removes viewer loader styles.
public/trace-viewer/index.html Removes the viewer entry point.
public/trace-viewer/manifest.webmanifest Removes the viewer manifest.
public/trace-viewer/playwright-logo.svg Removes the viewer logo.
public/trace-viewer/sw.bundle.js Removes the vulnerable service worker.
public/trace-viewer/xtermModule.DYP7pi_n.css Removes vendored terminal styles.
test/fixtures/active_storage/attachments.yml Adds a trace attachment fixture.
test/fixtures/active_storage/blobs.yml Adds a trace blob fixture.
test/integration/artifacts_controller_test.rb Tests download-only, external-link, and 404 behavior.
test/lib/upright/configuration_test.rb Tests viewer URL validation and assignment order.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/upright/configuration.rb Outdated
Comment thread app/helpers/upright/artifacts_helper.rb Outdated
Comment thread lib/upright/engine.rb
Copilot AI review requested due to automatic review settings August 20, 2026 08:02
The check had three raise paths, a rescue that rewrapped URI::InvalidURIError,
and a second call from hostname= to cover initializer order. That is more
machinery than the check earns: isolation comes from no longer shipping a
viewer, so pointing config.trace_viewer_url at Upright's own hostname is an
operator mistake in a file they wrote, not a bypass.

One comparison in the writer, one message. A relative URL no longer raises —
it resolves to a path Upright serves nothing at, so the link 404s instead.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 26 changed files in this pull request and generated no new comments.

Suppressed comments (3)

lib/upright/configuration.rb:205

  • The isolation check is case-sensitive even though DNS hostnames are not. For example, with hostname = "upright.example.com", https://UPRIGHT.EXAMPLE.COM/... passes and the browser still reaches the admin origin. This parser also accepts non-HTTP schemes whenever they expose a host. Normalize both hosts and require an absolute HTTP(S) URI before comparing them.
      port_suffix = Rails.env.local? ? "(:\\d+)?" : ""
      hosts = [ /.*\.#{Regexp.escape(hostname)}#{port_suffix}/, /#{Regexp.escape(hostname)}#{port_suffix}/ ]
      Array(@public_status_custom_domains).each do |domain|
        hosts << /\A#{Regexp.escape(domain)}#{port_suffix}\z/
      end

lib/upright/engine.rb:69

  • Removing this middleware makes the existing smoke test fail on every run: bin/smoke_test:291-301 still requires /trace-viewer/index.html to return 200. Update that step to require 404 and describe the isolation check so deployment validation matches the new behavior.
    app.config.assets.paths << root.join("app/javascript")

app/helpers/upright/artifacts_helper.rb:13

  • Appending ?trace= as text breaks otherwise valid configured viewer URLs that already contain a query or fragment: the viewer receives no trace parameter (or it lands after #). Build the query through URI so the new parameter is merged before any fragment.
    trace = main_app.rails_blob_url(artifact, disposition: :inline, expires_in: 24.hours, host: request.host)
    "#{viewer}?trace=#{CGI.escape(trace)}"

Copilot AI review requested due to automatic review settings August 20, 2026 08:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 26 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

app/helpers/upright/artifacts_helper.rb:13

  • Appending ?trace= as a string breaks configured viewer URLs that already contain a query or fragment: the viewer never receives a trace parameter. Parse the configured URL and merge the parameter into its query instead.
    trace = main_app.rails_blob_url(artifact, disposition: :inline, expires_in: 24.hours, host: request.host)
    "#{viewer}?trace=#{CGI.escape(trace)}"

lib/upright/engine.rb:69

  • Removing this static middleware makes bin/smoke_test fail unconditionally: Step 10 at lines 291-301 still requires /trace-viewer/index.html to return HTTP 200. Update that smoke assertion to require 404 so the deployment check reflects the new security boundary.
    app.config.assets.paths << root.join("app/javascript")

Comment thread lib/upright/configuration.rb Outdated
Comment on lines +104 to +108
def trace_viewer_url=(url)
@trace_viewer_url = url.presence
host = URI(@trace_viewer_url.to_s).host.to_s

if @hostname.present? && (host == @hostname || host.end_with?(".#{@hostname}"))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same thread as above — fixed in 9a4fcf7. Absolute http(s) URL with a host is now required, and the host comparison is case- and trailing-dot-insensitive.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Splitting this thread's two halves, since one is now fixed and one is not.

Fixed in 9a4fcf7: relative and hostless values are refused — an http(s) URL with a host is required, and the host comparison folds case and strips a trailing dot. The check is #admin_domain? now.

Still open, deliberately: re-validating when hostname= is assigned. Leaving the thread unresolved for that point.

A viewer on another origin fetches the trace itself, so handing it an Active
Storage blob URL didn't work: the fetch carries no session, the response carries
no Access-Control-Allow-Origin, and the blob route redirects, which makes every
response in the chain subject to the same check. The link opened the viewer and
the viewer then failed to load anything.

Serve the trace from Upright::TracesController instead. It sends
Access-Control-Allow-Origin for the configured viewer's origin and nothing else,
and 404s outright when no viewer is configured, so the unauthenticated route
exists only where it is used. In place of the session it takes a signed id
scoped to a purpose, expiring after 24 hours, and resolvable only to a blob
attached to an Upright::ProbeResult under a .zip filename.

Range requests are answered through ActiveStorage::Streaming, which the viewer
needs to read a ZIP's central directory without pulling the whole archive, and
Content-Range is exposed so it can read what it got back.
Copilot AI review requested due to automatic review settings August 20, 2026 08:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 22 out of 29 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

app/helpers/upright/artifacts_helper.rb:12

  • Appending a second ? breaks any valid viewer URL that already has a query, and appending after # puts the trace URL in the fragment. In either case the viewer receives no trace parameter. Parse the configured URL and merge the parameter into its query instead.
    "#{viewer}?trace=#{CGI.escape(trace_url_for(artifact))}"

lib/upright/configuration.rb:108

  • This accepts hostless/non-HTTP values such as /trace-viewer: the artifact page then emits an admin-origin link, while trace_viewer_origin returns nil and the signed trace route always 404s. The hostname comparison is also case- and trailing-dot-sensitive, so DNS-equivalent admin hosts bypass the documented rejection. Validate an absolute HTTP(S) URL and compare canonicalized hosts before assigning it.
  def trace_viewer_url=(url)
    @trace_viewer_url = url.presence
    host = URI(@trace_viewer_url.to_s).host.to_s

    if @hostname.present? && (host == @hostname || host.end_with?(".#{@hostname}"))

Comment thread test/integration/artifacts_controller_test.rb
Three fixes from Copilot's review:

bin/smoke_test step 10 still required /trace-viewer/index.html to return 200, so
every smoke run would have failed. It now requires 404, which makes it a
regression check for this change rather than an obsolete step.

The viewer URL took `?trace=` appended to it, which broke any viewer URL
carrying a query of its own and hid the parameter behind a fragment. The
parameter is merged into the query now.

config.trace_viewer_url accepted values with no host, and compared hosts
case-sensitively, so an equivalent spelling of the admin hostname passed the
guard. It now requires an http(s) URL with a host, and compares hosts folded to
lower case with any trailing dot removed.

Not taken: re-checking the URL when hostname= is assigned. The hostname is set
in config/environments/*.rb, which loads before initializers, so the ordering
the review describes doesn't arise in a generated app, and a viewer URL is
inert if it does — nothing is served at the admin origin to render a trace.
Copilot AI review requested due to automatic review settings August 20, 2026 08:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 22 out of 30 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

lib/upright/configuration.rb:108

  • The invalid value is stored before validation. If ConfigurationError is rescued, the configuration remains set to the rejected URL, so a same-admin-origin viewer can still be linked and used afterward. Restore the previous value on failure or validate the candidate before committing it.
  def trace_viewer_url=(url)
    @trace_viewer_url = url.presence

    unless isolated_trace_viewer?
      raise Upright::ConfigurationError, "config.trace_viewer_url must be an http(s) URL outside #{@hostname}"

Comment thread app/helpers/upright/artifacts_helper.rb
ActiveStorage::Streaming#send_blob_stream sets neither Content-Length nor
Accept-Ranges, and the viewer needs both: it sizes the archive from
Content-Length before asking for a range, and won't ask at all without
Accept-Ranges. Large traces loaded badly or not at all.

Sets both on the non-range branch, matching
ActiveStorage::Blobs::ProxyController. Range responses already carried
Content-Range and Accept-Ranges from send_blob_byte_range_data.

Adds a private cache window bounded by the signed id's lifetime rather than the
proxy controller's http_cache_forever, since a trace URL carries its own
authorization and expires. Skips the session with
ActiveStorage::DisableSession, as the proxy controller does.
The viewer's service worker attaches its own request header, which makes even a
GET preflighted. The route answered no OPTIONS, so the preflight 404'd and every
resource fetch behind it failed — a trace opened but its screenshots didn't
load. The route takes OPTIONS now and answers with the methods and headers the
preflight asks about.

config.trace_viewer_url defaults to https://trace.playwright.dev, overridable
with TRACE_VIEWER_URL or by assigning the setting, and nil for download-only
traces. A trace artifact shows the viewer link alone when a viewer is
configured, and the `npx playwright show-trace` command when none is.

Renames the isolation check to #admin_domain?, which says what it asks, and
drops the commentary the method and constant names already carry.
Merging the parameter kept an existing one, and URLSearchParams#get returns the
first value, so a viewer URL configured with ?trace=old opened that trace for
every artifact. The existing entry is dropped before ours is added.
Two defects blocked the viewer, both confirmed against deployed staging.

The preflight returned 422. OPTIONS is not a safe method to Rails, so forgery
protection rejected it before the controller ran, and every fetch behind the
preflight failed. The controller skips forgery protection; there is no session
here to forge against.

Every response was chunked with no Content-Length, including the 206 where
send_blob_byte_range_data sets it explicitly. ActiveStorage::Streaming includes
ActionController::Live, which streams every response from the controller and
drops the header. A HEAD reported Content-Length 0, and the viewer reads that as
an empty archive, so it rendered nothing.

Drops ActiveStorage::Streaming and answers from send_data instead: HEAD reports
the byte size, a ranged GET reports its slice with Content-Range, and a full GET
reports the whole length. Ranges still come from Rack::Utils.get_byte_ranges and
ActiveStorage::Blob#download_chunk, so only the fallback whole-archive read
buffers, and the viewer reaches for ranges once it can see the size.
Comment thread app/controllers/upright/traces_controller.rb Fixed
CodeQL flagged the skip as rb/csrf-protection-disabled, and it was the wrong
tool for the problem. Only the CORS preflight tripped the check: OPTIONS is not
a safe method to Rails, so verification ran and answered it with a 422. Every
real request here is a GET or a HEAD, which Rails treats as safe and never
checks.

The preflight is answered from a prepended callback instead, so it returns
before verification runs and the protection stays in the chain for anything
added later.

The preflight tests now run inside with_forgery_protection. Without it they
passed against a test environment that disables the check, which is why CI was
green while staging returned 422.
@lewispb
lewispb merged commit 17001d1 into main Aug 20, 2026
8 checks passed
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