diff --git a/.changeset/bright-app-doctor-checks.md b/.changeset/bright-app-doctor-checks.md new file mode 100644 index 00000000000..50caafa786c --- /dev/null +++ b/.changeset/bright-app-doctor-checks.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Add App Doctor agent checks for lifecycle replay, dependency reachability, and active upload previews. diff --git a/packages/app/src/cli/services/app-doctor-engine/checks/ACTIVE_UPLOADS_AND_PRIVILEGED_PREVIEWS.md b/packages/app/src/cli/services/app-doctor-engine/checks/ACTIVE_UPLOADS_AND_PRIVILEGED_PREVIEWS.md new file mode 100644 index 00000000000..d2a1b77f5cd --- /dev/null +++ b/packages/app/src/cli/services/app-doctor-engine/checks/ACTIVE_UPLOADS_AND_PRIVILEGED_PREVIEWS.md @@ -0,0 +1,75 @@ +--- +id: ACTIVE_UPLOADS_AND_PRIVILEGED_PREVIEWS +version: 1 +severity: high +--- + +# Active Uploads And Privileged Previews + +Find cases where merchant-, customer-, webhook-, or external-service-supplied +files become active content in a privileged origin. Trace uploads, imports, +previews, and generated assets from ingestion through storage and final render. + +The risk is not the upload alone. The risk is an untrusted-upload-to-active-render +path: SVG, HTML, XML, PDF, blob/data URL, or another active format is accepted and +later rendered in a storefront, embedded admin, customer-account, theme-editor, +or operator/admin context where it can execute or leak protected data. + +## What to look for + +1. **Find upload and import entry points.** Search for file uploads, import jobs, + webhook attachments, remote fetches, document parsers, blob/data URL handling, + and generated preview endpoints. + +2. **Trace file metadata and validation.** Check size limits, extension checks, + declared MIME type, magic-byte/file-signature verification, filename handling, + generated storage names, antivirus/sanitization, and any image/PDF re-encoding. + +3. **Inspect storage and serving boundaries.** Determine whether the object is + stored on a non-executable origin, served with explicit `Content-Type` and + `Content-Disposition`, and prevented from inheriting privileged cookies or + browser authority. + +4. **Follow every final renderer.** Check storefront/theme renderers, embedded + admin previews, customer-account views, email/PDF previews, admin/operator + tools, iframe/srcdoc/blob/data URL renderers, and any browser code that inserts + the uploaded content into the DOM. + +5. **Check sandboxing and isolation.** Verify iframes, preview origins, CSP, + download headers, SVG sanitization, PDF handling, and re-encoding before + deciding the content is safe. + +## What to report + +Report a finding only for a complete untrusted-upload-to-active-render path where +the uploaded or imported object is actually rendered or served into a privileged +executable context. Show: +- who controls the uploaded/imported content; +- which validation or isolation boundary is missing; +- where the content becomes active or executable; +- which privileged origin or user is affected; and +- file/line evidence for both the ingest path and the renderer/serving path. + +Example: + +```json +{ + "file": "app/controllers/previews_controller.rb", + "line": 28, + "message": "Uploaded SVG is rendered inline in the admin preview without sanitization or origin isolation", + "evidence": [ + { "file": "app/controllers/uploads_controller.rb", "line": 14, "quote": "params[:file]" }, + { "file": "app/controllers/previews_controller.rb", "line": 28, "quote": "render inline: blob.download" } + ], + "confidence": "high", + "reasoning": "The merchant-controlled SVG is stored without re-encoding and later rendered inline in the embedded admin origin, so script-capable SVG content can execute with merchant authority." +} +``` + +Do not report: +- files that are forced to download and never rendered in an active origin; +- images/PDFs that are re-encoded or sanitized before serving; +- isolated preview origins with no privileged cookies, storage, or message bridge; +- missing deployment details where you cannot establish executable rendering or unsafe serving. +- permissive content types, inline disposition, or storage/header hygiene issues + without a concrete privileged renderer or execution surface. diff --git a/packages/app/src/cli/services/app-doctor-engine/checks/DEPENDENCY_REACHABILITY.md b/packages/app/src/cli/services/app-doctor-engine/checks/DEPENDENCY_REACHABILITY.md new file mode 100644 index 00000000000..ba090b574f3 --- /dev/null +++ b/packages/app/src/cli/services/app-doctor-engine/checks/DEPENDENCY_REACHABILITY.md @@ -0,0 +1,67 @@ +--- +id: DEPENDENCY_REACHABILITY +version: 1 +severity: medium +--- + +# Dependency Reachability + +Start from a static dependency or SDK finding and determine whether the app is +actually exposed to the vulnerable behavior. A vulnerable package version is not +enough by itself: confirm that the app uses the vulnerable API or helper, enables +the affected configuration, and exposes the relevant trust boundary. + +## What to look for + +1. **Identify the vulnerable version and advisory scope.** Read the manifest, + lockfile, or deterministic finding and determine the exact package, version, + vulnerable range, and affected API/helper/configuration from the advisory or + shipped Shopify SDK behavior. + +2. **Find imports and call sites.** Search for direct imports, wrapper helpers, + generated clients, middleware, framework adapters, or transitive call paths + that reach the vulnerable API or helper. + +3. **Check configuration and feature gates.** Some vulnerabilities only apply + when a flag, transport, parser mode, canonicalization shape, or optional + feature is enabled. Verify the app actually enables the affected path. + +4. **Trace the reachable impact.** Confirm which untrusted input can reach the + vulnerable dependency behavior and what authority or data is exposed if the + bug triggers. + +5. **Distinguish version exposure from exploitability.** If the vulnerable + package is present but the app never calls the affected API/helper, or the + vulnerable configuration is disabled, keep the result unresolved rather than + reporting a finding. + +## What to report + +Report a finding only when you can show: +- the vulnerable package or Shopify SDK version; +- the vulnerable API or helper in use; +- the enabling configuration or call shape; +- the untrusted input or trigger; and +- the resulting security impact. + +Example: + +```json +{ + "file": "app/services/session_verifier.ts", + "line": 18, + "message": "App uses vulnerable session-token helper without issuer validation", + "evidence": [ + { "file": "package.json", "line": 12, "quote": "\"@shopify/shopify-app-remix\": \"x.y.z\"" }, + { "file": "app/services/session_verifier.ts", "line": 18, "quote": "verifySessionToken(token)" } + ], + "confidence": "high", + "reasoning": "The installed SDK version contains the vulnerable helper implementation and the app calls that helper on attacker-controlled session tokens without an issuer check." +} +``` + +Do not report: +- a vulnerable version with no reachable use of the affected API/helper; +- dev-only tooling or test-only dependencies that cannot affect production; +- advisories whose required configuration is not enabled in this app; +- guessed exploitability when the call path or trigger cannot be established from source. diff --git a/packages/app/src/cli/services/app-doctor-engine/checks/SESSION_LIFECYCLE_AND_REPLAY.md b/packages/app/src/cli/services/app-doctor-engine/checks/SESSION_LIFECYCLE_AND_REPLAY.md new file mode 100644 index 00000000000..c12cc69e789 --- /dev/null +++ b/packages/app/src/cli/services/app-doctor-engine/checks/SESSION_LIFECYCLE_AND_REPLAY.md @@ -0,0 +1,72 @@ +--- +id: SESSION_LIFECYCLE_AND_REPLAY +version: 1 +severity: high +--- + +# Session Lifecycle And Replay + +Find security-sensitive lifecycle gaps where a session, token, signed URL, +capability, or state-changing endpoint remains usable after the app should have +invalidated it, or where a request can be replayed to repeat a privileged action. + +Focus on stale sessions after logout or uninstall, role/permission downgrades, +replayable order or redemption endpoints, and cookie-authenticated state changes +whose protection disappears outside the happy path. + +## What to look for + +1. **Map the lifecycle boundaries.** Identify login, token issuance, refresh, + logout, uninstall, revocation, account disconnect, role change, and feature + disablement flows. Find where sessions, refresh tokens, signed links, and + capability records are created, rotated, and deleted. + +2. **Find replayable sensitive actions.** Search for order fulfillment, + redemption, refund, payout, invitation, export, configuration, and mutation + endpoints that can be invoked more than once. Check for nonce, idempotency, + consumed-token, replay-window, or state-transition guards. + +3. **Trace stale artifacts after lifecycle changes.** Verify that logout, + uninstall, token revocation, shop disconnect, or role downgrade invalidates + every downstream session, refresh token, signed URL, cache entry, webhook + capability, and background-job credential that could still authorize work. + +4. **Check cookie-authenticated state changes.** For server-rendered or + cookie-backed flows, verify CSRF protection still applies on replayed direct + URLs, stale links, and downgraded sessions. Session presence alone is not proof + that the operation is still authorized. + +5. **Compare the first successful action with later retries.** A create path may + be authorized once but later update/delete/redeem/replay paths may skip the + same checks. Follow the full state machine, not just the initial handler. + +## What to report + +Report a finding only for a complete lifecycle or replay path where you can show: +- the principal or caller; +- the stale or replayable artifact; +- the missing invalidation, idempotency, or re-authorization boundary; +- the sensitive action that remains reachable; and +- the affected shop, user, customer, or financial authority. + +Example: + +```json +{ + "file": "app/controllers/redemptions_controller.rb", + "line": 42, + "message": "Redeem endpoint accepts the same signed link after the reward was already consumed", + "evidence": [ + { "file": "app/controllers/redemptions_controller.rb", "line": 42, "quote": "Reward.find(params[:id]).redeem!" }, + { "file": "app/models/reward.rb", "line": 18, "quote": "def redeem!" } + ], + "confidence": "high", + "reasoning": "The signed redemption URL remains valid after the first redemption and no consumed-token or state-transition guard runs before issuing the reward again." +} +``` + +Do not report: +- safe retries protected by idempotency keys, consumed-token state, or replay windows; +- stateless GET requests that expose no protected data or side effect; +- cleanup code where you cannot show a stale credential or replayed action remains usable; +- theoretical lifecycle concerns with no demonstrated stale session, replay, or sensitive action. diff --git a/packages/app/src/cli/services/app-doctor-engine/checks/embedded.ts b/packages/app/src/cli/services/app-doctor-engine/checks/embedded.ts index 97189dfb35f..4e8e824d663 100644 --- a/packages/app/src/cli/services/app-doctor-engine/checks/embedded.ts +++ b/packages/app/src/cli/services/app-doctor-engine/checks/embedded.ts @@ -4,12 +4,14 @@ // prettier-ignore export const EMBEDDED_CHECK_SOURCES: ReadonlyArray = [ + "---\nid: ACTIVE_UPLOADS_AND_PRIVILEGED_PREVIEWS\nversion: 1\nseverity: high\n---\n\n# Active Uploads And Privileged Previews\n\nFind cases where merchant-, customer-, webhook-, or external-service-supplied\nfiles become active content in a privileged origin. Trace uploads, imports,\npreviews, and generated assets from ingestion through storage and final render.\n\nThe risk is not the upload alone. The risk is an untrusted-upload-to-active-render\npath: SVG, HTML, XML, PDF, blob/data URL, or another active format is accepted and\nlater rendered in a storefront, embedded admin, customer-account, theme-editor,\nor operator/admin context where it can execute or leak protected data.\n\n## What to look for\n\n1. **Find upload and import entry points.** Search for file uploads, import jobs,\n webhook attachments, remote fetches, document parsers, blob/data URL handling,\n and generated preview endpoints.\n\n2. **Trace file metadata and validation.** Check size limits, extension checks,\n declared MIME type, magic-byte/file-signature verification, filename handling,\n generated storage names, antivirus/sanitization, and any image/PDF re-encoding.\n\n3. **Inspect storage and serving boundaries.** Determine whether the object is\n stored on a non-executable origin, served with explicit `Content-Type` and\n `Content-Disposition`, and prevented from inheriting privileged cookies or\n browser authority.\n\n4. **Follow every final renderer.** Check storefront/theme renderers, embedded\n admin previews, customer-account views, email/PDF previews, admin/operator\n tools, iframe/srcdoc/blob/data URL renderers, and any browser code that inserts\n the uploaded content into the DOM.\n\n5. **Check sandboxing and isolation.** Verify iframes, preview origins, CSP,\n download headers, SVG sanitization, PDF handling, and re-encoding before\n deciding the content is safe.\n\n## What to report\n\nReport a finding only for a complete untrusted-upload-to-active-render path where\nthe uploaded or imported object is actually rendered or served into a privileged\nexecutable context. Show:\n- who controls the uploaded/imported content;\n- which validation or isolation boundary is missing;\n- where the content becomes active or executable;\n- which privileged origin or user is affected; and\n- file/line evidence for both the ingest path and the renderer/serving path.\n\nExample:\n\n```json\n{\n \"file\": \"app/controllers/previews_controller.rb\",\n \"line\": 28,\n \"message\": \"Uploaded SVG is rendered inline in the admin preview without sanitization or origin isolation\",\n \"evidence\": [\n { \"file\": \"app/controllers/uploads_controller.rb\", \"line\": 14, \"quote\": \"params[:file]\" },\n { \"file\": \"app/controllers/previews_controller.rb\", \"line\": 28, \"quote\": \"render inline: blob.download\" }\n ],\n \"confidence\": \"high\",\n \"reasoning\": \"The merchant-controlled SVG is stored without re-encoding and later rendered inline in the embedded admin origin, so script-capable SVG content can execute with merchant authority.\"\n}\n```\n\nDo not report:\n- files that are forced to download and never rendered in an active origin;\n- images/PDFs that are re-encoded or sanitized before serving;\n- isolated preview origins with no privileged cookies, storage, or message bridge;\n- missing deployment details where you cannot establish executable rendering or unsafe serving.\n- permissive content types, inline disposition, or storage/header hygiene issues\n without a concrete privileged renderer or execution surface.\n", "---\nid: APP_PROXY_LIQUID_INJECTION\nversion: 1\nseverity: high\n---\n\n# App Proxy Liquid Injection\n\nTrace verified app-proxy request values into active response bodies, including Liquid and HTML response types. Report only a request-controlled value that reaches an active response; static templates and inert JSON are not findings.\n", "---\nid: APP_PROXY_UNVERIFIED_SIGNATURE\nversion: 2\nseverity: high\n---\n\nFind app proxy endpoints that read proxy parameters without verifying\nthe Shopify signature, allowing an attacker to impersonate Shopify and\nsend fake proxy requests.\n\nApp proxies let an app serve content directly on the merchant's store\nvia a URL like `https://shop.example.com/apps/my-app/proxy`. Shopify\nsigns every proxy request with an HMAC using the app's shared secret.\nIf the app doesn't verify this signature, anyone can send requests to\nthe proxy endpoint with forged parameters — including `shop`,\n`logged_in_customer_id`, and `path_prefix`.\n\n## What to look for\n\n1. **Find app proxy route handlers.** These are endpoints configured as\n app proxies in `shopify.app.toml` under `[app_proxy]` or in the app's\n routing config. They typically read parameters like:\n - `shop` or `shop_id`\n - `logged_in_customer_id`\n - `path_prefix`\n - `signature`\n - `timestamp`\n\n2. **Check for signature verification.** The handler must verify the\n HMAC signature before trusting any proxy parameter. Look for:\n - **Remix:** `authenticate.public.appProxy(request)` — the official\n verification function\n - **Rails:** `verified_request?` or manual HMAC verification using\n `ShopifyApp` utilities\n - **Express:** Manual HMAC verification using the app secret\n - **PHP:** `ShopifyUtils::verifyProxyRequest()` or equivalent\n\n3. **If no verification is present, check whether the handler:**\n - Reads `shop` from the query string and uses it to scope data\n - Reads `logged_in_customer_id` and uses it for authorisation\n - Returns any shop-specific data\n\n If any of these are true and there's no signature check, it's a real\n finding.\n\n4. **Check for the HMAC pattern even if the function name isn't obvious.**\n Some apps implement custom verification:\n - `crypto.createHmac('sha256', API_SECRET)`\n - `OpenSSL::HMAC.digest`\n - `hash_hmac('sha256', ...)`\n - Comparison with `timingSafeEqual` or `secure_compare`\n\n5. **Separate app-local findings from protocol hardening signals.** Missing\n verification is a finding when the handler trusts signed parameters without\n any verification boundary. Weak comparison, unusual canonicalization, or\n delimiterless concatenation is not automatically an app finding: keep it\n unresolved unless you can show a usable victim-signed request path or another\n concrete exploit condition in this app.\n\n## What to report\n\nFor each proxy handler that reads shop/customer parameters without\nsignature verification, or where you can demonstrate a usable victim-signed\nrequest path through a weak verification implementation:\n\n```json\n{\n \"file\": \"app/routes/proxy.ts\",\n \"line\": 15,\n \"message\": \"App proxy handler reads shop parameter without signature verification\",\n \"snippet\": \"const shop = url.searchParams.get('shop')\",\n \"evidence\": [\n {\n \"file\": \"app/routes/proxy.ts\",\n \"line\": 15,\n \"quote\": \"const shop = url.searchParams.get('shop')\"\n },\n {\n \"file\": \"app/routes/proxy.ts\",\n \"line\": 1,\n \"quote\": \"no authenticate.public.appProxy or HMAC verification found\"\n }\n ],\n \"confidence\": \"high\",\n \"reasoning\": \"The handler reads the shop parameter from the query string and uses it to query shop data, but no signature verification is present. An attacker can send requests with any shop parameter.\"\n}\n```\n\nDo not report:\n\n- Handlers that call `authenticate.public.appProxy(request)` (Remix)\n- Handlers with manual HMAC verification\n- Handlers that return only static content (no shop-specific data)\n- Protocol-only canonicalization concerns with no demonstrated app-local exploit path\n- Test handlers\n", "---\nid: COMMITTED_SECRET\nversion: 1\nseverity: high\n---\n\n# Committed Secret\n\nInspect files skipped by deterministic secret scanning for committed credentials. Never quote or reproduce a secret; cite only the file and redacted credential kind, and recommend rotation.\n", "---\nid: CREDENTIAL_BROWSER_LEAKAGE\nversion: 1\nseverity: high\n---\n\n# Credential Browser Leakage\n\nTrace credentials, access tokens, session tokens, and client secrets into loader/HTTP responses, browser globals, DOM values, client bundles, or external requests. Do not report server-only use or safe boolean/redacted/hash-derived values.\n", "---\nid: CREDENTIAL_LOG_LEAKAGE\nversion: 1\nseverity: high\n---\n\n# Credential Log Leakage\n\nTrace credentials, access tokens, session tokens, and client secrets through aliases and helpers to console, logger, telemetry, or error-reporting sinks. Do not report boolean presence checks, deliberate redaction, or one-way hashes.\n", "---\nid: CSRF_MISSING_PROTECTION\nversion: 2\nseverity: medium\n---\n\nFind state-changing endpoints (POST, PUT, DELETE, PATCH) that don't\nverify CSRF protection, allowing an attacker to forge requests on\nbehalf of an authenticated user.\n\nCSRF (Cross-Site Request Forgery) occurs when an app accepts\nstate-changing requests without checking that the request came from\nthe app's own UI. In Shopify apps, embedded apps use session tokens\n(JWT) that provide some CSRF protection, but server-rendered apps and\napp proxies still need explicit CSRF checks.\n\n## What to look for\n\n1. **Find state-changing handlers.** Search for:\n - Rails: controller actions responding to POST/PUT/PATCH/DELETE\n (check `routes.rb` or controller method names like `create`,\n `update`, `destroy`)\n - Remix: `action` exports in route files\n - Express: `app.post()`, `app.put()`, `app.delete()`\n - PHP: form handlers, POST routes\n\n2. **Check for CSRF protection on each.** Look for:\n - Rails: `protect_from_forgery` (default in Rails, but check for\n `skip_forgery_protection` or `protect_from_forgery with: :null_session`)\n - Remix: session token validation (`authenticate.admin(request)`)\n - Express: `csurf` middleware or equivalent\n - PHP: CSRF token in form, `VerifyCsrfToken` middleware\n\n3. **Flag explicit opt-outs.** Search for:\n - `skip_forgery_protection` — disables CSRF entirely for a controller\n - `protect_from_forgery with: :null_session` — used for webhooks, but\n if on a non-webhook endpoint, CSRF is missing\n - `skip_before_action :verify_authenticity_token` — skips the Rails\n CSRF check\n\n4. **Distinguish webhooks from user-facing endpoints.** Webhooks use\n HMAC verification instead of CSRF tokens — `protect_from_forgery\n with: :null_session` is correct for webhooks. But the same pattern\n on a user-facing POST handler is a CSRF vulnerability.\n\n5. **Check Shopify-specific patterns.** Embedded apps that use\n `authenticate.admin(request)` get session token validation that\n prevents CSRF. But if an action skips `authenticate.admin` and still\n processes state changes, CSRF protection may be missing.\n\n6. **Require a concrete sensitive action.** A missing anti-CSRF signal is only a\n finding when the forged request can change privileged state, access protected\n data, or trigger another security-relevant action. A harmless no-op or public\n write endpoint is not enough by itself.\n\n## What to report\n\nFor each state-changing endpoint without CSRF protection that reaches a concrete sensitive action:\n\n```json\n{\n \"file\": \"app/controllers/settings_controller.rb\",\n \"line\": 5,\n \"message\": \"POST handler with CSRF protection disabled\",\n \"snippet\": \"skip_forgery_protection\",\n \"evidence\": [\n {\n \"file\": \"app/controllers/settings_controller.rb\",\n \"line\": 5,\n \"quote\": \"skip_forgery_protection\"\n },\n {\n \"file\": \"app/controllers/settings_controller.rb\",\n \"line\": 10,\n \"quote\": \"def update\"\n }\n ],\n \"confidence\": \"medium\",\n \"reasoning\": \"The update action accepts POST requests but CSRF protection is explicitly skipped. This is not a webhook handler, and the action mutates privileged state, so an attacker can forge the request from another site.\"\n}\n```\n\nDo not report:\n\n- Webhook handlers with `protect_from_forgery with: :null_session`\n (HMAC is the CSRF protection for webhooks)\n- Endpoints protected by `authenticate.admin(request)` (session\n token provides CSRF protection)\n- GET-only handlers (not state-changing)\n- API endpoints that use bearer token auth (not cookie-based, so\n CSRF doesn't apply)\n- State-changing handlers where no privileged or security-relevant effect is reachable\n- Test controllers\n", + "---\nid: DEPENDENCY_REACHABILITY\nversion: 1\nseverity: medium\n---\n\n# Dependency Reachability\n\nStart from a static dependency or SDK finding and determine whether the app is\nactually exposed to the vulnerable behavior. A vulnerable package version is not\nenough by itself: confirm that the app uses the vulnerable API or helper, enables\nthe affected configuration, and exposes the relevant trust boundary.\n\n## What to look for\n\n1. **Identify the vulnerable version and advisory scope.** Read the manifest,\n lockfile, or deterministic finding and determine the exact package, version,\n vulnerable range, and affected API/helper/configuration from the advisory or\n shipped Shopify SDK behavior.\n\n2. **Find imports and call sites.** Search for direct imports, wrapper helpers,\n generated clients, middleware, framework adapters, or transitive call paths\n that reach the vulnerable API or helper.\n\n3. **Check configuration and feature gates.** Some vulnerabilities only apply\n when a flag, transport, parser mode, canonicalization shape, or optional\n feature is enabled. Verify the app actually enables the affected path.\n\n4. **Trace the reachable impact.** Confirm which untrusted input can reach the\n vulnerable dependency behavior and what authority or data is exposed if the\n bug triggers.\n\n5. **Distinguish version exposure from exploitability.** If the vulnerable\n package is present but the app never calls the affected API/helper, or the\n vulnerable configuration is disabled, keep the result unresolved rather than\n reporting a finding.\n\n## What to report\n\nReport a finding only when you can show:\n- the vulnerable package or Shopify SDK version;\n- the vulnerable API or helper in use;\n- the enabling configuration or call shape;\n- the untrusted input or trigger; and\n- the resulting security impact.\n\nExample:\n\n```json\n{\n \"file\": \"app/services/session_verifier.ts\",\n \"line\": 18,\n \"message\": \"App uses vulnerable session-token helper without issuer validation\",\n \"evidence\": [\n { \"file\": \"package.json\", \"line\": 12, \"quote\": \"\\\"@shopify/shopify-app-remix\\\": \\\"x.y.z\\\"\" },\n { \"file\": \"app/services/session_verifier.ts\", \"line\": 18, \"quote\": \"verifySessionToken(token)\" }\n ],\n \"confidence\": \"high\",\n \"reasoning\": \"The installed SDK version contains the vulnerable helper implementation and the app calls that helper on attacker-controlled session tokens without an issuer check.\"\n}\n```\n\nDo not report:\n- a vulnerable version with no reachable use of the affected API/helper;\n- dev-only tooling or test-only dependencies that cannot affect production;\n- advisories whose required configuration is not enabled in this app;\n- guessed exploitability when the call path or trigger cannot be established from source.\n", "---\nid: DEPRECATED_SCRIPT_TAG_SCOPE\nversion: 1\nseverity: medium\n---\n\n# Deprecated Script Tag Scope\n\nInspect parsed app scopes and JavaScript/TypeScript Admin API operations for deprecated ScriptTag capability. Report `read_script_tags`, `write_script_tags`, or ScriptTag create/update use under this single product ID.\n", "---\nid: EOL_API_VERSION\nversion: 1\nseverity: low\n---\n\n# Eol Api Version\n\nInspect every unresolved `shopify.app*.toml` plus React Router `app/shopify.server.*` declarations. Shopify publishes quarterly versions in January, April, July, and October and supports each stable version for 12 months; App Doctor allows a documented 30-day extension grace period before reporting it as end-of-life. Cite the exact declaration. For malformed config, computed `ApiVersion` values, or a Shopify-announced exceptional extension, inspect the source and current lifecycle policy rather than inferring from unrelated constants.\n", "---\nid: EXPIRING_OFFLINE_TOKEN\nversion: 1\nseverity: medium\n---\n\n# Expiring Offline Token\n\nFor supported React Router apps, verify `expiringOfflineAccessTokens` is enabled and the selected session storage persists `expires`, `refreshToken`, and `refreshTokenExpires` metadata needed for refresh and rotation. `isOnline: false` selects an offline session; it does not disable token expiry and is not a finding. Report an explicit `expiringOfflineAccessTokens: false`. Treat absent or computed flags, custom storage, and ambiguous Prisma schemas as unresolved investigation: inspect storage adapters, migrations, and serialization before returning a clean result. Config-only and unsupported frameworks are handled by the runtime applicability boundary.\n", @@ -27,6 +29,7 @@ export const EMBEDDED_CHECK_SOURCES: ReadonlyArray = [ "---\nid: REQUEST_DERIVED_SHOP_SCOPE\nversion: 3\nseverity: high\n---\n\nFind cases where a shop or tenant selector comes from an untrusted or\ninsufficiently bound source instead of the authenticated installation or\nsession, and is used to scope a database query or select an Admin /\nStorefront API context.\n\nThe key insight: a shop filter that uses an attacker-controlled value is\nno filter at all. The attacker can pass any shop's identifier and access\nthat shop's data. This is distinct from `MISSING_TENANT_ISOLATION` (no\nshop filter at all) — here the filter or context selection exists, but\nthe selector wins over the authenticated installation/session context.\n\nThis bug appears in two common forms:\n\n**Form 1: Database query scoped by an untrusted selector.**\n\n```ruby\n# Rails — shop_id from params, not session\nToken.where(shop_id: params[:shop_id]).delete_all\nOrder.find_by(shop_id: params[:shop])\n```\n\n**Form 2: API context selected by an untrusted selector.**\n\n```typescript\n// Remix — shop from formData, not session\nconst shop = formData.get(\"shop\");\nconst { admin } = await unauthenticated.admin(shop);\n// Now admin is scoped to whatever shop the caller passed\n```\n\nBoth are the same vulnerability: the caller chooses which shop's data to\naccess. In Form 1, the query filter is attacker-controlled. In Form 2,\nthe API context is attacker-controlled. `unauthenticated.admin()` and\n`unauthenticated.storefront()` deliberately take a shop parameter, so\nusing either with an unbound selector is an IDOR.\n\n## What to look for\n\n1. **Find database queries that filter on a shop/tenant column.** Search for:\n - `where(shop_id:`, `where(shop:`, `where(store_id:`, `where(tenant_id:`\n - `.find_by(shop_id:`, `.find_or_initialize_by(shop_id:`\n\n2. **Find privileged Shopify API context selectors.** Search for:\n - `unauthenticated.admin(`\n - `unauthenticated.storefront(`\n - Helpers that construct Admin / Storefront API clients from a supplied shop\n\n3. **Trace the selector for every query or API context call.** Determine\n where it comes from:\n - `params[:shop_id]`, `formData.get(\"shop\")`, `url.searchParams.get(\"shop\")`\n — request input, attacker-controlled\n - `request.headers[\"X-Shopify-Shop-Domain\"]` or equivalent raw headers\n — attacker-controlled unless independently verified\n - cache keys, serialized background-job payloads, token-exchange artifacts,\n GraphQL IDs, or database fields originally written from lower-trust input\n - `session.shop`, `current_shop.shop_id`, `shop.shop_id`, or a verified\n installation/session record — trusted\n - A local variable — trace it back to its first trusted or untrusted source\n\n4. **Check for compensating controls.** The selector may be safe even if it\n came from lower-trust input, IF the code re-binds it before use:\n - An HMAC signature on the URL or payload\n - A `before_action` / middleware check that compares it to the session\n - A Pundit policy or authorization check that proves tenant ownership\n - A background-job lookup that loads the installation/session record and\n ignores the raw job value after rebinding\n\n Follow the control to its definition and verify it actually dominates the\n query or API-context creation you are reviewing.\n\n5. **Check for the OAuth callback pattern.** In Shopify OAuth flows,\n `shop_id` often comes from a signed URL that was generated by the\n app's own backend using the session shop. The HMAC on that URL is\n the control. This is safe — but verify the signing key isn't\n hardcoded or leaked.\n\n6. **Distinguish `authenticate.admin` from `unauthenticated.*`.**\n `authenticate.admin(request)` derives the shop from the session — safe.\n `unauthenticated.admin(shop)` / `unauthenticated.storefront(shop)` take the\n shop as an argument — only safe if the argument is session-derived or\n independently verified.\n\n## What to report\n\nFor each query or API context call where the selector is proven\nattacker-controlled or unbound to the current installation/session and no\ncompensating control dominates the sink:\n\n```json\n{\n \"file\": \"app/routes/api.orders.ts\",\n \"line\": 6,\n \"message\": \"Shop from formData passed to unauthenticated.admin() — IDOR\",\n \"snippet\": \"const shop = formData.get(\\\"shop\\\"); const { admin } = await unauthenticated.admin(shop);\",\n \"evidence\": [\n {\n \"file\": \"app/routes/api.orders.ts\",\n \"line\": 5,\n \"quote\": \"const shop = formData.get(\\\"shop\\\")\"\n },\n {\n \"file\": \"app/routes/api.orders.ts\",\n \"line\": 6,\n \"quote\": \"unauthenticated.admin(shop)\"\n }\n ],\n \"confidence\": \"high\",\n \"reasoning\": \"Shop comes from formData (request input) and is passed to unauthenticated.admin(). No session verification or rebinding occurs, so an attacker can select another shop's Admin API context.\"\n}\n```\n\nDo not report:\n\n- Calls to `authenticate.admin(request)` — the shop comes from the session\n- Queries where shop_id comes from `current_shop`, `session.shop`, or other\n session-derived sources\n- Queries guarded by an HMAC signature or explicit rebinding to a trusted installation\n- Queries on the Shop model itself (looking up a shop by id is normal)\n- Queries in webhook handlers where verified HMAC-bound context supplies the selector\n- Background-job, cache, header, or persisted shop values proven to be rebound to a\n trusted installation/session record before use\n- Values whose provenance is unclear but not demonstrably attacker-controlled;\n keep those unresolved instead of reporting a finding\n", "---\nid: SCOPE_OVER_REQUEST\nversion: 1\nseverity: high\n---\n\nFind cases where an app requests OAuth scopes it does not use, or uses\nscopes in ways that exceed what the merchant authorised.\n\nWhen a merchant installs an app, they grant a set of access scopes (e.g.\n`read_orders`, `write_products`). The app should only access data covered\nby those scopes. Two risks:\n\n1. **Over-requested scopes:** the app declares scopes in its config that it\n never references in code. This is a privacy violation — the merchant\n granted access to data the app doesn't need.\n\n2. **Under-verified usage:** the app calls an API endpoint that requires a\n scope, but doesn't check that the scope was granted before making the\n call. This can fail at runtime or, worse, access data the merchant\n didn't authorise if the scope was added by a different code path.\n\n## What to look for\n\n1. **Find the declared scopes.** Look in `shopify.app.toml` under\n `[access_scopes]` → `scopes`, or in the app's OAuth redirect URL, or\n in environment variables like `SCOPES`.\n\n2. **Find where scopes are used.** Search for API calls that reference\n Shopify resources: `admin.rest.get`, `admin.graphql`, REST resource\n classes, GraphQL queries on `orders`, `products`, `customers`, etc.\n\n3. **Match scopes to usage.** Each scope should map to at least one API\n call:\n - `read_orders` → queries on orders\n - `write_products` → mutations on products\n - `read_customers` → queries on customers\n - etc.\n\n4. **Flag scopes with no matching usage.** If `read_analytics` is declared\n but no code references analytics, that's an over-requested scope.\n\n5. **Flag API calls with no matching scope.** If code queries customers\n but `read_customers` isn't declared, that's an under-verified usage.\n\n## What to report\n\n```json\n{\n \"file\": \"shopify.app.toml\",\n \"line\": 10,\n \"message\": \"Scope 'read_analytics' is declared but never referenced in app code\",\n \"evidence\": [\n {\n \"file\": \"shopify.app.toml\",\n \"line\": 10,\n \"quote\": \"scopes = \\\"read_orders,read_analytics\\\"\"\n }\n ],\n \"confidence\": \"medium\",\n \"reasoning\": \"Searched all source files for 'analytics' and found no API calls referencing analytics endpoints or resources.\"\n}\n```\n\nFor under-verified usage, report the code location, not the TOML:\n\n```json\n{\n \"file\": \"app/services/customer_export.rb\",\n \"line\": 15,\n \"message\": \"Queries customers but 'read_customers' is not in declared scopes\",\n \"evidence\": [\n {\n \"file\": \"app/services/customer_export.rb\",\n \"line\": 15,\n \"quote\": \"Customer.all\"\n },\n {\n \"file\": \"shopify.app.toml\",\n \"line\": 10,\n \"quote\": \"scopes = \\\"read_orders\\\"\"\n }\n ],\n \"confidence\": \"high\"\n}\n```\n\nNote: if the app has zero source files (config-only app), do not report\nover-requested scopes — you cannot verify usage from an empty corpus.\n", "---\nid: SCRIPT_TAG_URL_INJECTION\nversion: 2\nseverity: high\n---\n\nFind cases where the ScriptTag API is used with a URL derived from user\ninput, allowing an attacker to inject arbitrary scripts into every\nmerchant's storefront.\n\nThe ScriptTag API injects a `