Skip to content

Warn when a theme app extension contacts origins it doesn't declare (prototype) - #9004

Draft
jplhomer wants to merge 1 commit into
jplhomer/tae-network-access-allowed-sourcesfrom
jplhomer/tae-undeclared-egress-check
Draft

jplhomer wants to merge 1 commit into
jplhomer/tae-network-access-allowed-sourcesfrom
jplhomer/tae-undeclared-egress-check

Conversation

@jplhomer

Copy link
Copy Markdown
Contributor

Note

Prototype, stacked on #9003. The base branch is #9003's branch, so this diff shows only the new check. Draft for discussion. It warns and reports, and blocks nothing.

WHY are these changes introduced?

#9003 lets theme app extensions declare the origins they contact in [extensions.capabilities.network_access] allowed_sources. Nothing tells developers when their extension contacts an origin they didn't declare, or that they haven't declared anything at all. Undeclared origins may be reported now and blocked on storefronts later, so it's worth surfacing them while that's cheap to fix.

WHAT is this pull request doing?

One shared detector: packages/app/src/cli/utilities/extensions/theme-egress.ts. It finds the origins an extension's files contact, classifies each sink by kind, and matches origins with #9003's normalizeAllowedSource. Two surfaces use it:

  1. Build and deploy warning (non-blocking). It runs in the theme build step after theme check. When allowed_sources is declared, it prints one warning per undeclared origin with file:line, then one hint. A single line summarizes URLs built at runtime. When the table isn't declared, it prints one info line with the number of external origins, so existing extensions aren't flooded with warnings. The step is wrapped in try/catch and logs failures only at debug level, so a detector bug can't fail a build or deploy.
  2. App security check THEME_EXTENSION_UNDECLARED_EGRESS (deterministic, version 1, medium, theme app extensions only). With the table declared, there's one finding per undeclared origin at the line where it's used, plus a fix. Without the table, there's one finding per extension on its shopify.extension.toml, listing the origins. A Liquid parser failure marks the check unresolved, the same as LIQUID_UNSAFE_RENDER. It also has a checks/THEME_EXTENSION_UNDECLARED_EGRESS.md agent prompt (precedence: union), which covers what the deterministic rule can't resolve, a regenerated embedded.ts, and catalog and registry entries.

Sink kinds. Only egress is checked and reported. navigation sinks are collected and returned but reported nowhere yet. They'd fit a later check against app-level navigation hosts.

Kind Sinks
egress Liquid/HTML: <script src>, <link href> (stylesheet, preconnect, prefetch, preload, modulepreload, dns-prefetch, icon, manifest), <iframe src>, <img src>, <source src>, the script_tag and stylesheet_tag filters, and inline <script> and <style> bodies. JS assets: fetch(), XMLHttpRequest#open, new WebSocket(), new EventSource(), navigator.sendBeacon(), dynamic import(), new URL() (literal absolute URLs only). CSS assets: url(), @import.
navigation <a href>, <form action>, location.href =, location.assign(), location.replace(), window.open()

Each finding is {file, line, column, kind: 'egress' | 'navigation', status: 'undeclared' | 'dynamic', sink, origin?, insecure?}.

Matching uses the same rules as #9003's validation. An origin is the scheme, host and port. Hosts compare case-insensitively, and the default :443 is dropped. A leading *. matches one or more subdomain labels but not the bare domain, the same as a CSP host source. wss: matches like https:, and the warning suggests the https:// entry. http: and ws: are always reported, because allowed_sources is HTTPS only.

Ignored: relative URLs, non-network schemes (data:, blob:, mailto: and others), Liquid URL filters (asset_url, shopify_asset_url, image_url, file_url and the like), the shop's own URLs (routes.*, shop.*, request.* in Liquid; Shopify.routes and location in JS), and Shopify-owned hosts: cdn.shopify.com, shopify.com and *.myshopify.com.

Dynamic URLs (template interpolation, concatenation, variables, Liquid output) get status: 'dynamic'. They're never reported as undeclared. A literal origin followed by a dynamic path (https://api.example.com/${id}) still counts as that origin.

Known blind spots:

  • JS and CSS are matched with regexes over source with comments masked. The engine has no JS parser for theme assets, and UNSAFE_INNERHTML also uses regexes. Regex literals and strings that look like code can confuse it.
  • Not scanned: srcset, <video>/<audio>/<embed>/<object>, style attributes, Worker/importScripts, @font-face via JS, and requests made inside bundled third-party libraries through computed URLs.
  • URLs that merchants configure in block settings are dynamic by nature.
  • The app security engine only reads .css inside theme extension directories. A small change to discover.ts adds CSS for theme extensions only, so the language inventory and other checks don't change. Like the rest of the engine, it recognizes theme extensions in the flat TOML format (type = "theme" at the top level).

Phase 2. Once storefronts enforce allowed_sources, blocking deploys belongs in the theme specification's preDeployValidation, using the same detector. There's a comment there. This PR adds no blocking errors.

Sample output

shopify app build on a fixture app with a theme extension that declares allowed_sources = ["https://api.example.com"]. The RemoteAsset warnings come from the existing theme check.

