/
githubmirror
/
amphtml
Обзор
Документация
Войти
/
githubmirror
/
amphtml
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
extensions/amp-iframe/0.1/amp-iframe.js
857 строк
24 KB
erwin mombay
add additional forbidden term for ampproject.org (#39803)
08 фев 2024, 00:19
Не верифицирован
08 фев 2024, 00:19
190a7ea
Код
Авторство
О чём код?
import {MessageType_Enum} from '#core/3p-frame-messaging'; import {ActionTrust_Enum} from '#core/constants/action-constants'; import {AMPDOC_SINGLETON_NAME_ENUM} from '#core/constants/enums'; import {removeElement} from '#core/dom'; import { LayoutPriority_Enum, applyFillContent, isLayoutSizeDefined, } from '#core/dom/layout'; import {propagateAttributes} from '#core/dom/propagate-attributes'; import {setStyle} from '#core/dom/style'; import {playIgnoringError} from '#core/dom/video'; import {PauseHelper} from '#core/dom/video/pause-helper'; import {parseJson} from '#core/types/object/json'; import {endsWith} from '#core/types/string'; import {base64EncodeFromBytes} from '#core/types/string/base64'; import {utf8Encode} from '#core/types/string/bytes'; import {isExperimentOn} from '#experiments'; import {Services} from '#service'; import {createCustomEvent, getData, listen} from '#utils/event-helper'; import {IntersectionObserver3pHost} from '#utils/intersection-observer-3p-host'; import {user, userAssert} from '#utils/log'; import {isAdPositionAllowed} from '../../../src/ad-helper'; import * as urls from '../../../src/config/urls'; import {getConsentDataToForward} from '../../../src/consent'; import { isAdLike, listenFor, looksLikeTrackingIframe, } from '../../../src/iframe-helper'; import {removeFragment} from '../../../src/url'; /** @const {string} */ const TAG_ = 'amp-iframe'; /** @const {!Array<string>} */ const ATTRIBUTES_TO_PROPAGATE = [ 'allowfullscreen', 'allowpaymentrequest', 'allowtransparency', 'allow', 'frameborder', 'referrerpolicy', 'scrolling', 'tabindex', 'title', ]; /** @type {number} */ let count = 0; /** @type {number} */ let trackingIframeTimeout = 5000; export class AmpIframe extends AMP.BaseElement { /** @param {!AmpElement} element */ constructor(element) { super(element); /** @private {?Element} */ this.placeholder_ = null; /** @private {boolean} */ this.hasPlaceholder_ = false; /** @private {boolean} */ this.isAdLike_ = false; /** @private {boolean} */ this.isTrackingFrame_ = false; /** @private {boolean} */ this.isDisallowedAsAd_ = false; /** @private {?HTMLIFrameElement} */ this.iframe_ = null; /** @private {boolean} */ this.isResizable_ = false; /** @private {?IntersectionObserver3pHost} */ this.intersectionObserverHostApi_ = null; /** @private {string} */ this.sandbox_ = ''; /** @private {Function} */ this.unlistenPym_ = null; /** * The source of the iframe. May change to null for tracking iframes * to prevent them from being recreated. * @type {?string} **/ this.iframeSrc = null; /** * The element which will contain the iframe. This may be the amp-iframe * itself if the iframe is non-scrolling, or a wrapper element if it is. * @private {?Element} */ this.container_ = null; /** * The origin of URL at `src` attr, if available. Otherwise, null. * @private {?string} */ this.targetOrigin_ = null; /** @private {boolean} */ this.hasErroredEmbedSize_ = false; /** @private @const */ this.pauseHelper_ = new PauseHelper(this.element); } /** @override */ isLayoutSupported(layout) { return isLayoutSizeDefined(layout); } /** * @param {string} src * @param {string} containerSrc * @param {string} sandbox * @return {string} * @private */ assertSource_(src, containerSrc, sandbox = '') { const {element} = this; const urlService = Services.urlForDoc(element); const url = urlService.parse(src); const {hostname, origin, protocol} = url; // Some of these can be easily circumvented with redirects. // Checks are mostly there to prevent people easily do something // they did not mean to. userAssert( urlService.isSecure(src) || protocol == 'data:', 'Invalid <amp-iframe> src. Must start with https://. Found %s', element ); const containerUrl = urlService.parse(containerSrc); userAssert( !this.sandboxContainsToken_(sandbox, 'allow-same-origin') || (origin != containerUrl.origin && protocol != 'data:'), 'Origin of <amp-iframe> must not be equal to container %s' + ' if allow-same-origin is set. See https://github.com/ampproject/' + 'amphtml/blob/main/docs/spec/amp-iframe-origin-policy.md for details.', element ); userAssert( !( endsWith(hostname, `.${urls.thirdPartyFrameHost}`) || // eslint-disable-next-line local/no-forbidden-terms endsWith(hostname, '.ampproject.org') ), 'amp-iframe does not allow embedding of frames from ' + // eslint-disable-next-line local/no-forbidden-terms 'ampproject.*: %s', src ); return src; } /** @private */ assertPosition_() { const pos = this.element.getLayoutBox(); const minTop = Math.min(600, this.getViewport().getSize().height * 0.75); userAssert( pos.top >= minTop, '<amp-iframe> elements must be positioned outside the first 75% ' + 'of the viewport or 600px from the top (whichever is smaller): %s ' + ' Current position %s. Min: %s' + "Positioning rules don't apply for iframes that use `placeholder`." + 'See https://github.com/ampproject/amphtml/blob/main/extensions/' + 'amp-iframe/amp-iframe.md#iframe-with-placeholder for details.', this.element, pos.top, minTop ); } /** * @param {string} sandbox * @param {string} token * @return {boolean} * @private */ sandboxContainsToken_(sandbox, token) { const re = new RegExp(`\\s${token}\\s`, 'i'); return re.test(' ' + sandbox + ' '); } /** * Transforms the src attribute. When possible, it adds `#amp=1` fragment * to indicate that the iframe is running in AMP environment. * @param {?string} src * @return {string|undefined} * @private */ transformSrc_(src) { if (!src) { return; } const {hash, protocol} = Services.urlForDoc(this.element).parse(src); // data-URLs are not modified. if (protocol == 'data:') { return src; } // If fragment already exists, it's not modified. if (hash && hash != '#') { return src; } // Add `#amp=1` fragment. return removeFragment(src) + '#amp=1'; } /** * Transforms the srcdoc attribute if present to an equivalent data URI. * * It may be OK to change this later to leave the `srcdoc` in place and * instead ensure that `allow-same-origin` is not present, but this * implementation has the right security behavior which is that the document * may under no circumstances be able to run JS on the parent. * @param {?string} srcdoc * @param {string} sandbox * @return {string|undefined} Data URI for the srcdoc * @private */ transformSrcDoc_(srcdoc, sandbox) { if (!srcdoc) { return; } userAssert( !(' ' + sandbox + ' ').match(/\s+allow-same-origin\s+/i), 'allow-same-origin is not allowed with the srcdoc attribute %s.', this.element ); return ( 'data:text/html;charset=utf-8;base64,' + base64EncodeFromBytes(utf8Encode(srcdoc)) ); } /** * @param {boolean=} onLayout * @override */ preconnectCallback(onLayout) { if (this.iframeSrc) { Services.preconnectFor(this.win).url( this.getAmpDoc(), this.iframeSrc, onLayout ); } } /** @override */ buildCallback() { this.sandbox_ = this.element.getAttribute('sandbox'); const iframeSrc = /** @type {string} */ ( this.transformSrc_(this.element.getAttribute('src')) || this.transformSrcDoc_( this.element.getAttribute('srcdoc'), this.sandbox_ ) ); this.iframeSrc = this.assertSource_( iframeSrc, window.location.href, this.sandbox_ ); this.placeholder_ = this.getPlaceholder(); this.hasPlaceholder_ = !!this.placeholder_; this.isResizable_ = this.element.hasAttribute('resizable'); if (this.isResizable_) { this.element.setAttribute('scrolling', 'no'); } if (!this.element.hasAttribute('frameborder')) { this.element.setAttribute('frameborder', '0'); } this.container_ = makeIOsScrollable(this.element); this.registerIframeMessaging_(); } /** @override */ onLayoutMeasure() { const {element} = this; this.isAdLike_ = isAdLike(element); this.isTrackingFrame_ = this.looksLikeTrackingIframe_(); this.isDisallowedAsAd_ = this.isAdLike_ && !isAdPositionAllowed(element, this.win); } /** * @return {boolean} * @private */ looksLikeTrackingIframe_() { // It may be tempting to inline this method, but it's referenced in tests. return looksLikeTrackingIframe(this.element); } /** @override */ layoutCallback() { userAssert( !this.isDisallowedAsAd_, 'amp-iframe is not used for ' + 'displaying fixed ad. Please use amp-sticky-ad and amp-ad instead.' ); if (!this.hasPlaceholder_) { this.assertPosition_(); } if (this.isResizable_) { userAssert( this.getOverflowElement(), 'Overflow element must be defined for resizable frames: %s', this.element ); } if (!this.iframeSrc) { // This failed already, lets not signal another error. return Promise.resolve(); } if (this.isTrackingFrame_) { if ( !this.getAmpDoc().registerSingleton( AMPDOC_SINGLETON_NAME_ENUM.TRACKING_IFRAME ) ) { console /*OK*/ .error( 'Only 1 analytics/tracking iframe allowed per ' + 'page. Please use amp-analytics instead or file a GitHub issue ' + 'for your use case: ' + 'https://github.com/ampproject/amphtml/issues/new/choose' ); return Promise.resolve(); } } const iframe = this.element.ownerDocument.createElement('iframe'); this.iframe_ = /** @type {HTMLIFrameElement} */ (iframe); applyFillContent(iframe); iframe.name = 'amp_iframe' + count++; if (this.hasPlaceholder_) { setStyle(iframe, 'zIndex', -1); } propagateAttributes(ATTRIBUTES_TO_PROPAGATE, this.element, iframe); // TEMPORARY: disable `allow=autoplay` // This is a workaround for M72-M74 user-activation breakage. // If this is still here in May 2019, please ping @aghassemi // See https://github.com/ampproject/amphtml/issues/21242 for details. // TODO(aghassemi, #21247) let allowVal = iframe.getAttribute('allow') || ''; // allow syntax is complex, not worth parsing for temp code. allowVal = allowVal.replace('autoplay', 'autoplay-disabled'); iframe.setAttribute('allow', allowVal); setSandbox(this.element, iframe, this.sandbox_); iframe.src = this.iframeSrc; if (!this.isTrackingFrame_) { this.intersectionObserverHostApi_ = new IntersectionObserver3pHost( this, iframe ); } iframe.onload = () => { // Chrome does not reflect the iframe readystate. iframe.readyState = 'complete'; this.activateIframe_(); if (this.isTrackingFrame_) { // Prevent this iframe from ever being recreated. this.iframeSrc = null; Services.timerFor(this.win) .promise(trackingIframeTimeout) .then(() => { removeElement(iframe); this.element.setAttribute('amp-removed', ''); this.iframe_ = null; }); } }; listenFor( iframe, 'embed-size', (data) => { this.updateSize_(data['height'], data['width']); }, /*opt_is3P*/ undefined, /*opt_includingNestedWindows*/ undefined, /*opt_allowOpaqueOrigin*/ true ); // Listen for resize messages sent by Pym.js. this.unlistenPym_ = listen(this.win, 'message', (event) => { return this.listenForPymMessage_(/** @type {!MessageEvent} */ (event)); }); if (this.hasPlaceholder_) { listenFor(iframe, 'embed-ready', this.activateIframe_.bind(this)); } listenFor( iframe, MessageType_Enum.SEND_CONSENT_DATA, (data, source, origin) => { this.sendConsentData_(source, origin); } ); this.container_.appendChild(iframe); return this.loadPromise(iframe).then(() => { // On iOS the iframe at times fails to render inside the `overflow:auto` // container. To avoid this problem, we set the `overflow:auto` property // 1s later via `amp-active` class. if (this.container_ != this.element) { Services.timerFor(this.win).delay(() => { this.mutateElement(() => { this.container_.classList.add('amp-active'); }); }, 1000); } this.pauseHelper_.updatePlaying(true); }); } /** * Listen for Pym.js messages for 'height' and 'width'. * * @see http://blog.apps.npr.org/pym.js/ * @param {!MessageEvent} event * @private */ listenForPymMessage_(event) { if (!this.iframe_ || event.source !== this.iframe_.contentWindow) { return; } const data = getData(event); if (typeof data !== 'string' || !data.startsWith('pym')) { return; } // The format of the message takes the form of `pymxPYMx${id}xPYMx${type}xPYMx${message}`. // The id is unnecessary for integration with amp-iframe; the possible types include // 'height', 'width', 'parentPositionInfo', 'navigateTo', and 'scrollToChildPos'. // Only the 'height' and 'width' messages are currently supported. // See <https://github.com/nprapps/pym.js/blob/57feb68/src/pym.js#L85-L102> const args = data.split(/xPYMx/); if ('height' === args[2]) { this.updateSize_(parseInt(args[3], 10), undefined); } else if ('width' === args[2]) { this.updateSize_(undefined, parseInt(args[3], 10)); } else { user().warn(TAG_, `Unsupported Pym.js message: ${data}`); } } /** * Requests consent data from consent module * and forwards information to iframe * @param {Window} source * @param {string} origin * @private */ sendConsentData_(source, origin) { getConsentDataToForward(this.element, this.getConsentPolicy()).then( (consents) => { this.sendConsentDataToIframe_(source, origin, { 'sentinel': 'amp', 'type': MessageType_Enum.CONSENT_DATA, ...consents, }); } ); } /** * Send consent data to iframe * @param {Window} source * @param {string} origin * @param {JsonObject} data * @private */ sendConsentDataToIframe_(source, origin, data) { source./*OK*/ postMessage(data, origin); } /** * Removes this iframe from the page, freeing its resources. This is needed * to stop the bad eggs who continue to play videos even after the user has * swiped away from the doc. * @override **/ unlayoutCallback() { if (this.unlistenPym_) { this.unlistenPym_(); this.unlistenPym_ = null; } if (this.iframe_) { removeElement(this.iframe_); if (this.placeholder_) { this.togglePlaceholder(true); } this.iframe_ = null; // Needs to clean up intersectionObserverHostApi_ if (this.intersectionObserverHostApi_) { this.intersectionObserverHostApi_.destroy(); this.intersectionObserverHostApi_ = null; } } this.pauseHelper_.updatePlaying(false); return true; } /** @override */ getLayoutPriority() { if (this.isAdLike_) { return LayoutPriority_Enum.ADS; // See AmpAd3PImpl. } if (this.isTrackingFrame_) { return LayoutPriority_Enum.METADATA; } return super.getLayoutPriority(); } /** @override */ mutatedAttributesCallback(mutations) { const src = mutations['src']; if (src !== undefined) { this.iframeSrc = /** @type {?string} */ (this.transformSrc_(src)); if (this.iframe_) { this.iframe_.src = this.assertSource_( /** @type {string} */ (this.iframeSrc), window.location.href, this.sandbox_ ); } } if (this.iframe_ && mutations['title']) { // only propagating title because propagating all causes e2e error: propagateAttributes(['title'], this.element, this.iframe_); } } /** @override */ unlayoutOnPause() { return true; } /** * Makes the iframe visible. * @private */ activateIframe_() { if (this.placeholder_) { this.getVsync().mutate(() => { if (this.iframe_) { setStyle(this.iframe_, 'zIndex', 0); this.togglePlaceholder(false); } }); } } /** * No need for the default behavior, we'll call togglePlaceholder ourselves. * @override */ firstLayoutCompleted() {} /** * Throws an error if window navigation is disallowed by this element. * Otherwise, does nothing. * @throws {!Error} */ throwIfCannotNavigate() { if (!this.sandboxContainsToken_(this.sandbox_, 'allow-top-navigation')) { throw user().createError( '"AMP.navigateTo" is only allowed on ' + '<amp-iframe> when its "sandbox" attribute contains ' + '"allow-top-navigation".' ); } } /** * Updates the element's dimensions to accommodate the iframe's * requested dimensions. * @param {number|undefined} height * @param {number|undefined} width * @private */ updateSize_(height, width) { if (!this.isResizable_) { if (!this.hasErroredEmbedSize_) { this.user().error( TAG_, 'Ignoring embed-size request because this iframe is not resizable', this.element ); this.hasErroredEmbedSize_ = true; } return; } if (height < 100) { this.user().error( TAG_, 'Ignoring embed-size request because the resize height is less ' + 'than 100px. If you are using amp-iframe to display ads, consider ' + 'using amp-ad instead.', this.element ); return; } // Calculate new width and height of the container to include the padding. // If padding is negative, just use the requested width and height directly. let newHeight, newWidth; height = parseInt(height, 10); if (!isNaN(height)) { newHeight = Math.max( height + (this.element./*OK*/ offsetHeight - this.iframe_./*OK*/ offsetHeight), height ); } width = parseInt(width, 10); if (!isNaN(width)) { newWidth = Math.max( width + (this.element./*OK*/ offsetWidth - this.iframe_./*OK*/ offsetWidth), width ); } if (newHeight !== undefined || newWidth !== undefined) { this.attemptChangeSize(newHeight, newWidth).then( () => { if (newHeight !== undefined) { this.element.setAttribute('height', newHeight); } if (newWidth !== undefined) { this.element.setAttribute('width', newWidth); } this.element.overflowCallback( /* overflown */ false, newHeight, newWidth ); }, () => {} ); } else { this.user().error( TAG_, 'Ignoring embed-size request because ' + 'no width or height value is provided', this.element ); } } /** * Registers 'postMessage' action and 'message' event. * @private */ registerIframeMessaging_() { if (!isExperimentOn(this.win, 'iframe-messaging')) { return; } const {element} = this; const src = element.getAttribute('src'); if (src) { this.targetOrigin_ = Services.urlForDoc(element).parse(src).origin; } // Register action (even if targetOrigin_ is not available so we can // provide a helpful error message). this.registerAction('postMessage', (invocation) => { if (this.targetOrigin_) { this.iframe_.contentWindow./*OK*/ postMessage( invocation.args, this.targetOrigin_ ); } else { user().error( TAG_, '"postMessage" action is only allowed with "src"' + 'attribute with an origin.' ); } }); // However, don't listen for 'message' event if targetOrigin_ is null. if (!this.targetOrigin_) { return; } const maxUnexpectedMessages = 10; let unexpectedMessages = 0; const listener = (e) => { if (e.source !== this.iframe_.contentWindow) { // Ignore messages from other iframes. return; } if (e.origin !== this.targetOrigin_) { user().error( TAG_, '"message" received from unexpected origin: ' + e.origin + '. Only allowed from: ' + this.targetOrigin_ ); return; } if (!this.isUserGesture_()) { unexpectedMessages++; user().error( TAG_, '"message" event may only be triggered from a user gesture.' ); // Disable the 'message' event if the iframe is behaving badly. if (unexpectedMessages >= maxUnexpectedMessages) { user().error( TAG_, 'Too many non-gesture-triggered "message" ' + 'events; detaching event listener.' ); this.win.removeEventListener('message', listener); } return; } const unsanitized = getData(e); let sanitized; try { sanitized = parseJson(JSON.stringify(unsanitized)); } catch (e) { user().error(TAG_, 'Data from "message" event must be JSON.'); return; } const event = createCustomEvent(this.win, 'amp-iframe:message', { 'data': sanitized, }); const actionService = Services.actionServiceForDoc(this.element); actionService.trigger( this.element, 'message', event, ActionTrust_Enum.HIGH ); }; // TODO(choumx): Consider using global listener in iframe-helper. this.win.addEventListener('message', listener); } /** * Returns true if a user gesture was recently performed. * @return {boolean} * @private */ isUserGesture_() { // Best effort polyfill until native support is available: check that // iframe has focus and audio playback isn't immediately paused. if (this.getAmpDoc().getRootNode().activeElement !== this.iframe_) { return false; } const audio = this.win.document.createElement('audio'); playIgnoringError(audio); if (audio.paused) { return false; } return true; } /** * @param {string} value * @visibleForTesting */ setTargetOriginForTesting(value) { this.targetOrigin_ = value; } } /** * We always set a sandbox. Default is that none of the things that need * to be opted in are allowed. * @param {!Element} element * @param {!Element} iframe * @param {string} sandbox */ function setSandbox(element, iframe, sandbox) { const allows = sandbox || ''; iframe.setAttribute('sandbox', allows); } /** * If scrolling is allowed for the iframe, wraps the element into a container * that is scrollable because iOS auto expands iframes to their size. * @param {!Element} element * @return {!Element} The container or the iframe. */ function makeIOsScrollable(element) { if (element.getAttribute('scrolling') != 'no') { const wrapper = element.ownerDocument.createElement( 'i-amphtml-scroll-container' ); element.appendChild(wrapper); return wrapper; } return element; } /** * @param {number} ms */ export function setTrackingIframeTimeoutForTesting(ms) { trackingIframeTimeout = ms; } AMP.extension(TAG_, '0.1', (AMP) => { AMP.registerElement(TAG_, AmpIframe); });