@shopify/shopify-app-remix 6.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var shopifyApi = require('@shopify/shopify-api');
3
4
  var ensureCorsHeaders = require('../helpers/ensure-cors-headers.js');
4
5
  var validateSessionToken = require('../helpers/validate-session-token.js');
5
6
  var getSessionTokenHeader = require('../helpers/get-session-token-header.js');
@@ -16,10 +17,10 @@ var updateUsageSubscriptionCappedAmount = require('./billing/update-usage-subscr
16
17
  var createAdminApiContext = require('./helpers/create-admin-api-context.js');
17
18
  var ensureAppIsEmbeddedIfRequired = require('./helpers/ensure-app-is-embedded-if-required.js');
18
19
  var ensureSessionTokenSearchParamIfRequired = require('./helpers/ensure-session-token-search-param-if-required.js');
19
- require('@shopify/shopify-api');
20
20
  var redirect = require('./helpers/redirect.js');
21
21
  var renderAppBridge = require('./helpers/render-app-bridge.js');
22
22
  var validateShopAndHostParams = require('./helpers/validate-shop-and-host-params.js');
23
+ var validateRedirectUrl = require('./helpers/validate-redirect-url.js');
23
24
  var factory = require('./scope/factory.js');
24
25
 
25
26
  function authStrategyFactory({ strategy, ...params }) {
@@ -37,6 +38,10 @@ function authStrategyFactory({ strategy, ...params }) {
37
38
  const url = new URL(request.url);
38
39
  if (url.pathname === config.auth.exitIframePath) {
39
40
  const destination = url.searchParams.get('exitIframe');
41
+ if (!validateRedirectUrl.isTrustedExitIframeDestination(config.appUrl, destination)) {
42
+ logger.warning('Rejected exit-iframe redirect to an untrusted destination', { shop: getShopFromRequest.getShopFromRequest(request), destination });
43
+ throw new shopifyApi.ShopifyError('Invalid exit-iframe destination. Refusing to redirect.');
44
+ }
40
45
  logger.debug('Rendering exit iframe page', {
41
46
  shop: getShopFromRequest.getShopFromRequest(request),
42
47
  destination,
@@ -1 +1 @@
1
- {"version":3,"file":"authenticate.js","sources":["../../../../../../src/server/authenticate/admin/authenticate.ts"],"sourcesContent":["import {JwtPayload, Session} from '@shopify/shopify-api';\n\nimport type {BasicParams} from '../../types';\nimport type {AppConfigArg} from '../../config-types';\nimport {\n getSessionTokenHeader,\n ensureCORSHeadersFactory,\n getSessionTokenFromUrlParam,\n respondToBotRequest,\n respondToOptionsRequest,\n validateSessionToken,\n getShopFromRequest,\n} from '../helpers';\n\nimport {\n cancelBillingFactory,\n requestBillingFactory,\n requireBillingFactory,\n checkBillingFactory,\n createUsageRecordFactory,\n updateUsageCappedAmountFactory,\n} from './billing';\nimport type {\n AdminContext,\n AuthenticateAdmin,\n EmbeddedAdminContext,\n NonEmbeddedAdminContext,\n} from './types';\nimport {\n createAdminApiContext,\n ensureAppIsEmbeddedIfRequired,\n ensureSessionTokenSearchParamIfRequired,\n redirectFactory,\n renderAppBridge,\n validateShopAndHostParams,\n} from './helpers';\nimport {AuthorizationStrategy} from './strategies/types';\nimport {scopesApiFactory} from './scope/factory';\n\nexport interface SessionTokenContext {\n shop: string;\n sessionId?: string;\n sessionToken?: string;\n payload?: JwtPayload;\n}\n\ninterface AuthStrategyParams extends BasicParams {\n strategy: AuthorizationStrategy;\n}\n\nexport function authStrategyFactory<ConfigArg extends AppConfigArg>({\n strategy,\n ...params\n}: AuthStrategyParams): AuthenticateAdmin<ConfigArg> {\n const {api, logger, config} = params;\n\n async function respondToBouncePageRequest(request: Request) {\n const url = new URL(request.url);\n\n if (url.pathname === config.auth.patchSessionTokenPath) {\n logger.debug('Rendering bounce page', {\n shop: getShopFromRequest(request),\n });\n throw renderAppBridge({config, logger, api}, request);\n }\n }\n\n async function respondToExitIframeRequest(request: Request) {\n const url = new URL(request.url);\n\n if (url.pathname === config.auth.exitIframePath) {\n const destination = url.searchParams.get('exitIframe')!;\n\n logger.debug('Rendering exit iframe page', {\n shop: getShopFromRequest(request),\n destination,\n });\n throw renderAppBridge({config, logger, api}, request, {url: destination});\n }\n }\n\n type AdminContextBase =\n EmbeddedAdminContext<ConfigArg> | NonEmbeddedAdminContext<ConfigArg>;\n\n function createContext(\n request: Request,\n session: Session,\n authStrategy: AuthorizationStrategy,\n sessionToken?: JwtPayload,\n ): AdminContext<ConfigArg> {\n let context: AdminContextBase = {\n admin: createAdminApiContext(\n session,\n params,\n authStrategy.handleClientError(request),\n ),\n billing: {\n require: requireBillingFactory(params, request, session),\n check: checkBillingFactory(params, request, session),\n request: requestBillingFactory(params, request, session),\n cancel: cancelBillingFactory(params, request, session),\n createUsageRecord: createUsageRecordFactory(params, request, session),\n updateUsageCappedAmount: updateUsageCappedAmountFactory(\n params,\n request,\n session,\n ),\n },\n\n session,\n cors: ensureCORSHeadersFactory(params, request),\n };\n\n context = addEmbeddedFeatures(context, request, session, sessionToken);\n context = addScopesFeatures(context);\n\n return context as AdminContext<ConfigArg>;\n }\n\n function addEmbeddedFeatures(\n context: AdminContextBase,\n request: Request,\n session: Session,\n sessionToken?: JwtPayload,\n ) {\n if (config.isEmbeddedApp) {\n return {\n ...context,\n sessionToken,\n redirect: redirectFactory(params, request, session.shop),\n };\n }\n return context;\n }\n\n function addScopesFeatures(context: AdminContextBase) {\n return {\n ...context,\n scopes: scopesApiFactory(params, context.session, context.admin),\n };\n }\n\n return async function authenticateAdmin(request: Request) {\n try {\n respondToBotRequest(params, request);\n respondToOptionsRequest(params, request);\n await respondToBouncePageRequest(request);\n await respondToExitIframeRequest(request);\n await strategy.respondToOAuthRequests(request);\n\n // If this is a valid request, but it doesn't have a session token header, this is a document request. We need to\n // ensure we're embedded if needed and we have the information needed to load the session.\n if (!getSessionTokenHeader(request)) {\n validateShopAndHostParams(params, request);\n await ensureAppIsEmbeddedIfRequired(params, request);\n await ensureSessionTokenSearchParamIfRequired(params, request);\n }\n\n const {payload, shop, sessionId, sessionToken} =\n await getSessionTokenContext(params, request);\n\n logger.info('Authenticating admin request', {shop});\n\n logger.debug('Loading session from storage', {shop, sessionId});\n const existingSession = sessionId\n ? await config.sessionStorage!.loadSession(sessionId)\n : undefined;\n\n const session = await strategy.authenticate(request, {\n session: existingSession,\n sessionToken,\n shop,\n });\n\n return createContext(request, session, strategy, payload);\n } catch (errorOrResponse) {\n if (errorOrResponse instanceof Response) {\n logger.debug('Authenticate returned a response', {\n shop: getShopFromRequest(request),\n });\n ensureCORSHeadersFactory(params, request)(errorOrResponse);\n }\n\n throw errorOrResponse;\n }\n };\n}\n\nasync function getSessionTokenContext(\n params: BasicParams,\n request: Request,\n): Promise<SessionTokenContext> {\n const {api, config, logger} = params;\n\n const headerSessionToken = getSessionTokenHeader(request);\n const searchParamSessionToken = getSessionTokenFromUrlParam(request);\n const sessionToken = (headerSessionToken || searchParamSessionToken)!;\n\n logger.debug('Attempting to authenticate session token', {\n shop: getShopFromRequest(request),\n sessionToken: JSON.stringify({\n header: headerSessionToken,\n search: searchParamSessionToken,\n }),\n });\n\n if (config.isEmbeddedApp) {\n const payload = await validateSessionToken(params, request, sessionToken);\n const dest = new URL(payload.dest);\n const shop = dest.hostname;\n\n logger.debug('Session token is valid - authenticated', {shop, payload});\n const sessionId = config.useOnlineTokens\n ? api.session.getJwtSessionId(shop, payload.sub)\n : api.session.getOfflineId(shop);\n\n return {shop, payload, sessionId, sessionToken};\n }\n\n const url = new URL(request.url);\n const shop = url.searchParams.get('shop')!;\n\n const sessionId = await api.session.getCurrentId({\n isOnline: config.useOnlineTokens,\n rawRequest: request,\n });\n\n return {shop, sessionId, payload: undefined, sessionToken};\n}\n"],"names":["getShopFromRequest","renderAppBridge","request","createAdminApiContext","requireBillingFactory","checkBillingFactory","requestBillingFactory","cancelBillingFactory","createUsageRecordFactory","updateUsageCappedAmountFactory","ensureCORSHeadersFactory","redirectFactory","scopesApiFactory","respondToBotRequest","respondToOptionsRequest","getSessionTokenHeader","validateShopAndHostParams","ensureAppIsEmbeddedIfRequired","ensureSessionTokenSearchParamIfRequired","getSessionTokenFromUrlParam","validateSessionToken"],"mappings":"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"}
1
+ {"version":3,"file":"authenticate.js","sources":["../../../../../../src/server/authenticate/admin/authenticate.ts"],"sourcesContent":["import {JwtPayload, Session, ShopifyError} from '@shopify/shopify-api';\n\nimport type {BasicParams} from '../../types';\nimport type {AppConfigArg} from '../../config-types';\nimport {\n getSessionTokenHeader,\n ensureCORSHeadersFactory,\n getSessionTokenFromUrlParam,\n respondToBotRequest,\n respondToOptionsRequest,\n validateSessionToken,\n getShopFromRequest,\n} from '../helpers';\n\nimport {\n cancelBillingFactory,\n requestBillingFactory,\n requireBillingFactory,\n checkBillingFactory,\n createUsageRecordFactory,\n updateUsageCappedAmountFactory,\n} from './billing';\nimport type {\n AdminContext,\n AuthenticateAdmin,\n EmbeddedAdminContext,\n NonEmbeddedAdminContext,\n} from './types';\nimport {\n createAdminApiContext,\n ensureAppIsEmbeddedIfRequired,\n ensureSessionTokenSearchParamIfRequired,\n isTrustedExitIframeDestination,\n redirectFactory,\n renderAppBridge,\n validateShopAndHostParams,\n} from './helpers';\nimport {AuthorizationStrategy} from './strategies/types';\nimport {scopesApiFactory} from './scope/factory';\n\nexport interface SessionTokenContext {\n shop: string;\n sessionId?: string;\n sessionToken?: string;\n payload?: JwtPayload;\n}\n\ninterface AuthStrategyParams extends BasicParams {\n strategy: AuthorizationStrategy;\n}\n\nexport function authStrategyFactory<ConfigArg extends AppConfigArg>({\n strategy,\n ...params\n}: AuthStrategyParams): AuthenticateAdmin<ConfigArg> {\n const {api, logger, config} = params;\n\n async function respondToBouncePageRequest(request: Request) {\n const url = new URL(request.url);\n\n if (url.pathname === config.auth.patchSessionTokenPath) {\n logger.debug('Rendering bounce page', {\n shop: getShopFromRequest(request),\n });\n throw renderAppBridge({config, logger, api}, request);\n }\n }\n\n async function respondToExitIframeRequest(request: Request) {\n const url = new URL(request.url);\n\n if (url.pathname === config.auth.exitIframePath) {\n const destination = url.searchParams.get('exitIframe');\n\n if (!isTrustedExitIframeDestination(config.appUrl, destination)) {\n logger.warning(\n 'Rejected exit-iframe redirect to an untrusted destination',\n {shop: getShopFromRequest(request), destination},\n );\n throw new ShopifyError(\n 'Invalid exit-iframe destination. Refusing to redirect.',\n );\n }\n\n logger.debug('Rendering exit iframe page', {\n shop: getShopFromRequest(request),\n destination,\n });\n throw renderAppBridge({config, logger, api}, request, {url: destination});\n }\n }\n\n type AdminContextBase =\n EmbeddedAdminContext<ConfigArg> | NonEmbeddedAdminContext<ConfigArg>;\n\n function createContext(\n request: Request,\n session: Session,\n authStrategy: AuthorizationStrategy,\n sessionToken?: JwtPayload,\n ): AdminContext<ConfigArg> {\n let context: AdminContextBase = {\n admin: createAdminApiContext(\n session,\n params,\n authStrategy.handleClientError(request),\n ),\n billing: {\n require: requireBillingFactory(params, request, session),\n check: checkBillingFactory(params, request, session),\n request: requestBillingFactory(params, request, session),\n cancel: cancelBillingFactory(params, request, session),\n createUsageRecord: createUsageRecordFactory(params, request, session),\n updateUsageCappedAmount: updateUsageCappedAmountFactory(\n params,\n request,\n session,\n ),\n },\n\n session,\n cors: ensureCORSHeadersFactory(params, request),\n };\n\n context = addEmbeddedFeatures(context, request, session, sessionToken);\n context = addScopesFeatures(context);\n\n return context as AdminContext<ConfigArg>;\n }\n\n function addEmbeddedFeatures(\n context: AdminContextBase,\n request: Request,\n session: Session,\n sessionToken?: JwtPayload,\n ) {\n if (config.isEmbeddedApp) {\n return {\n ...context,\n sessionToken,\n redirect: redirectFactory(params, request, session.shop),\n };\n }\n return context;\n }\n\n function addScopesFeatures(context: AdminContextBase) {\n return {\n ...context,\n scopes: scopesApiFactory(params, context.session, context.admin),\n };\n }\n\n return async function authenticateAdmin(request: Request) {\n try {\n respondToBotRequest(params, request);\n respondToOptionsRequest(params, request);\n await respondToBouncePageRequest(request);\n await respondToExitIframeRequest(request);\n await strategy.respondToOAuthRequests(request);\n\n // If this is a valid request, but it doesn't have a session token header, this is a document request. We need to\n // ensure we're embedded if needed and we have the information needed to load the session.\n if (!getSessionTokenHeader(request)) {\n validateShopAndHostParams(params, request);\n await ensureAppIsEmbeddedIfRequired(params, request);\n await ensureSessionTokenSearchParamIfRequired(params, request);\n }\n\n const {payload, shop, sessionId, sessionToken} =\n await getSessionTokenContext(params, request);\n\n logger.info('Authenticating admin request', {shop});\n\n logger.debug('Loading session from storage', {shop, sessionId});\n const existingSession = sessionId\n ? await config.sessionStorage!.loadSession(sessionId)\n : undefined;\n\n const session = await strategy.authenticate(request, {\n session: existingSession,\n sessionToken,\n shop,\n });\n\n return createContext(request, session, strategy, payload);\n } catch (errorOrResponse) {\n if (errorOrResponse instanceof Response) {\n logger.debug('Authenticate returned a response', {\n shop: getShopFromRequest(request),\n });\n ensureCORSHeadersFactory(params, request)(errorOrResponse);\n }\n\n throw errorOrResponse;\n }\n };\n}\n\nasync function getSessionTokenContext(\n params: BasicParams,\n request: Request,\n): Promise<SessionTokenContext> {\n const {api, config, logger} = params;\n\n const headerSessionToken = getSessionTokenHeader(request);\n const searchParamSessionToken = getSessionTokenFromUrlParam(request);\n const sessionToken = (headerSessionToken || searchParamSessionToken)!;\n\n logger.debug('Attempting to authenticate session token', {\n shop: getShopFromRequest(request),\n sessionToken: JSON.stringify({\n header: headerSessionToken,\n search: searchParamSessionToken,\n }),\n });\n\n if (config.isEmbeddedApp) {\n const payload = await validateSessionToken(params, request, sessionToken);\n const dest = new URL(payload.dest);\n const shop = dest.hostname;\n\n logger.debug('Session token is valid - authenticated', {shop, payload});\n const sessionId = config.useOnlineTokens\n ? api.session.getJwtSessionId(shop, payload.sub)\n : api.session.getOfflineId(shop);\n\n return {shop, payload, sessionId, sessionToken};\n }\n\n const url = new URL(request.url);\n const shop = url.searchParams.get('shop')!;\n\n const sessionId = await api.session.getCurrentId({\n isOnline: config.useOnlineTokens,\n rawRequest: request,\n });\n\n return {shop, sessionId, payload: undefined, sessionToken};\n}\n"],"names":["getShopFromRequest","renderAppBridge","isTrustedExitIframeDestination","ShopifyError","request","createAdminApiContext","requireBillingFactory","checkBillingFactory","requestBillingFactory","cancelBillingFactory","createUsageRecordFactory","updateUsageCappedAmountFactory","ensureCORSHeadersFactory","redirectFactory","scopesApiFactory","respondToBotRequest","respondToOptionsRequest","getSessionTokenHeader","validateShopAndHostParams","ensureAppIsEmbeddedIfRequired","ensureSessionTokenSearchParamIfRequired","getSessionTokenFromUrlParam","validateSessionToken"],"mappings":"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"}
@@ -43,6 +43,55 @@ function sanitizeRedirectUrl(domain, redirectUrl, options = {}) {
43
43
  throw new shopifyApi.ShopifyError('Invalid URL. Refusing to redirect');
44
44
  }
45
45
  }
46
+ const SHOPIFY_ADMIN_HOST = 'admin.shopify.com';
47
+ const SHOP_ADMIN_HOST_REGEX = /\.myshopify\.com$/i;
48
+ /**
49
+ * Determines whether an exit-iframe destination is one we trust enough to
50
+ * redirect to.
51
+ *
52
+ * The destination comes straight from the request URL, so it is limited to:
53
+ *
54
+ * - the app's own origin, including relative paths (used by the OAuth flow), or
55
+ * - the Shopify admin: `admin.shopify.com`, or `<shop>.myshopify.com` under
56
+ * `/admin` (where billing confirmation URLs live).
57
+ *
58
+ * Other Shopify-owned hosts are intentionally excluded, including storefronts
59
+ * (`<shop>.myshopify.com/`), the CDN (`cdn.shopify.com`) and the community
60
+ * forums (`community.shopify.com`), because their content is not controlled by
61
+ * the app or the admin. Anything not explicitly allowed is refused.
62
+ */
63
+ function isTrustedExitIframeDestination(appUrl, destination) {
64
+ if (typeof destination !== 'string' || destination.trim() === '') {
65
+ return false;
66
+ }
67
+ let destinationUrl;
68
+ let appOrigin;
69
+ try {
70
+ destinationUrl = new URL(destination, appUrl);
71
+ appOrigin = new URL(appUrl).origin;
72
+ }
73
+ catch (_error) {
74
+ return false;
75
+ }
76
+ // The app's own origin (including relative paths) is always allowed.
77
+ if (destinationUrl.origin === appOrigin) {
78
+ return true;
79
+ }
80
+ // Every other destination must be a Shopify admin URL served over https with
81
+ // no custom port.
82
+ if (destinationUrl.protocol !== 'https:' || destinationUrl.port !== '') {
83
+ return false;
84
+ }
85
+ const { hostname, pathname } = destinationUrl;
86
+ if (hostname === SHOPIFY_ADMIN_HOST) {
87
+ return true;
88
+ }
89
+ return SHOP_ADMIN_HOST_REGEX.test(hostname) && isAdminPath(pathname);
90
+ }
91
+ function isAdminPath(pathname) {
92
+ return pathname === '/admin' || pathname.startsWith('/admin/');
93
+ }
46
94
 
95
+ exports.isTrustedExitIframeDestination = isTrustedExitIframeDestination;
47
96
  exports.sanitizeRedirectUrl = sanitizeRedirectUrl;
48
97
  //# sourceMappingURL=validate-redirect-url.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"validate-redirect-url.js","sources":["../../../../../../../src/server/authenticate/admin/helpers/validate-redirect-url.ts"],"sourcesContent":["import {ShopifyError} from '@shopify/shopify-api';\n\ninterface Options {\n requireSSL?: boolean;\n throwOnInvalid?: boolean;\n}\n\ntype SanitizedRedirectUrl<OptionsArg extends Options> =\n OptionsArg['throwOnInvalid'] extends false ? URL | undefined : URL;\n\nconst FILE_URI_MATCH = /\\/\\/\\//;\nconst INVALID_RELATIVE_URL = /[/\\\\][/\\\\]/;\nconst WHITESPACE_CHARACTER = /\\s/;\nconst VALID_PROTOCOLS = ['https:', 'http:'];\n\nfunction isSafe(\n domain: string,\n redirectUrl: unknown,\n requireSSL: boolean | undefined = true,\n): redirectUrl is string {\n if (typeof redirectUrl !== 'string') {\n return false;\n }\n\n if (\n FILE_URI_MATCH.test(redirectUrl) ||\n WHITESPACE_CHARACTER.test(redirectUrl)\n ) {\n return false;\n }\n\n let url: URL;\n\n try {\n url = new URL(redirectUrl, domain);\n } catch (_error) {\n return false;\n }\n\n if (INVALID_RELATIVE_URL.test(url.pathname)) {\n return false;\n }\n\n if (!VALID_PROTOCOLS.includes(url.protocol)) {\n return false;\n }\n\n if (requireSSL && url.protocol !== 'https:') {\n return false;\n }\n\n return true;\n}\n\nexport function sanitizeRedirectUrl<OptionsArg extends Options>(\n domain: string,\n redirectUrl: unknown,\n options: OptionsArg = {} as OptionsArg,\n): SanitizedRedirectUrl<OptionsArg> {\n if (isSafe(domain, redirectUrl, options.requireSSL)) {\n return new URL(redirectUrl, domain) as SanitizedRedirectUrl<OptionsArg>;\n } else if (options.throwOnInvalid === false) {\n return undefined as SanitizedRedirectUrl<OptionsArg>;\n } else {\n throw new ShopifyError('Invalid URL. Refusing to redirect');\n }\n}\n"],"names":["ShopifyError"],"mappings":";;;;AAUA,MAAM,cAAc,GAAG,QAAQ;AAC/B,MAAM,oBAAoB,GAAG,YAAY;AACzC,MAAM,oBAAoB,GAAG,IAAI;AACjC,MAAM,eAAe,GAAG,CAAC,QAAQ,EAAE,OAAO,CAAC;AAE3C,SAAS,MAAM,CACb,MAAc,EACd,WAAoB,EACpB,aAAkC,IAAI,EAAA;AAEtC,IAAA,IAAI,OAAO,WAAW,KAAK,QAAQ,EAAE;AACnC,QAAA,OAAO,KAAK;IACd;AAEA,IAAA,IACE,cAAc,CAAC,IAAI,CAAC,WAAW,CAAC;AAChC,QAAA,oBAAoB,CAAC,IAAI,CAAC,WAAW,CAAC,EACtC;AACA,QAAA,OAAO,KAAK;IACd;AAEA,IAAA,IAAI,GAAQ;AAEZ,IAAA,IAAI;QACF,GAAG,GAAG,IAAI,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC;IACpC;IAAE,OAAO,MAAM,EAAE;AACf,QAAA,OAAO,KAAK;IACd;IAEA,IAAI,oBAAoB,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE;AAC3C,QAAA,OAAO,KAAK;IACd;IAEA,IAAI,CAAC,eAAe,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE;AAC3C,QAAA,OAAO,KAAK;IACd;IAEA,IAAI,UAAU,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE;AAC3C,QAAA,OAAO,KAAK;IACd;AAEA,IAAA,OAAO,IAAI;AACb;AAEM,SAAU,mBAAmB,CACjC,MAAc,EACd,WAAoB,EACpB,UAAsB,EAAgB,EAAA;IAEtC,IAAI,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,UAAU,CAAC,EAAE;AACnD,QAAA,OAAO,IAAI,GAAG,CAAC,WAAW,EAAE,MAAM,CAAqC;IACzE;AAAO,SAAA,IAAI,OAAO,CAAC,cAAc,KAAK,KAAK,EAAE;AAC3C,QAAA,OAAO,SAA6C;IACtD;SAAO;AACL,QAAA,MAAM,IAAIA,uBAAY,CAAC,mCAAmC,CAAC;IAC7D;AACF;;;;"}
1
+ {"version":3,"file":"validate-redirect-url.js","sources":["../../../../../../../src/server/authenticate/admin/helpers/validate-redirect-url.ts"],"sourcesContent":["import {ShopifyError} from '@shopify/shopify-api';\n\ninterface Options {\n requireSSL?: boolean;\n throwOnInvalid?: boolean;\n}\n\ntype SanitizedRedirectUrl<OptionsArg extends Options> =\n OptionsArg['throwOnInvalid'] extends false ? URL | undefined : URL;\n\nconst FILE_URI_MATCH = /\\/\\/\\//;\nconst INVALID_RELATIVE_URL = /[/\\\\][/\\\\]/;\nconst WHITESPACE_CHARACTER = /\\s/;\nconst VALID_PROTOCOLS = ['https:', 'http:'];\n\nfunction isSafe(\n domain: string,\n redirectUrl: unknown,\n requireSSL: boolean | undefined = true,\n): redirectUrl is string {\n if (typeof redirectUrl !== 'string') {\n return false;\n }\n\n if (\n FILE_URI_MATCH.test(redirectUrl) ||\n WHITESPACE_CHARACTER.test(redirectUrl)\n ) {\n return false;\n }\n\n let url: URL;\n\n try {\n url = new URL(redirectUrl, domain);\n } catch (_error) {\n return false;\n }\n\n if (INVALID_RELATIVE_URL.test(url.pathname)) {\n return false;\n }\n\n if (!VALID_PROTOCOLS.includes(url.protocol)) {\n return false;\n }\n\n if (requireSSL && url.protocol !== 'https:') {\n return false;\n }\n\n return true;\n}\n\nexport function sanitizeRedirectUrl<OptionsArg extends Options>(\n domain: string,\n redirectUrl: unknown,\n options: OptionsArg = {} as OptionsArg,\n): SanitizedRedirectUrl<OptionsArg> {\n if (isSafe(domain, redirectUrl, options.requireSSL)) {\n return new URL(redirectUrl, domain) as SanitizedRedirectUrl<OptionsArg>;\n } else if (options.throwOnInvalid === false) {\n return undefined as SanitizedRedirectUrl<OptionsArg>;\n } else {\n throw new ShopifyError('Invalid URL. Refusing to redirect');\n }\n}\n\nconst SHOPIFY_ADMIN_HOST = 'admin.shopify.com';\nconst SHOP_ADMIN_HOST_REGEX = /\\.myshopify\\.com$/i;\n\n/**\n * Determines whether an exit-iframe destination is one we trust enough to\n * redirect to.\n *\n * The destination comes straight from the request URL, so it is limited to:\n *\n * - the app's own origin, including relative paths (used by the OAuth flow), or\n * - the Shopify admin: `admin.shopify.com`, or `<shop>.myshopify.com` under\n * `/admin` (where billing confirmation URLs live).\n *\n * Other Shopify-owned hosts are intentionally excluded, including storefronts\n * (`<shop>.myshopify.com/`), the CDN (`cdn.shopify.com`) and the community\n * forums (`community.shopify.com`), because their content is not controlled by\n * the app or the admin. Anything not explicitly allowed is refused.\n */\nexport function isTrustedExitIframeDestination(\n appUrl: string,\n destination: unknown,\n): destination is string {\n if (typeof destination !== 'string' || destination.trim() === '') {\n return false;\n }\n\n let destinationUrl: URL;\n let appOrigin: string;\n try {\n destinationUrl = new URL(destination, appUrl);\n appOrigin = new URL(appUrl).origin;\n } catch (_error) {\n return false;\n }\n\n // The app's own origin (including relative paths) is always allowed.\n if (destinationUrl.origin === appOrigin) {\n return true;\n }\n\n // Every other destination must be a Shopify admin URL served over https with\n // no custom port.\n if (destinationUrl.protocol !== 'https:' || destinationUrl.port !== '') {\n return false;\n }\n\n const {hostname, pathname} = destinationUrl;\n\n if (hostname === SHOPIFY_ADMIN_HOST) {\n return true;\n }\n\n return SHOP_ADMIN_HOST_REGEX.test(hostname) && isAdminPath(pathname);\n}\n\nfunction isAdminPath(pathname: string): boolean {\n return pathname === '/admin' || pathname.startsWith('/admin/');\n}\n"],"names":["ShopifyError"],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { ShopifyError } from '@shopify/shopify-api';
1
2
  import { ensureCORSHeadersFactory } from '../helpers/ensure-cors-headers.mjs';
2
3
  import { validateSessionToken } from '../helpers/validate-session-token.mjs';
3
4
  import { getSessionTokenHeader, getSessionTokenFromUrlParam } from '../helpers/get-session-token-header.mjs';
@@ -14,10 +15,10 @@ import { updateUsageCappedAmountFactory } from './billing/update-usage-subscript
14
15
  import { createAdminApiContext } from './helpers/create-admin-api-context.mjs';
15
16
  import { ensureAppIsEmbeddedIfRequired } from './helpers/ensure-app-is-embedded-if-required.mjs';
16
17
  import { ensureSessionTokenSearchParamIfRequired } from './helpers/ensure-session-token-search-param-if-required.mjs';
17
- import '@shopify/shopify-api';
18
18
  import { redirectFactory } from './helpers/redirect.mjs';
19
19
  import { renderAppBridge } from './helpers/render-app-bridge.mjs';
20
20
  import { validateShopAndHostParams } from './helpers/validate-shop-and-host-params.mjs';
21
+ import { isTrustedExitIframeDestination } from './helpers/validate-redirect-url.mjs';
21
22
  import { scopesApiFactory } from './scope/factory.mjs';
22
23
 
23
24
  function authStrategyFactory({ strategy, ...params }) {
@@ -35,6 +36,10 @@ function authStrategyFactory({ strategy, ...params }) {
35
36
  const url = new URL(request.url);
36
37
  if (url.pathname === config.auth.exitIframePath) {
37
38
  const destination = url.searchParams.get('exitIframe');
39
+ if (!isTrustedExitIframeDestination(config.appUrl, destination)) {
40
+ logger.warning('Rejected exit-iframe redirect to an untrusted destination', { shop: getShopFromRequest(request), destination });
41
+ throw new ShopifyError('Invalid exit-iframe destination. Refusing to redirect.');
42
+ }
38
43
  logger.debug('Rendering exit iframe page', {
39
44
  shop: getShopFromRequest(request),
40
45
  destination,
@@ -1 +1 @@
1
- {"version":3,"file":"authenticate.mjs","sources":["../../../../../../src/server/authenticate/admin/authenticate.ts"],"sourcesContent":["import {JwtPayload, Session} from '@shopify/shopify-api';\n\nimport type {BasicParams} from '../../types';\nimport type {AppConfigArg} from '../../config-types';\nimport {\n getSessionTokenHeader,\n ensureCORSHeadersFactory,\n getSessionTokenFromUrlParam,\n respondToBotRequest,\n respondToOptionsRequest,\n validateSessionToken,\n getShopFromRequest,\n} from '../helpers';\n\nimport {\n cancelBillingFactory,\n requestBillingFactory,\n requireBillingFactory,\n checkBillingFactory,\n createUsageRecordFactory,\n updateUsageCappedAmountFactory,\n} from './billing';\nimport type {\n AdminContext,\n AuthenticateAdmin,\n EmbeddedAdminContext,\n NonEmbeddedAdminContext,\n} from './types';\nimport {\n createAdminApiContext,\n ensureAppIsEmbeddedIfRequired,\n ensureSessionTokenSearchParamIfRequired,\n redirectFactory,\n renderAppBridge,\n validateShopAndHostParams,\n} from './helpers';\nimport {AuthorizationStrategy} from './strategies/types';\nimport {scopesApiFactory} from './scope/factory';\n\nexport interface SessionTokenContext {\n shop: string;\n sessionId?: string;\n sessionToken?: string;\n payload?: JwtPayload;\n}\n\ninterface AuthStrategyParams extends BasicParams {\n strategy: AuthorizationStrategy;\n}\n\nexport function authStrategyFactory<ConfigArg extends AppConfigArg>({\n strategy,\n ...params\n}: AuthStrategyParams): AuthenticateAdmin<ConfigArg> {\n const {api, logger, config} = params;\n\n async function respondToBouncePageRequest(request: Request) {\n const url = new URL(request.url);\n\n if (url.pathname === config.auth.patchSessionTokenPath) {\n logger.debug('Rendering bounce page', {\n shop: getShopFromRequest(request),\n });\n throw renderAppBridge({config, logger, api}, request);\n }\n }\n\n async function respondToExitIframeRequest(request: Request) {\n const url = new URL(request.url);\n\n if (url.pathname === config.auth.exitIframePath) {\n const destination = url.searchParams.get('exitIframe')!;\n\n logger.debug('Rendering exit iframe page', {\n shop: getShopFromRequest(request),\n destination,\n });\n throw renderAppBridge({config, logger, api}, request, {url: destination});\n }\n }\n\n type AdminContextBase =\n EmbeddedAdminContext<ConfigArg> | NonEmbeddedAdminContext<ConfigArg>;\n\n function createContext(\n request: Request,\n session: Session,\n authStrategy: AuthorizationStrategy,\n sessionToken?: JwtPayload,\n ): AdminContext<ConfigArg> {\n let context: AdminContextBase = {\n admin: createAdminApiContext(\n session,\n params,\n authStrategy.handleClientError(request),\n ),\n billing: {\n require: requireBillingFactory(params, request, session),\n check: checkBillingFactory(params, request, session),\n request: requestBillingFactory(params, request, session),\n cancel: cancelBillingFactory(params, request, session),\n createUsageRecord: createUsageRecordFactory(params, request, session),\n updateUsageCappedAmount: updateUsageCappedAmountFactory(\n params,\n request,\n session,\n ),\n },\n\n session,\n cors: ensureCORSHeadersFactory(params, request),\n };\n\n context = addEmbeddedFeatures(context, request, session, sessionToken);\n context = addScopesFeatures(context);\n\n return context as AdminContext<ConfigArg>;\n }\n\n function addEmbeddedFeatures(\n context: AdminContextBase,\n request: Request,\n session: Session,\n sessionToken?: JwtPayload,\n ) {\n if (config.isEmbeddedApp) {\n return {\n ...context,\n sessionToken,\n redirect: redirectFactory(params, request, session.shop),\n };\n }\n return context;\n }\n\n function addScopesFeatures(context: AdminContextBase) {\n return {\n ...context,\n scopes: scopesApiFactory(params, context.session, context.admin),\n };\n }\n\n return async function authenticateAdmin(request: Request) {\n try {\n respondToBotRequest(params, request);\n respondToOptionsRequest(params, request);\n await respondToBouncePageRequest(request);\n await respondToExitIframeRequest(request);\n await strategy.respondToOAuthRequests(request);\n\n // If this is a valid request, but it doesn't have a session token header, this is a document request. We need to\n // ensure we're embedded if needed and we have the information needed to load the session.\n if (!getSessionTokenHeader(request)) {\n validateShopAndHostParams(params, request);\n await ensureAppIsEmbeddedIfRequired(params, request);\n await ensureSessionTokenSearchParamIfRequired(params, request);\n }\n\n const {payload, shop, sessionId, sessionToken} =\n await getSessionTokenContext(params, request);\n\n logger.info('Authenticating admin request', {shop});\n\n logger.debug('Loading session from storage', {shop, sessionId});\n const existingSession = sessionId\n ? await config.sessionStorage!.loadSession(sessionId)\n : undefined;\n\n const session = await strategy.authenticate(request, {\n session: existingSession,\n sessionToken,\n shop,\n });\n\n return createContext(request, session, strategy, payload);\n } catch (errorOrResponse) {\n if (errorOrResponse instanceof Response) {\n logger.debug('Authenticate returned a response', {\n shop: getShopFromRequest(request),\n });\n ensureCORSHeadersFactory(params, request)(errorOrResponse);\n }\n\n throw errorOrResponse;\n }\n };\n}\n\nasync function getSessionTokenContext(\n params: BasicParams,\n request: Request,\n): Promise<SessionTokenContext> {\n const {api, config, logger} = params;\n\n const headerSessionToken = getSessionTokenHeader(request);\n const searchParamSessionToken = getSessionTokenFromUrlParam(request);\n const sessionToken = (headerSessionToken || searchParamSessionToken)!;\n\n logger.debug('Attempting to authenticate session token', {\n shop: getShopFromRequest(request),\n sessionToken: JSON.stringify({\n header: headerSessionToken,\n search: searchParamSessionToken,\n }),\n });\n\n if (config.isEmbeddedApp) {\n const payload = await validateSessionToken(params, request, sessionToken);\n const dest = new URL(payload.dest);\n const shop = dest.hostname;\n\n logger.debug('Session token is valid - authenticated', {shop, payload});\n const sessionId = config.useOnlineTokens\n ? api.session.getJwtSessionId(shop, payload.sub)\n : api.session.getOfflineId(shop);\n\n return {shop, payload, sessionId, sessionToken};\n }\n\n const url = new URL(request.url);\n const shop = url.searchParams.get('shop')!;\n\n const sessionId = await api.session.getCurrentId({\n isOnline: config.useOnlineTokens,\n rawRequest: request,\n });\n\n return {shop, sessionId, payload: undefined, sessionToken};\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"authenticate.mjs","sources":["../../../../../../src/server/authenticate/admin/authenticate.ts"],"sourcesContent":["import {JwtPayload, Session, ShopifyError} from '@shopify/shopify-api';\n\nimport type {BasicParams} from '../../types';\nimport type {AppConfigArg} from '../../config-types';\nimport {\n getSessionTokenHeader,\n ensureCORSHeadersFactory,\n getSessionTokenFromUrlParam,\n respondToBotRequest,\n respondToOptionsRequest,\n validateSessionToken,\n getShopFromRequest,\n} from '../helpers';\n\nimport {\n cancelBillingFactory,\n requestBillingFactory,\n requireBillingFactory,\n checkBillingFactory,\n createUsageRecordFactory,\n updateUsageCappedAmountFactory,\n} from './billing';\nimport type {\n AdminContext,\n AuthenticateAdmin,\n EmbeddedAdminContext,\n NonEmbeddedAdminContext,\n} from './types';\nimport {\n createAdminApiContext,\n ensureAppIsEmbeddedIfRequired,\n ensureSessionTokenSearchParamIfRequired,\n isTrustedExitIframeDestination,\n redirectFactory,\n renderAppBridge,\n validateShopAndHostParams,\n} from './helpers';\nimport {AuthorizationStrategy} from './strategies/types';\nimport {scopesApiFactory} from './scope/factory';\n\nexport interface SessionTokenContext {\n shop: string;\n sessionId?: string;\n sessionToken?: string;\n payload?: JwtPayload;\n}\n\ninterface AuthStrategyParams extends BasicParams {\n strategy: AuthorizationStrategy;\n}\n\nexport function authStrategyFactory<ConfigArg extends AppConfigArg>({\n strategy,\n ...params\n}: AuthStrategyParams): AuthenticateAdmin<ConfigArg> {\n const {api, logger, config} = params;\n\n async function respondToBouncePageRequest(request: Request) {\n const url = new URL(request.url);\n\n if (url.pathname === config.auth.patchSessionTokenPath) {\n logger.debug('Rendering bounce page', {\n shop: getShopFromRequest(request),\n });\n throw renderAppBridge({config, logger, api}, request);\n }\n }\n\n async function respondToExitIframeRequest(request: Request) {\n const url = new URL(request.url);\n\n if (url.pathname === config.auth.exitIframePath) {\n const destination = url.searchParams.get('exitIframe');\n\n if (!isTrustedExitIframeDestination(config.appUrl, destination)) {\n logger.warning(\n 'Rejected exit-iframe redirect to an untrusted destination',\n {shop: getShopFromRequest(request), destination},\n );\n throw new ShopifyError(\n 'Invalid exit-iframe destination. Refusing to redirect.',\n );\n }\n\n logger.debug('Rendering exit iframe page', {\n shop: getShopFromRequest(request),\n destination,\n });\n throw renderAppBridge({config, logger, api}, request, {url: destination});\n }\n }\n\n type AdminContextBase =\n EmbeddedAdminContext<ConfigArg> | NonEmbeddedAdminContext<ConfigArg>;\n\n function createContext(\n request: Request,\n session: Session,\n authStrategy: AuthorizationStrategy,\n sessionToken?: JwtPayload,\n ): AdminContext<ConfigArg> {\n let context: AdminContextBase = {\n admin: createAdminApiContext(\n session,\n params,\n authStrategy.handleClientError(request),\n ),\n billing: {\n require: requireBillingFactory(params, request, session),\n check: checkBillingFactory(params, request, session),\n request: requestBillingFactory(params, request, session),\n cancel: cancelBillingFactory(params, request, session),\n createUsageRecord: createUsageRecordFactory(params, request, session),\n updateUsageCappedAmount: updateUsageCappedAmountFactory(\n params,\n request,\n session,\n ),\n },\n\n session,\n cors: ensureCORSHeadersFactory(params, request),\n };\n\n context = addEmbeddedFeatures(context, request, session, sessionToken);\n context = addScopesFeatures(context);\n\n return context as AdminContext<ConfigArg>;\n }\n\n function addEmbeddedFeatures(\n context: AdminContextBase,\n request: Request,\n session: Session,\n sessionToken?: JwtPayload,\n ) {\n if (config.isEmbeddedApp) {\n return {\n ...context,\n sessionToken,\n redirect: redirectFactory(params, request, session.shop),\n };\n }\n return context;\n }\n\n function addScopesFeatures(context: AdminContextBase) {\n return {\n ...context,\n scopes: scopesApiFactory(params, context.session, context.admin),\n };\n }\n\n return async function authenticateAdmin(request: Request) {\n try {\n respondToBotRequest(params, request);\n respondToOptionsRequest(params, request);\n await respondToBouncePageRequest(request);\n await respondToExitIframeRequest(request);\n await strategy.respondToOAuthRequests(request);\n\n // If this is a valid request, but it doesn't have a session token header, this is a document request. We need to\n // ensure we're embedded if needed and we have the information needed to load the session.\n if (!getSessionTokenHeader(request)) {\n validateShopAndHostParams(params, request);\n await ensureAppIsEmbeddedIfRequired(params, request);\n await ensureSessionTokenSearchParamIfRequired(params, request);\n }\n\n const {payload, shop, sessionId, sessionToken} =\n await getSessionTokenContext(params, request);\n\n logger.info('Authenticating admin request', {shop});\n\n logger.debug('Loading session from storage', {shop, sessionId});\n const existingSession = sessionId\n ? await config.sessionStorage!.loadSession(sessionId)\n : undefined;\n\n const session = await strategy.authenticate(request, {\n session: existingSession,\n sessionToken,\n shop,\n });\n\n return createContext(request, session, strategy, payload);\n } catch (errorOrResponse) {\n if (errorOrResponse instanceof Response) {\n logger.debug('Authenticate returned a response', {\n shop: getShopFromRequest(request),\n });\n ensureCORSHeadersFactory(params, request)(errorOrResponse);\n }\n\n throw errorOrResponse;\n }\n };\n}\n\nasync function getSessionTokenContext(\n params: BasicParams,\n request: Request,\n): Promise<SessionTokenContext> {\n const {api, config, logger} = params;\n\n const headerSessionToken = getSessionTokenHeader(request);\n const searchParamSessionToken = getSessionTokenFromUrlParam(request);\n const sessionToken = (headerSessionToken || searchParamSessionToken)!;\n\n logger.debug('Attempting to authenticate session token', {\n shop: getShopFromRequest(request),\n sessionToken: JSON.stringify({\n header: headerSessionToken,\n search: searchParamSessionToken,\n }),\n });\n\n if (config.isEmbeddedApp) {\n const payload = await validateSessionToken(params, request, sessionToken);\n const dest = new URL(payload.dest);\n const shop = dest.hostname;\n\n logger.debug('Session token is valid - authenticated', {shop, payload});\n const sessionId = config.useOnlineTokens\n ? api.session.getJwtSessionId(shop, payload.sub)\n : api.session.getOfflineId(shop);\n\n return {shop, payload, sessionId, sessionToken};\n }\n\n const url = new URL(request.url);\n const shop = url.searchParams.get('shop')!;\n\n const sessionId = await api.session.getCurrentId({\n isOnline: config.useOnlineTokens,\n rawRequest: request,\n });\n\n return {shop, sessionId, payload: undefined, sessionToken};\n}\n"],"names":[],"mappings":"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"}
@@ -41,6 +41,54 @@ function sanitizeRedirectUrl(domain, redirectUrl, options = {}) {
41
41
  throw new ShopifyError('Invalid URL. Refusing to redirect');
42
42
  }
43
43
  }
44
+ const SHOPIFY_ADMIN_HOST = 'admin.shopify.com';
45
+ const SHOP_ADMIN_HOST_REGEX = /\.myshopify\.com$/i;
46
+ /**
47
+ * Determines whether an exit-iframe destination is one we trust enough to
48
+ * redirect to.
49
+ *
50
+ * The destination comes straight from the request URL, so it is limited to:
51
+ *
52
+ * - the app's own origin, including relative paths (used by the OAuth flow), or
53
+ * - the Shopify admin: `admin.shopify.com`, or `<shop>.myshopify.com` under
54
+ * `/admin` (where billing confirmation URLs live).
55
+ *
56
+ * Other Shopify-owned hosts are intentionally excluded, including storefronts
57
+ * (`<shop>.myshopify.com/`), the CDN (`cdn.shopify.com`) and the community
58
+ * forums (`community.shopify.com`), because their content is not controlled by
59
+ * the app or the admin. Anything not explicitly allowed is refused.
60
+ */
61
+ function isTrustedExitIframeDestination(appUrl, destination) {
62
+ if (typeof destination !== 'string' || destination.trim() === '') {
63
+ return false;
64
+ }
65
+ let destinationUrl;
66
+ let appOrigin;
67
+ try {
68
+ destinationUrl = new URL(destination, appUrl);
69
+ appOrigin = new URL(appUrl).origin;
70
+ }
71
+ catch (_error) {
72
+ return false;
73
+ }
74
+ // The app's own origin (including relative paths) is always allowed.
75
+ if (destinationUrl.origin === appOrigin) {
76
+ return true;
77
+ }
78
+ // Every other destination must be a Shopify admin URL served over https with
79
+ // no custom port.
80
+ if (destinationUrl.protocol !== 'https:' || destinationUrl.port !== '') {
81
+ return false;
82
+ }
83
+ const { hostname, pathname } = destinationUrl;
84
+ if (hostname === SHOPIFY_ADMIN_HOST) {
85
+ return true;
86
+ }
87
+ return SHOP_ADMIN_HOST_REGEX.test(hostname) && isAdminPath(pathname);
88
+ }
89
+ function isAdminPath(pathname) {
90
+ return pathname === '/admin' || pathname.startsWith('/admin/');
91
+ }
44
92
 
45
- export { sanitizeRedirectUrl };
93
+ export { isTrustedExitIframeDestination, sanitizeRedirectUrl };
46
94
  //# sourceMappingURL=validate-redirect-url.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"validate-redirect-url.mjs","sources":["../../../../../../../src/server/authenticate/admin/helpers/validate-redirect-url.ts"],"sourcesContent":["import {ShopifyError} from '@shopify/shopify-api';\n\ninterface Options {\n requireSSL?: boolean;\n throwOnInvalid?: boolean;\n}\n\ntype SanitizedRedirectUrl<OptionsArg extends Options> =\n OptionsArg['throwOnInvalid'] extends false ? URL | undefined : URL;\n\nconst FILE_URI_MATCH = /\\/\\/\\//;\nconst INVALID_RELATIVE_URL = /[/\\\\][/\\\\]/;\nconst WHITESPACE_CHARACTER = /\\s/;\nconst VALID_PROTOCOLS = ['https:', 'http:'];\n\nfunction isSafe(\n domain: string,\n redirectUrl: unknown,\n requireSSL: boolean | undefined = true,\n): redirectUrl is string {\n if (typeof redirectUrl !== 'string') {\n return false;\n }\n\n if (\n FILE_URI_MATCH.test(redirectUrl) ||\n WHITESPACE_CHARACTER.test(redirectUrl)\n ) {\n return false;\n }\n\n let url: URL;\n\n try {\n url = new URL(redirectUrl, domain);\n } catch (_error) {\n return false;\n }\n\n if (INVALID_RELATIVE_URL.test(url.pathname)) {\n return false;\n }\n\n if (!VALID_PROTOCOLS.includes(url.protocol)) {\n return false;\n }\n\n if (requireSSL && url.protocol !== 'https:') {\n return false;\n }\n\n return true;\n}\n\nexport function sanitizeRedirectUrl<OptionsArg extends Options>(\n domain: string,\n redirectUrl: unknown,\n options: OptionsArg = {} as OptionsArg,\n): SanitizedRedirectUrl<OptionsArg> {\n if (isSafe(domain, redirectUrl, options.requireSSL)) {\n return new URL(redirectUrl, domain) as SanitizedRedirectUrl<OptionsArg>;\n } else if (options.throwOnInvalid === false) {\n return undefined as SanitizedRedirectUrl<OptionsArg>;\n } else {\n throw new ShopifyError('Invalid URL. Refusing to redirect');\n }\n}\n"],"names":[],"mappings":";;AAUA,MAAM,cAAc,GAAG,QAAQ;AAC/B,MAAM,oBAAoB,GAAG,YAAY;AACzC,MAAM,oBAAoB,GAAG,IAAI;AACjC,MAAM,eAAe,GAAG,CAAC,QAAQ,EAAE,OAAO,CAAC;AAE3C,SAAS,MAAM,CACb,MAAc,EACd,WAAoB,EACpB,aAAkC,IAAI,EAAA;AAEtC,IAAA,IAAI,OAAO,WAAW,KAAK,QAAQ,EAAE;AACnC,QAAA,OAAO,KAAK;IACd;AAEA,IAAA,IACE,cAAc,CAAC,IAAI,CAAC,WAAW,CAAC;AAChC,QAAA,oBAAoB,CAAC,IAAI,CAAC,WAAW,CAAC,EACtC;AACA,QAAA,OAAO,KAAK;IACd;AAEA,IAAA,IAAI,GAAQ;AAEZ,IAAA,IAAI;QACF,GAAG,GAAG,IAAI,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC;IACpC;IAAE,OAAO,MAAM,EAAE;AACf,QAAA,OAAO,KAAK;IACd;IAEA,IAAI,oBAAoB,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE;AAC3C,QAAA,OAAO,KAAK;IACd;IAEA,IAAI,CAAC,eAAe,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE;AAC3C,QAAA,OAAO,KAAK;IACd;IAEA,IAAI,UAAU,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE;AAC3C,QAAA,OAAO,KAAK;IACd;AAEA,IAAA,OAAO,IAAI;AACb;AAEM,SAAU,mBAAmB,CACjC,MAAc,EACd,WAAoB,EACpB,UAAsB,EAAgB,EAAA;IAEtC,IAAI,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,CAAC,UAAU,CAAC,EAAE;AACnD,QAAA,OAAO,IAAI,GAAG,CAAC,WAAW,EAAE,MAAM,CAAqC;IACzE;AAAO,SAAA,IAAI,OAAO,CAAC,cAAc,KAAK,KAAK,EAAE;AAC3C,QAAA,OAAO,SAA6C;IACtD;SAAO;AACL,QAAA,MAAM,IAAI,YAAY,CAAC,mCAAmC,CAAC;IAC7D;AACF;;;;"}
1
+ {"version":3,"file":"validate-redirect-url.mjs","sources":["../../../../../../../src/server/authenticate/admin/helpers/validate-redirect-url.ts"],"sourcesContent":["import {ShopifyError} from '@shopify/shopify-api';\n\ninterface Options {\n requireSSL?: boolean;\n throwOnInvalid?: boolean;\n}\n\ntype SanitizedRedirectUrl<OptionsArg extends Options> =\n OptionsArg['throwOnInvalid'] extends false ? URL | undefined : URL;\n\nconst FILE_URI_MATCH = /\\/\\/\\//;\nconst INVALID_RELATIVE_URL = /[/\\\\][/\\\\]/;\nconst WHITESPACE_CHARACTER = /\\s/;\nconst VALID_PROTOCOLS = ['https:', 'http:'];\n\nfunction isSafe(\n domain: string,\n redirectUrl: unknown,\n requireSSL: boolean | undefined = true,\n): redirectUrl is string {\n if (typeof redirectUrl !== 'string') {\n return false;\n }\n\n if (\n FILE_URI_MATCH.test(redirectUrl) ||\n WHITESPACE_CHARACTER.test(redirectUrl)\n ) {\n return false;\n }\n\n let url: URL;\n\n try {\n url = new URL(redirectUrl, domain);\n } catch (_error) {\n return false;\n }\n\n if (INVALID_RELATIVE_URL.test(url.pathname)) {\n return false;\n }\n\n if (!VALID_PROTOCOLS.includes(url.protocol)) {\n return false;\n }\n\n if (requireSSL && url.protocol !== 'https:') {\n return false;\n }\n\n return true;\n}\n\nexport function sanitizeRedirectUrl<OptionsArg extends Options>(\n domain: string,\n redirectUrl: unknown,\n options: OptionsArg = {} as OptionsArg,\n): SanitizedRedirectUrl<OptionsArg> {\n if (isSafe(domain, redirectUrl, options.requireSSL)) {\n return new URL(redirectUrl, domain) as SanitizedRedirectUrl<OptionsArg>;\n } else if (options.throwOnInvalid === false) {\n return undefined as SanitizedRedirectUrl<OptionsArg>;\n } else {\n throw new ShopifyError('Invalid URL. Refusing to redirect');\n }\n}\n\nconst SHOPIFY_ADMIN_HOST = 'admin.shopify.com';\nconst SHOP_ADMIN_HOST_REGEX = /\\.myshopify\\.com$/i;\n\n/**\n * Determines whether an exit-iframe destination is one we trust enough to\n * redirect to.\n *\n * The destination comes straight from the request URL, so it is limited to:\n *\n * - the app's own origin, including relative paths (used by the OAuth flow), or\n * - the Shopify admin: `admin.shopify.com`, or `<shop>.myshopify.com` under\n * `/admin` (where billing confirmation URLs live).\n *\n * Other Shopify-owned hosts are intentionally excluded, including storefronts\n * (`<shop>.myshopify.com/`), the CDN (`cdn.shopify.com`) and the community\n * forums (`community.shopify.com`), because their content is not controlled by\n * the app or the admin. Anything not explicitly allowed is refused.\n */\nexport function isTrustedExitIframeDestination(\n appUrl: string,\n destination: unknown,\n): destination is string {\n if (typeof destination !== 'string' || destination.trim() === '') {\n return false;\n }\n\n let destinationUrl: URL;\n let appOrigin: string;\n try {\n destinationUrl = new URL(destination, appUrl);\n appOrigin = new URL(appUrl).origin;\n } catch (_error) {\n return false;\n }\n\n // The app's own origin (including relative paths) is always allowed.\n if (destinationUrl.origin === appOrigin) {\n return true;\n }\n\n // Every other destination must be a Shopify admin URL served over https with\n // no custom port.\n if (destinationUrl.protocol !== 'https:' || destinationUrl.port !== '') {\n return false;\n }\n\n const {hostname, pathname} = destinationUrl;\n\n if (hostname === SHOPIFY_ADMIN_HOST) {\n return true;\n }\n\n return SHOP_ADMIN_HOST_REGEX.test(hostname) && isAdminPath(pathname);\n}\n\nfunction isAdminPath(pathname: string): boolean {\n return pathname === '/admin' || pathname.startsWith('/admin/');\n}\n"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"authenticate.d.ts","sourceRoot":"","sources":["../../../../../src/server/authenticate/admin/authenticate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,UAAU,EAAU,MAAM,sBAAsB,CAAC;AAEzD,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,oBAAoB,CAAC;AAmBrD,OAAO,KAAK,EAEV,iBAAiB,EAGlB,MAAM,SAAS,CAAC;AASjB,OAAO,EAAC,qBAAqB,EAAC,MAAM,oBAAoB,CAAC;AAGzD,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,UAAU,CAAC;CACtB;AAED,UAAU,kBAAmB,SAAQ,WAAW;IAC9C,QAAQ,EAAE,qBAAqB,CAAC;CACjC;AAED,wBAAgB,mBAAmB,CAAC,SAAS,SAAS,YAAY,EAAE,EAClE,QAAQ,EACR,GAAG,MAAM,EACV,EAAE,kBAAkB,GAAG,iBAAiB,CAAC,SAAS,CAAC,CAqInD"}
1
+ {"version":3,"file":"authenticate.d.ts","sourceRoot":"","sources":["../../../../../src/server/authenticate/admin/authenticate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,UAAU,EAAwB,MAAM,sBAAsB,CAAC;AAEvE,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,oBAAoB,CAAC;AAmBrD,OAAO,KAAK,EAEV,iBAAiB,EAGlB,MAAM,SAAS,CAAC;AAUjB,OAAO,EAAC,qBAAqB,EAAC,MAAM,oBAAoB,CAAC;AAGzD,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,UAAU,CAAC;CACtB;AAED,UAAU,kBAAmB,SAAQ,WAAW;IAC9C,QAAQ,EAAE,qBAAqB,CAAC;CACjC;AAED,wBAAgB,mBAAmB,CAAC,SAAS,SAAS,YAAY,EAAE,EAClE,QAAQ,EACR,GAAG,MAAM,EACV,EAAE,kBAAkB,GAAG,iBAAiB,CAAC,SAAS,CAAC,CA+InD"}
@@ -12,4 +12,5 @@ export * from './redirect';
12
12
  export * from './render-app-bridge';
13
13
  export * from './trigger-after-auth-hook';
14
14
  export * from './validate-shop-and-host-params';
15
+ export * from './validate-redirect-url';
15
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../../src/server/authenticate/admin/helpers/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sCAAsC,CAAC;AACrD,cAAc,iDAAiD,CAAC;AAChE,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,mCAAmC,CAAC;AAClD,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC;AACpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iCAAiC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../../src/server/authenticate/admin/helpers/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sCAAsC,CAAC;AACrD,cAAc,iDAAiD,CAAC;AAChE,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,mCAAmC,CAAC;AAClD,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC;AACpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iCAAiC,CAAC;AAChD,cAAc,yBAAyB,CAAC"}
@@ -4,5 +4,21 @@ interface Options {
4
4
  }
5
5
  type SanitizedRedirectUrl<OptionsArg extends Options> = OptionsArg['throwOnInvalid'] extends false ? URL | undefined : URL;
6
6
  export declare function sanitizeRedirectUrl<OptionsArg extends Options>(domain: string, redirectUrl: unknown, options?: OptionsArg): SanitizedRedirectUrl<OptionsArg>;
7
+ /**
8
+ * Determines whether an exit-iframe destination is one we trust enough to
9
+ * redirect to.
10
+ *
11
+ * The destination comes straight from the request URL, so it is limited to:
12
+ *
13
+ * - the app's own origin, including relative paths (used by the OAuth flow), or
14
+ * - the Shopify admin: `admin.shopify.com`, or `<shop>.myshopify.com` under
15
+ * `/admin` (where billing confirmation URLs live).
16
+ *
17
+ * Other Shopify-owned hosts are intentionally excluded, including storefronts
18
+ * (`<shop>.myshopify.com/`), the CDN (`cdn.shopify.com`) and the community
19
+ * forums (`community.shopify.com`), because their content is not controlled by
20
+ * the app or the admin. Anything not explicitly allowed is refused.
21
+ */
22
+ export declare function isTrustedExitIframeDestination(appUrl: string, destination: unknown): destination is string;
7
23
  export {};
8
24
  //# sourceMappingURL=validate-redirect-url.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validate-redirect-url.d.ts","sourceRoot":"","sources":["../../../../../../src/server/authenticate/admin/helpers/validate-redirect-url.ts"],"names":[],"mappings":"AAEA,UAAU,OAAO;IACf,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,KAAK,oBAAoB,CAAC,UAAU,SAAS,OAAO,IAClD,UAAU,CAAC,gBAAgB,CAAC,SAAS,KAAK,GAAG,GAAG,GAAG,SAAS,GAAG,GAAG,CAAC;AA8CrE,wBAAgB,mBAAmB,CAAC,UAAU,SAAS,OAAO,EAC5D,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,OAAO,EACpB,OAAO,GAAE,UAA6B,GACrC,oBAAoB,CAAC,UAAU,CAAC,CAQlC"}
1
+ {"version":3,"file":"validate-redirect-url.d.ts","sourceRoot":"","sources":["../../../../../../src/server/authenticate/admin/helpers/validate-redirect-url.ts"],"names":[],"mappings":"AAEA,UAAU,OAAO;IACf,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,KAAK,oBAAoB,CAAC,UAAU,SAAS,OAAO,IAClD,UAAU,CAAC,gBAAgB,CAAC,SAAS,KAAK,GAAG,GAAG,GAAG,SAAS,GAAG,GAAG,CAAC;AA8CrE,wBAAgB,mBAAmB,CAAC,UAAU,SAAS,OAAO,EAC5D,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,OAAO,EACpB,OAAO,GAAE,UAA6B,GACrC,oBAAoB,CAAC,UAAU,CAAC,CAQlC;AAKD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,OAAO,GACnB,WAAW,IAAI,MAAM,CAgCvB"}