UNPKG

sandblaster

Version:

A client-side JavaScript library to detect if your code is running inside of a sandboxed iframe... and, if desired, might just be able to change that fact!

462 lines (461 loc) 17.1 kB
/*! * sandblaster * A client-side JavaScript library to detect if your code is running inside of a sandboxed iframe... and, if desired, might just be able to change that fact! * Copyright (c) 2014-2018 James M. Greene * Licensed MIT * http://jamesmgreene.github.io/sandblaster/ * v1.3.0 */ (function(window, undefined) { "use strict"; /******************** * * * Utility code * * * ********************/ var _slice = Array.prototype.slice; function _cloneError(err) { // Some browsers and/or Error types do not need to clone correctly via // JSON serialization, so we may need to manually update the copy instead. // // This likely means that the Error instance's properties are: // (a) non-enumerable; // (b) prototypically inherited; or // (c) getter functions return { name: err.name, message: err.message, stack: err.stack }; } function _union(arr1, arr2) { return (arr1 || []).concat(arr2 || []).filter(function(key, i, arr) { return arr.indexOf(key) === i; }); } function _isWindowFramed() { /*jshint eqeqeq:false */ // Cannot compare WindowProxy objects with ===/!== var isNotChildWindow = !window.opener, hasWindowAncestors = !!(window.top && window != window.top || window.parent && window != window.parent); return isNotChildWindow && hasWindowAncestors; } function _isFrameElement(el) { var result; try { result = typeof el === "object" && el !== null && el.nodeType === 1 && /^I?FRAME$/.test(el.nodeName || "") && !!el.hasAttribute && !!el.getAttribute && !!el.setAttribute; } catch (err) { result = false; } return result; } function _getFrame(errback) { var frameEl; try { frameEl = window.frameElement; if (!_isFrameElement(frameEl)) { frameEl = null; } } catch (err) { frameEl = null; if (typeof errback === "function") { errback(_cloneError(err)); } } return frameEl; } function _getEffectiveScriptOrigin(errback) { var effectiveScriptOrigin; try { effectiveScriptOrigin = document.domain || null; } catch (err) { effectiveScriptOrigin = null; if (typeof errback === "function") { errback(_cloneError(err)); } } return effectiveScriptOrigin; } function _defaultAllowancesMapForNullAllowances() { return { forms: null, modals: null, orientationLock: null, pointerLock: null, popups: null, popupsToEscapeSandbox: null, presentation: null, sameOrigin: true, scripts: true, topNavigation: null, topNavigationByUserActivation: null }; } function _createAllowancesMap(allowancesString) { var allowanceList, allowancesMap = null; if (allowancesString === null) { allowancesMap = _defaultAllowancesMapForNullAllowances(); } else if (typeof allowancesString === "string") { allowanceList = allowancesString.replace(/^\s+|\s+$/g, "").toLowerCase().split(/\s+/); allowancesMap = { forms: allowanceList.indexOf("allow-forms") !== -1, modals: allowanceList.indexOf("allow-modals") !== -1, orientationLock: allowanceList.indexOf("allow-orientation-lock") !== -1, pointerLock: allowanceList.indexOf("allow-pointer-lock") !== -1, popups: allowanceList.indexOf("allow-popups") !== -1, popupsToEscapeSandbox: allowanceList.indexOf("allow-popups-to-escape-sandbox") !== -1, presentation: allowanceList.indexOf("allow-presentation") !== -1, sameOrigin: allowanceList.indexOf("allow-same-origin") !== -1, scripts: allowanceList.indexOf("allow-scripts") !== -1, topNavigation: allowanceList.indexOf("allow-top-navigation") !== -1, topNavigationByUserActivation: allowanceList.indexOf("allow-top-navigation-by-user-activation") !== -1 }; } return allowancesMap; } function _sandboxAllowancesMatch(sbAllowances1, sbAllowances2) { var allUsedKeys, i, len, prop, allMatched = false; if (sbAllowances1 && typeof sbAllowances1 === "object" && sbAllowances2 && typeof sbAllowances2 === "object") { allUsedKeys = _union(Object.keys(sbAllowances1), Object.keys(sbAllowances2)); allMatched = true; for (i = 0, len = allUsedKeys.length; i < len; i++) { prop = allUsedKeys[i]; if (!(sbAllowances1.hasOwnProperty(prop) && sbAllowances2.hasOwnProperty(prop) && sbAllowances1[prop] === sbAllowances2[prop])) { allMatched = false; break; } } } return allMatched; } /********************* * * * Analysis code * * * *********************/ function _analyzeSandboxing(frameEl, errback) { /*jshint maxstatements:45 */ // // An interesting note from: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe // // NOTE: // "When the embedded document has the same origin as the main // page, it is strongly discouraged to use both allow-scripts // and allow-same-origin at the same time, as that allows the // embedded document to programmatically remove the sandbox // attribute. Although it is accepted, this case is no more // secure than not using the sandbox attribute." // // So... let's give that a try, too! >=D // var sandboxAllowances, resandboxable, resandboxAllowances, resandboxLossless, result = { sandboxed: undefined, sandboxAllowances: undefined, unsandboxable: undefined, resandboxable: undefined, errors: [] }; if (_isFrameElement(frameEl)) { // Is sandboxed? try { result.sandboxed = frameEl.hasAttribute("sandbox"); } catch (sandboxErr) { result.sandboxed = null; if (typeof errback === "function") { errback(sandboxErr); } } // Get sandbox configuration if (result.sandboxed) { try { sandboxAllowances = frameEl.getAttribute("sandbox") || ""; } catch (sandboxErr) { sandboxAllowances = null; if (typeof errback === "function") { errback(sandboxErr); } } } else if (result.sandboxed === null) { sandboxAllowances = null; } if (sandboxAllowances === null || typeof sandboxAllowances === "string") { result.sandboxAllowances = _createAllowancesMap(sandboxAllowances); } // Analyze desandboxability if (result.sandboxed) { try { frameEl.removeAttribute("sandbox"); result.unsandboxable = !frameEl.hasAttribute("sandbox"); } catch (sandboxErr) { result.unsandboxable = false; if (typeof errback === "function") { errback(sandboxErr); } } } // Analyze resandboxability if (result.unsandboxable && typeof sandboxAllowances === "string") { try { frameEl.setAttribute("sandbox", sandboxAllowances); resandboxable = frameEl.hasAttribute("sandbox"); } catch (sandboxErr) { resandboxable = false; if (typeof errback === "function") { errback(sandboxErr); } } } // Get resandboxed configuration if (resandboxable && result.sandboxAllowances) { try { resandboxAllowances = frameEl.getAttribute("sandbox") || ""; } catch (sandboxErr) { resandboxAllowances = null; if (typeof errback === "function") { errback(sandboxErr); } } } else if (resandboxable === null) { resandboxAllowances = null; } if (resandboxAllowances === null || typeof resandboxAllowances === "string") { resandboxAllowances = _createAllowancesMap(resandboxAllowances); } // Analyze quality of resandboxability if (resandboxable) { resandboxLossless = null; if (result.sandboxAllowances && resandboxAllowances) { resandboxLossless = _sandboxAllowancesMatch(result.sandboxAllowances, resandboxAllowances); } } result.resandboxable = resandboxLossless; } return result; } /** * Underlying "class" for the sandblaster API. * * @constructor */ function SandBlaster() { var sb = this; if (!(sb instanceof SandBlaster)) { return new SandBlaster(); } sb._initialState = sb.detect(); sb._unsandboxState = null; } /** * ??? * * @returns Object detailing the current page's frame-related state and capabilities * @public */ SandBlaster.prototype.detect = function sandblaster$detect() { var frame, frameError, subResult, results = { framed: false, crossOrigin: null, sandboxed: null, sandboxAllowances: undefined, unsandboxable: undefined, resandboxable: undefined, sandboxable: undefined, errors: [] }, errback = function(err) { results.errors.push(_cloneError(err)); }, frameErrback = function(err) { frameError = _cloneError(err); results.errors.push(frameError); }; try { results.framed = _isWindowFramed(); if (!results.framed) { results.crossOrigin = undefined; results.sandboxed = undefined; results.errors = undefined; } else { frame = _getFrame(frameErrback); if (frame != null) { results.crossOrigin = false; subResult = _analyzeSandboxing(frame, errback); results.sandboxed = subResult.sandboxed; results.sandboxAllowances = subResult.sandboxAllowances; results.unsandboxable = subResult.unsandboxable; results.resandboxable = subResult.resandboxable; } else { // IMPORTANT: // Firefox will return `frame == null` and NOT throw any Error for // cross-origin `frameElement` access: // https://bugzilla.mozilla.org/show_bug.cgi?id=868235 // Set the most frequent default values results.crossOrigin = true; results.sandboxed = null; results.sandboxAllowances = { forms: null, modals: null, orientationLock: null, pointerLock: null, popups: null, popupsToEscapeSandbox: null, presentation: null, sameOrigin: null, scripts: true, topNavigation: null, topNavigationByUserActivation: null }; results.unsandboxable = false; results.resandboxable = false; // `document.domain` has been rendered useless by sandboxing // without granting `allow-same-origin` if (_getEffectiveScriptOrigin(errback) === null) { results.sandboxed = true; results.sandboxAllowances.sameOrigin = false; } else if (frameError) { if (frameError.name !== "SecurityError") { // Retract previous value... we're not sure anymore! results.crossOrigin = null; } else if (/(^|[\s\(\[@])sandbox(es|ed|ing|[\s\.,!\)\]@]|$)/.test(frameError.message.toLowerCase())) { results.sandboxed = true; results.sandboxAllowances.sameOrigin = true; } } } // Finally, do some analysis to see if we can authoritatively add sandboxing (e.g. if not sandboxed already) results.sandboxable = results.resandboxable || results.framed === true && results.crossOrigin === false && (results.sandboxed === false || results.sandboxAllowances.sameOrigin) || false; } } catch (err) { errback(err); } return results; }; /******************* * * * Action code * * * *******************/ /** * ??? */ SandBlaster.prototype.unsandbox = function sandblaster$unsandbox() { var _current, frameEl, result = false, sb = this, _init = sb._initialState; // If the page was never framed, bail out early because it will never change if (_init.framed === false) { result = true; } else if (_init.crossOrigin === false) { _current = sb.detect(); result = _current.sandboxed === false; // Unsandbox it if (_current.sandboxed && _current.unsandboxable) { // Keep track of the state of the frame before unsandboxing for potential resandboxing later sb._unsandboxState = _current; frameEl = _getFrame(function() { result = false; }); if (frameEl) { try { frameEl.removeAttribute("sandbox"); result = !frameEl.hasAttribute("sandbox"); } catch (err) { result = false; } } } } return result; }; /** * ??? */ SandBlaster.prototype.resandbox = function sandblaster$resandbox() { var result = false, sb = this; // If the page was unsandboxed, try to resandbox it if (sb._unsandboxState != null) { result = sb.sandbox(sb._unsandboxState.sandboxAllowances); } // If it succeeded, don't keep track of the previous unsandboxed state anymore if (result === true) { sb._unsandboxState = null; } return result; }; /** * ??? */ SandBlaster.prototype.sandbox = function sandblaster$sandbox(sandboxAllowances) { var allowancesString, keys, _current, _currentAllowances, isLockingDown, frameEl, sandboxAttr, result = false, sb = this, _init = sb._initialState, allowanceList = []; if (sandboxAllowances && typeof sandboxAllowances === "object" && _init.framed && _init.crossOrigin !== false) { // Verify the state before continuing _current = sb.detect(); // BUT also conduct some other live checks before attempting to sandbox it if (_current.sandboxed === false && _current.sandboxable || _current.sandboxed && _current.unsandboxable) { _currentAllowances = _current.sandboxAllowances; keys = _union(Object.keys(sandboxAllowances), Object.keys(_currentAllowances || {})); keys.forEach(function(allowance) { if (sandboxAllowances[allowance] === true || sandboxAllowances[allowance] == null && _currentAllowances && _currentAllowances[allowance] === true) { allowanceList.push("allow-" + allowance.replace(/[A-Z]/g, "-$1").toLowerCase()); } }); if (allowanceList.length > 0 && (frameEl = _getFrame())) { isLockingDown = allowanceList.indexOf("allow-same-origin") === -1 || allowanceList.indexOf("allow-scripts") === -1; allowancesString = allowanceList.sort().join(" "); try { // Add/update the sandbox frameEl.setAttribute("sandbox", allowancesString); result = isLockingDown; } catch (err) { result = false; } // Get a fresh element reference (or `null`) if (frameEl = _getFrame()) { // May throw a SecurityError now, depending on the new sandbox configuration try { sandboxAttr = (frameEl.getAttribute("sandbox") || "").replace(/^\s+|\s+$/g, "").split(/\s+/).sort().join(" "); result = sandboxAttr === allowancesString || isLockingDown && (sandboxAttr === "" || _getEffectiveScriptOrigin() === null); } catch (err) { // May not be able to touch the sandbox anymore due to security lockdown and/or // script disabling (if it even lets this event loop iteration finish!?) result = isLockingDown; } } } } } return result; }; /** * ??? */ SandBlaster.prototype.reload = function sandblaster$unsandboxAndReload() { var frameEl, attrs, newFrameEl, replacedFrameEl, result = false, sb = this, _init = sb._initialState; // If the page was never framed or was cross-origin, bail out early because it will never change if (_init.framed && _init.crossOrigin === false) { frameEl = _getFrame(); if (frameEl) { try { if (frameEl.parentNode && frameEl.parentNode.ownerDocument) { attrs = _slice.call(frameEl.attributes).map(function(item) { return { name: item.name, value: item.value }; }); newFrameEl = frameEl.parentNode.ownerDocument.createElement("iframe"); attrs.forEach(function(item) { newFrameEl.setAttribute(item.name, item.value); }); replacedFrameEl = frameEl.parentNode.replaceChild(newFrameEl, frameEl); result = frameEl === replacedFrameEl; } } catch (err) { result = false; } } } return result; }; /********************** * * * Export the API * * * **********************/ window.sandblaster = new SandBlaster(); })(window);