egress-demo │ Running theme check on your Theme app extension...
egress-demo │
egress-demo │ [warning]: RemoteAsset
egress-demo │ Asset should be served by the Shopify CDN for better performance.
egress-demo │
egress-demo │ 1  <script src="https://api.example.com/widget.js" defer></script>

egress-demo │
egress-demo │
egress-demo │ [warning]: RemoteAsset
egress-demo │ Asset should be served by the Shopify CDN for better performance.
egress-demo │
egress-demo │ 2  <script src="https://cdn.vendor-analytics.example/tracker.js"
defer></script>
egress-demo │
egress-demo │ [warning]: https://images.example.org (assets/app.css:2) isn't in
allowed_sources.
egress-demo │ [warning]: http://legacy.example.net (assets/app.js:2) uses an
insecure scheme. allowed_sources accepts only HTTPS origins.
egress-demo │ [warning]: wss://realtime.example.org (assets/app.js:3) isn't in
allowed_sources. Declare it as https://realtime.example.org.
egress-demo │ [warning]: https://cdn.vendor-analytics.example
(blocks/app-block.liquid:2) isn't in allowed_sources.
egress-demo │ Add the origins your extension needs to
capabilities.network_access.allowed_sources in shopify.extension.toml. Requests
to undeclared origins may be blocked on storefronts in the future.
egress-demo │ [info]: 1 URL is built at runtime and can't be checked
(assets/app.js:5).
egress-demo │ Bundling theme extension egress-demo...

The same extension without the table:

egress-demo │ [info]: This extension contacts 5 external origins. Consider
declaring them in capabilities.network_access.allowed_sources in
shopify.extension.toml.

In both cases the build succeeds. In the first case, shopify app security check reports the same four origins as THEME_EXTENSION_UNDECLARED_EGRESS findings with file, line and column. The <a href> in the block isn't reported.

How to test your changes?

  1. Add a theme extension with [capabilities.network_access] allowed_sources = ["https://api.example.com"] and a block containing <script src="https://other.example.com/x.js"></script>.
  2. Run shopify app build. You should see one warning for https://other.example.com, and the build still succeeds.
  3. Remove the table and build again. You should see a single info line.
  4. Run shopify app security check and look for THEME_EXTENSION_UNDECLARED_EGRESS.

Tests (packages/app):

  • src/cli/utilities/extensions/theme-egress.test.ts (detector, 44 tests): each sink kind, line and column, declared vs. undeclared, wildcard edge cases (*.cdn.example.com matches a.cdn.example.com and a.b.cdn.example.com but not cdn.example.com or evilcdn.example.com), :443 and other ports, http/ws, wss, Shopify-owned hosts, relative URLs, Liquid URL filters, dynamic URLs, comments, parser failures, and navigation classified separately.
  • src/cli/services/build/steps/build-theme-step.test.ts (5 tests): warnings when declared, one info line when not declared, nothing when everything is declared, navigation not reported, and no failure when the detector throws.
  • src/cli/services/app-security-engine/tests/theme-egress.test.ts (5 tests, scanning real temporary apps): findings when declared, one finding when not declared, no findings when declared, unresolved on a parser failure, and not_applicable without a theme extension. The registry, catalog, checks and deterministic-rules tests are updated for the new ID.
  • vitest run on app-security-engine, utilities/extensions, services/build, models/extensions/specifications, extension-instance, app/loader, dev/extension/payload and services/context: 77 files and 1376 tests. 1375 pass. The other one, in ui_extension.test.ts, timed out under load and passes when rerun alone (60/60).
  • pnpm tsc --noEmit, eslint and prettier --check on the changed files, embed-checks.mjs --check and pnpm knip: all clean.

Open questions

  • Severity and points for the check (medium, -10 for now), and whether "table not declared" should be a finding at all while the policy only reports.
  • Which Shopify hosts to exempt beyond cdn.shopify.com, shopify.com and *.myshopify.com.
  • Whether to report navigation sinks later against app-level navigation hosts.

Post-release steps

Add the theme-extension-undeclared-egress page under the app security checks docs.

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've considered possible documentation changes
  • I've considered analytics changes to measure impact
  • The change is user-facing — I've identified the correct bump type (patch for bug fixes · minor for new features · major for breaking changes) and added a changeset with pnpm changeset add

Add a shared detector that finds the origins a theme app extension loads
from or sends requests to, and compares them with
`[extensions.capabilities.network_access] allowed_sources`.

- The theme build step prints a non-blocking warning for each undeclared
  origin, or one info line when the extension doesn't declare
  allowed_sources.
- A new deterministic app security check,
  THEME_EXTENSION_UNDECLARED_EGRESS (version 1, medium), reports the same
  origins.

Navigation targets (links, form actions, redirects) are classified but
not reported.
@github-actions github-actions Bot added the Area: @shopify/app @shopify/app package issues label Oct 25, 2026
@jplhomer
jplhomer added this pull request to stack #9005 October 26, 2026 00:33

This branch has not been deployed

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

Labels

Area: @shopify/app @shopify/app package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant