diff --git a/circle.yml b/circle.yml index 3dcb9265bb48..70d1d365a5ab 100644 --- a/circle.yml +++ b/circle.yml @@ -330,7 +330,7 @@ jobs: # run unit tests from each individual package - run: yarn test - verify-mocha-results: - expectedResultCount: 6 + expectedResultCount: 8 - store_test_results: path: /tmp/cypress # CLI tests generate HTML files with sample CLI command output diff --git a/cli/schema/cypress.schema.json b/cli/schema/cypress.schema.json index 483f2373d580..4b69ddbcb589 100644 --- a/cli/schema/cypress.schema.json +++ b/cli/schema/cypress.schema.json @@ -228,6 +228,11 @@ "type": "boolean", "default": false, "description": "If `true`, Cypress will add `sameSite` values to the objects yielded from `cy.setCookie()`, `cy.getCookie()`, and `cy.getCookies()`. This will become the default behavior in Cypress 5.0." + }, + "experimentalSourceRewriting": { + "type": "boolean", + "default": false, + "description": "Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm." } } } diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index 930e46d396cf..d87c4a1f6015 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -478,11 +478,11 @@ declare namespace Cypress { /** * Returns a boolean indicating whether an object is a window object. */ - isWindow(obj: any): boolean + isWindow(obj: any): obj is Window /** * Returns a boolean indicating whether an object is a jQuery object. */ - isJquery(obj: any): boolean + isJquery(obj: any): obj is JQuery isInputType(element: JQuery | HTMLElement, type: string | string[]): boolean stringify(element: JQuery | HTMLElement, form: string): string getElements(element: JQuery): JQuery | HTMLElement[] @@ -2456,6 +2456,12 @@ declare namespace Cypress { * @default false */ experimentalGetCookiesSameSite: boolean + /** + * Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement + * algorithm. + * @default false + */ + experimentalSourceRewriting: boolean } /** diff --git a/package.json b/package.json index 651258d20fca..d62a42ad3f71 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "stop-only": "npx stop-only --skip .cy,.publish,.projects,node_modules,dist,dist-test,fixtures,lib,bower_components,src --exclude e2e.coffee,e2e.js", "stop-only-all": "yarn stop-only --folder packages", "pretest": "yarn ensure-deps", - "test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,network,reporter,runner,socket}'\"", + "test": "yarn lerna exec yarn test --scope cypress --scope \"'@packages/{electron,extension,https-proxy,launcher,network,proxy,rewriter,reporter,runner,socket}'\"", "test-debug": "lerna exec yarn test-debug --ignore \"'@packages/{coffee,desktop-gui,driver,root,static,web-config}'\"", "pretest-e2e": "yarn ensure-deps", "test-e2e": "lerna exec yarn test-e2e --ignore \"'@packages/{coffee,desktop-gui,driver,root,static,web-config}'\"", diff --git a/packages/driver/src/cypress.js b/packages/driver/src/cypress.js index 0125a9c88412..06f90df13357 100644 --- a/packages/driver/src/cypress.js +++ b/packages/driver/src/cypress.js @@ -32,6 +32,7 @@ const $utils = require('./cypress/utils') const $errUtils = require('./cypress/error_utils') const $scriptUtils = require('./cypress/script_utils') const browserInfo = require('./cypress/browser') +const resolvers = require('./cypress/resolvers') const debug = require('debug')('cypress:driver:cypress') const proxies = { @@ -607,6 +608,8 @@ $Cypress.prototype.Location = $Location $Cypress.prototype.Log = $Log $Cypress.prototype.LocalStorage = $LocalStorage $Cypress.prototype.Mocha = $Mocha +$Cypress.prototype.resolveWindowReference = resolvers.resolveWindowReference +$Cypress.prototype.resolveLocationReference = resolvers.resolveLocationReference $Cypress.prototype.Mouse = $Mouse $Cypress.prototype.Runner = $Runner $Cypress.prototype.Server = $Server diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee index 6be38cf5c6c3..4f474eb51d84 100644 --- a/packages/driver/src/cypress/error_messages.coffee +++ b/packages/driver/src/cypress/error_messages.coffee @@ -841,6 +841,18 @@ module.exports = { ng: no_global: "Angular global (`window.angular`) was not found in your window. You cannot use #{cmd('ng')} methods without angular." + proxy: + js_rewriting_failed: """ + An error occurred in the Cypress proxy layer while rewriting your source code. This is a bug in Cypress. + + JS URL: {{url}} + + Original error: + + {{errMessage}} + {{errStack}} + """ + reload: invalid_arguments: { message: "#{cmd('reload')} can only accept a boolean or `options` as its arguments." diff --git a/packages/driver/src/cypress/resolvers.ts b/packages/driver/src/cypress/resolvers.ts new file mode 100644 index 000000000000..ba50858c9d95 --- /dev/null +++ b/packages/driver/src/cypress/resolvers.ts @@ -0,0 +1,159 @@ +import _ from 'lodash' +import $Cypress from '../..' + +/** + * Fix property reads and writes that could potentially help the AUT to break out of its iframe. + * + * @param currentWindow the value of `globalThis` from the scope of the window reference in question + * @param accessedObject a reference to the object being accessed + * @param accessedProp the property name being accessed (Symbol/number properties are not intercepted) + * @param value the right-hand side of an assignment operation (accessedObject.accessedProp = value) + */ +export function resolveWindowReference (this: typeof $Cypress, currentWindow: Window, accessedObject: Window | any, accessedProp: string, value?: any) { + const { dom, state } = this + + const getTargetValue = () => { + const targetValue = accessedObject[accessedProp] + + if (dom.isWindow(accessedObject) && accessedProp === 'location') { + const targetLocation = resolveLocationReference(accessedObject) + + if (isValPassed) { + return targetLocation.href = value + } + + return targetLocation + } + + if (_.isFunction(targetValue)) { + return targetValue.bind(accessedObject) + } + + return targetValue + } + + const setTargetValue = () => { + if (dom.isWindow(accessedObject) && accessedProp === 'location') { + const targetLocation = resolveLocationReference(accessedObject) + + return targetLocation.href = value + } + + return (accessedObject[accessedProp] = value) + } + + const isValPassed = arguments.length === 4 + + const $autIframe = state('$autIframe') + + if (!$autIframe) { + // missing AUT iframe, resolve the property access normally + if (isValPassed) { + return setTargetValue() + } + + return getTargetValue() + } + + const contentWindow = $autIframe.prop('contentWindow') + + if (accessedObject === currentWindow.top) { + // doing a property access on topmost window, adjust accessedObject + accessedObject = contentWindow + } + + const targetValue = getTargetValue() + + if (!dom.isWindow(targetValue) || dom.isJquery(targetValue)) { + if (isValPassed) { + return setTargetValue() + } + + return targetValue + } + + // targetValue is a reference to a Window object + + if (accessedProp === 'top') { + // note: `isValPassed` is not considered here because `window.top` is readonly + return contentWindow + } + + if (accessedProp === 'parent') { + // note: `isValPassed` is not considered here because `window.parent` is readonly + if (targetValue === currentWindow.top) { + return contentWindow + } + + return targetValue + } + + throw new Error('unhandled resolveWindowReference') +} + +/** + * Fix `window.location` usages that would otherwise navigate to the wrong URL. + * + * @param currentWindow the value of `globalThis` from the scope of the location reference in question + */ +export function resolveLocationReference (currentWindow: Window) { + // @ts-ignore + if (currentWindow.__cypressFakeLocation) { + // @ts-ignore + return currentWindow.__cypressFakeLocation + } + + function _resolveHref (href: string) { + const a = currentWindow.document.createElement('a') + + a.href = href + + // a.href will be resolved into the correct fully-qualified URL + return a.href + } + + function assign (href: string) { + return currentWindow.location.assign(_resolveHref(href)) + } + + function replace (href: string) { + return currentWindow.location.replace(_resolveHref(href)) + } + + function setHref (href: string) { + return currentWindow.location.href = _resolveHref(href) + } + + const locationKeys = Object.keys(currentWindow.location) + + const fakeLocation = {} + + _.reduce(locationKeys, (acc, cur) => { + // set a dummy value, the proxy will handle sets/gets + acc[cur] = Symbol.for('Proxied') + + return acc + }, {}) + + // @ts-ignore + return currentWindow.__cypressFakeLocation = new Proxy(fakeLocation, { + get (_target, prop, _receiver) { + if (prop === 'assign') { + return assign + } + + if (prop === 'replace') { + return replace + } + + return currentWindow.location[prop] + }, + set (_obj, prop, value) { + if (prop === 'href') { + return setHref(value) + } + + return currentWindow.location[prop] = value + }, + }) +} diff --git a/packages/driver/test/cypress/integration/cypress/resolvers_spec.ts b/packages/driver/test/cypress/integration/cypress/resolvers_spec.ts new file mode 100644 index 000000000000..39755d0418e5 --- /dev/null +++ b/packages/driver/test/cypress/integration/cypress/resolvers_spec.ts @@ -0,0 +1,212 @@ +const getFakeWindowWithLocation = ($win: Window) => { + return { + document: $win.document, + location: { + someFn: cy.stub(), + someProp: 'foo', + href: 'original', + assign: cy.stub(), + replace: cy.stub(), + }, + } +} + +describe('src/cypress/resolvers', function () { + context('#resolveWindowReferences', function () { + it('returns bound fn if prop is fn', function () { + const unboundFn = function () { + return this + } + const unboundFnWindow = { + parent: unboundFn, + } + + // @ts-ignore + cy.spy(unboundFn, 'bind') + + // @ts-ignore + const actual = Cypress.resolveWindowReference({}, unboundFnWindow, 'parent') + + expect(actual).to.be.instanceOf(Function) + expect(actual()).to.eq(unboundFnWindow) + expect(unboundFn.bind).to.be.calledWith(unboundFnWindow) + }) + + it('returns proxied location object if prop is location', function () { + const contentWindow = Cypress.state('$autIframe')!.prop('contentWindow') + // @ts-ignore + const actual = Cypress.resolveWindowReference(contentWindow, contentWindow, 'location') + + cy.stub(Cypress.dom, 'isWindow').withArgs(contentWindow).returns(true) + cy.stub(Cypress.dom, 'isJquery').withArgs(contentWindow).returns(false) + + // @ts-ignore + expect(actual).to.eq(Cypress.resolveLocationReference(contentWindow)) + }) + + context('window reference selection', function () { + const cypressFrame = { + name: 'cypressFrame', + parent: null as unknown, + top: null as unknown, + } + + cypressFrame.parent = cypressFrame.top = cypressFrame + + const autIframe = { + name: 'autIframe', + parent: cypressFrame, + top: cypressFrame, + } + + const nestedIframe = { + name: 'nestedIframe', + parent: autIframe, + top: cypressFrame, + } + + const doublyNestedIframe = { + name: 'doublyNestedIframe', + parent: nestedIframe, + top: cypressFrame, + } + + ;[ + { + name: 'returns autIframe given parent call in autIframe', + currentWindow: autIframe, + accessedObject: autIframe, + accessedProp: 'parent', + expected: autIframe, + }, + { + name: 'returns autIframe given top call in autIframe', + currentWindow: autIframe, + accessedObject: autIframe, + accessedProp: 'top', + expected: autIframe, + }, + { + name: 'returns autIframe given parent call in nestedIframe', + currentWindow: nestedIframe, + accessedObject: nestedIframe, + accessedProp: 'parent', + expected: autIframe, + }, + { + name: 'returns autIframe given top call in nestedIframe', + currentWindow: nestedIframe, + accessedObject: nestedIframe, + accessedProp: 'top', + expected: autIframe, + }, + { + name: 'returns nestedIframe given parent call in doublyNestedIframe', + currentWindow: doublyNestedIframe, + accessedObject: doublyNestedIframe, + accessedProp: 'parent', + expected: nestedIframe, + }, + { + name: 'returns autIframe given top call in doublyNestedIframe', + currentWindow: doublyNestedIframe, + accessedObject: doublyNestedIframe, + accessedProp: 'top', + expected: autIframe, + }, + ] + // .slice(0, 1) + .forEach(({ name, currentWindow, accessedObject, accessedProp, expected }) => { + it(name, function () { + const isWindow = cy.stub(Cypress.dom, 'isWindow') + const isJquery = cy.stub(Cypress.dom, 'isJquery') + const state = cy.stub(Cypress, 'state') + + state.withArgs('$autIframe').returns({ + prop: cy.stub().withArgs('contentWindow').returns(autIframe), + }) + + ;[cypressFrame, autIframe, nestedIframe, doublyNestedIframe].forEach((frame) => { + isWindow.withArgs(frame).returns(true) + isJquery.withArgs(frame).returns(false) + }) + + // @ts-ignore + const actual = Cypress.resolveWindowReference(currentWindow, accessedObject, accessedProp) + + expect(actual).to.eq(expected) + }) + }) + }) + }) + + context('#resolveLocationReference', function () { + let fakeWindow + + beforeEach(() => { + cy.visit('/fixtures/generic.html') + .then(($win) => { + fakeWindow = getFakeWindowWithLocation($win) + }) + }) + + it('.href setter sets location.href with resolved URL', () => { + // @ts-ignore + const loc = Cypress.resolveLocationReference(fakeWindow) + + loc.href = 'foo' + + expect(fakeWindow.location.href).to.eq('http://localhost:3500/fixtures/foo') + }) + + it('.assign() calls location.assign with resolved URL', () => { + // @ts-ignore + const loc = Cypress.resolveLocationReference(fakeWindow) + + loc.assign('foo') + + expect(fakeWindow.location.assign).to.be.calledWith('http://localhost:3500/fixtures/foo') + }) + + it('.replace() calls location.replace with resolved URL', () => { + // @ts-ignore + const loc = Cypress.resolveLocationReference(fakeWindow) + + loc.replace('foo') + + expect(fakeWindow.location.replace).to.be.calledWith('http://localhost:3500/fixtures/foo') + }) + + it('calls through to unintercepted functions', () => { + // @ts-ignore + const loc = Cypress.resolveLocationReference(fakeWindow) + + loc.someFn('foo') + + expect(fakeWindow.location.someFn).to.be.calledWith('foo') + }) + + it('calls through to unintercepted setters + getters', () => { + // @ts-ignore + const loc = Cypress.resolveLocationReference(fakeWindow) + + expect(loc.someProp).to.eq('foo') + + loc.someProp = 'bar' + + expect(loc.someProp).to.eq('bar') + expect(fakeWindow.location.someProp).to.eq('bar') + }) + + it('returns the same object between calls', () => { + // @ts-ignore + const loc1 = Cypress.resolveLocationReference(fakeWindow) + // @ts-ignore + const loc2 = Cypress.resolveLocationReference(fakeWindow) + + expect(loc1).to.eq(loc2) + expect(fakeWindow.__cypressFakeLocation).to.eq(loc1) + expect(fakeWindow.__cypressFakeLocation).to.eq(loc2) + }) + }) +}) diff --git a/packages/driver/ts/internal-types.d.ts b/packages/driver/ts/internal-types.d.ts new file mode 100644 index 000000000000..fdd04977f2ee --- /dev/null +++ b/packages/driver/ts/internal-types.d.ts @@ -0,0 +1,17 @@ +// NOTE: this is for internal Cypress types that we don't want exposed in the public API but want for development +// TODO: find a better place for this + +declare namespace Cypress { + + interface Cypress { + /** + * Access and set Cypress's internal state. + */ + state: State + + } + + interface State { + (k: '$autIframe', v?: JQuery): JQuery | undefined + } +} diff --git a/packages/network/lib/connect.ts b/packages/network/lib/connect.ts index 6c94182277d3..658438a3e58d 100644 --- a/packages/network/lib/connect.ts +++ b/packages/network/lib/connect.ts @@ -43,7 +43,7 @@ export function getAddress (port: number, hostname: string) { return Array.prototype.concat.call(addresses).map(fn) }) .tapCatch((err) => { - debug('error getting address', { hostname, port, err }) + debug('error getting address %o', { hostname, port, err }) }) .any() } diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index f04dcdd48cb6..ae4c6561acb7 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -3,11 +3,12 @@ import debugModule from 'debug' import ErrorMiddleware from './error-middleware' import { HttpBuffers } from './util/buffers' import { IncomingMessage } from 'http' -import Promise from 'bluebird' +import Bluebird from 'bluebird' import { Readable } from 'stream' import { Request, Response } from 'express' import RequestMiddleware from './request-middleware' import ResponseMiddleware from './response-middleware' +import { DeferredSourceMapCache } from '@packages/rewriter' const debug = debugModule('cypress:proxy:http') @@ -42,6 +43,7 @@ type HttpMiddlewareCtx = { res: CypressResponse middleware: MiddlewareStacks + deferSourceMapRewrite: (opts: { js: string, url: string }) => string } & T const READONLY_MIDDLEWARE_KEYS: (keyof HttpMiddlewareThis<{}>)[] = [ @@ -86,14 +88,14 @@ export function _runStage (type: HttpStages, ctx: any) { const middlewareName = _.keys(middlewares)[0] if (!middlewareName) { - return Promise.resolve() + return Bluebird.resolve() } const middleware = middlewares[middlewareName] ctx.middleware[type] = _.omit(middlewares, middlewareName) - return new Promise((resolve) => { + return new Bluebird((resolve) => { let ended = false function copyChangedCtx () { @@ -173,6 +175,7 @@ export function _runStage (type: HttpStages, ctx: any) { export class Http { buffers: HttpBuffers + deferredSourceMapCache: DeferredSourceMapCache config: any getFileServerToken: () => string getRemoteState: () => any @@ -187,6 +190,7 @@ export class Http { request: any }) { this.buffers = new HttpBuffers() + this.deferredSourceMapCache = new DeferredSourceMapCache(opts.request) this.config = opts.config this.getFileServerToken = opts.getFileServerToken @@ -204,7 +208,7 @@ export class Http { } } - handle (req, res) { + handle (req: Request, res: Response) { const ctx: HttpMiddlewareCtx = { req, res, @@ -215,6 +219,12 @@ export class Http { getRemoteState: this.getRemoteState, request: this.request, middleware: _.cloneDeep(this.middleware), + deferSourceMapRewrite: (opts) => { + this.deferredSourceMapCache.defer({ + resHeaders: ctx.incomingRes.headers, + ...opts, + }) + }, } return _runStage(HttpStages.IncomingRequest, ctx) @@ -227,6 +237,20 @@ export class Http { }) } + async handleSourceMapRequest (req: Request, res: Response) { + try { + const sm = await this.deferredSourceMapCache.resolve(req.params.id, req.headers) + + if (!sm) { + throw new Error('no sourcemap found') + } + + res.json(sm) + } catch (err) { + res.status(500).json({ err }) + } + } + reset () { this.buffers.reset() } diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 6a5415d6025e..08a74a153733 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -74,6 +74,10 @@ function resContentTypeIsJavaScript (res: IncomingMessage) { ) } +function isHtml (res: IncomingMessage) { + return !resContentTypeIsJavaScript(res) +} + function resIsGzipped (res: IncomingMessage) { return (res.headers['content-encoding'] || '').includes('gzip') } @@ -364,10 +368,18 @@ const MaybeInjectHtml: ResponseMiddleware = function () { debug('injecting into HTML') - this.incomingResStream.pipe(concatStream((body) => { + this.incomingResStream.pipe(concatStream(async (body) => { const nodeCharset = getNodeCharsetFromResponse(this.incomingRes.headers, body) const decodedBody = iconv.decode(body, nodeCharset) - const injectedBody = rewriter.html(decodedBody, this.getRemoteState().domainName, this.res.wantsInjection, this.res.wantsSecurityRemoved) + const injectedBody = await rewriter.html(decodedBody, { + domainName: this.getRemoteState().domainName, + wantsInjection: this.res.wantsInjection, + wantsSecurityRemoved: this.res.wantsSecurityRemoved, + isHtml: isHtml(this.incomingRes), + useAstSourceRewriting: this.config.experimentalSourceRewriting, + url: this.req.proxiedUrl, + deferSourceMapRewrite: this.deferSourceMapRewrite, + }) const encodedBody = iconv.encode(injectedBody, nodeCharset) const pt = new PassThrough @@ -388,7 +400,13 @@ const MaybeRemoveSecurity: ResponseMiddleware = function () { debug('removing JS framebusting code') this.incomingResStream.setEncoding('utf8') - this.incomingResStream = this.incomingResStream.pipe(rewriter.security()).on('error', this.onError) + this.incomingResStream = this.incomingResStream.pipe(rewriter.security({ + isHtml: isHtml(this.incomingRes), + useAstSourceRewriting: this.config.experimentalSourceRewriting, + url: this.req.proxiedUrl, + deferSourceMapRewrite: this.deferSourceMapRewrite, + })).on('error', this.onError) + this.next() } diff --git a/packages/proxy/lib/http/util/ast-rewriter.ts b/packages/proxy/lib/http/util/ast-rewriter.ts new file mode 100644 index 000000000000..5b5c7752dc8e --- /dev/null +++ b/packages/proxy/lib/http/util/ast-rewriter.ts @@ -0,0 +1,38 @@ +import { HtmlJsRewriter, rewriteHtmlJsAsync, rewriteJsAsync } from '@packages/rewriter' +import duplexify from 'duplexify' +import { concatStream } from '@packages/network' +import stream from 'stream' +import { SecurityOpts } from './rewriter' + +const pumpify = require('pumpify') +const utf8Stream = require('utf8-stream') + +export const strip = async (source: string, opts: SecurityOpts) => { + if (opts.isHtml) { + return rewriteHtmlJsAsync(opts.url, source, opts.deferSourceMapRewrite) // threaded + } + + return rewriteJsAsync(opts.url, source, opts.deferSourceMapRewrite) // threaded +} + +export const stripStream = (opts: SecurityOpts) => { + if (opts.isHtml) { + return pumpify( + utf8Stream(), + HtmlJsRewriter(opts.url, opts.deferSourceMapRewrite), // non-threaded + ) + } + + const pt = new (stream.PassThrough)() + + return duplexify( + pumpify( + utf8Stream(), + concatStream(async (body) => { + pt.write(await strip(body.toString(), opts)) + pt.end() + }), + ), + pt, + ) +} diff --git a/packages/proxy/lib/http/util/security.ts b/packages/proxy/lib/http/util/regex-rewriter.ts similarity index 100% rename from packages/proxy/lib/http/util/security.ts rename to packages/proxy/lib/http/util/regex-rewriter.ts diff --git a/packages/proxy/lib/http/util/rewriter.ts b/packages/proxy/lib/http/util/rewriter.ts index 4829017d0a45..b5303fa4bf48 100644 --- a/packages/proxy/lib/http/util/rewriter.ts +++ b/packages/proxy/lib/http/util/rewriter.ts @@ -1,33 +1,60 @@ import * as inject from './inject' -import { strip, stripStream } from './security' +import * as astRewriter from './ast-rewriter' +import * as regexRewriter from './regex-rewriter' + +export type SecurityOpts = { + isHtml?: boolean + url: string + useAstSourceRewriting: boolean + deferSourceMapRewrite: (opts: any) => string +} + +export type InjectionOpts = { + domainName: string + wantsInjection: WantsInjection + wantsSecurityRemoved: any +} const doctypeRe = /(<\!doctype.*?>)/i const headRe = /()/i const bodyRe = /()/i const htmlRe = /()/i -export function html (html: string, domainName: string, wantsInjection, wantsSecurityRemoved) { +type WantsInjection = 'full' | 'partial' | false + +function getRewriter (useAstSourceRewriting: boolean) { + return useAstSourceRewriting ? astRewriter : regexRewriter +} + +function getHtmlToInject ({ domainName, wantsInjection }: InjectionOpts) { + switch (wantsInjection) { + case 'full': + return inject.full(domainName) + case 'partial': + return inject.partial(domainName) + default: + return + } +} + +export async function html (html: string, opts: SecurityOpts & InjectionOpts) { const replace = (re, str) => { return html.replace(re, str) } - const htmlToInject = (() => { - switch (wantsInjection) { - case 'full': - return inject.full(domainName) - case 'partial': - return inject.partial(domainName) - default: - return - } - })() + const htmlToInject = getHtmlToInject(opts) // strip clickjacking and framebusting // from the HTML if we've been told to - if (wantsSecurityRemoved) { - html = strip(html) + if (opts.wantsSecurityRemoved) { + html = await Promise.resolve(getRewriter(opts.useAstSourceRewriting).strip(html, opts)) } + if (!htmlToInject) { + return html + } + + // TODO: move this into regex-rewriting and have ast-rewriting handle this in its own way switch (false) { case !headRe.test(html): return replace(headRe, `$1 ${htmlToInject}`) @@ -47,4 +74,6 @@ export function html (html: string, domainName: string, wantsInjection, wantsSec } } -export const security = stripStream +export function security (opts: SecurityOpts) { + return getRewriter(opts.useAstSourceRewriting).stripStream(opts) +} diff --git a/packages/proxy/lib/network-proxy.ts b/packages/proxy/lib/network-proxy.ts index 0cddc9766fd1..0f95c2b44aeb 100644 --- a/packages/proxy/lib/network-proxy.ts +++ b/packages/proxy/lib/network-proxy.ts @@ -17,6 +17,10 @@ export class NetworkProxy { this.http.handle(req, res) } + handleSourceMapRequest (req, res) { + this.http.handleSourceMapRequest(req, res) + } + setHttpBuffer (buffer) { this.http.setBuffer(buffer) } diff --git a/packages/proxy/test/unit/http/util/security.spec.ts b/packages/proxy/test/unit/http/util/regex-rewriter.spec.ts similarity index 95% rename from packages/proxy/test/unit/http/util/security.spec.ts rename to packages/proxy/test/unit/http/util/regex-rewriter.spec.ts index 8f5387bc5767..305b83106b75 100644 --- a/packages/proxy/test/unit/http/util/security.spec.ts +++ b/packages/proxy/test/unit/http/util/regex-rewriter.spec.ts @@ -4,7 +4,7 @@ import { expect } from 'chai' import fs from 'fs' import Promise from 'bluebird' import rp from '@cypress/request-promise' -import * as security from '../../../../lib/http/util/security' +import * as regexRewriter from '../../../../lib/http/util/regex-rewriter' const original = `\ @@ -168,10 +168,10 @@ const expected = `\ \ ` -describe('http/util/security', () => { +describe('http/util/regex-rewriter', () => { context('.strip', () => { it('replaces obstructive code', () => { - expect(security.strip(original)).to.eq(expected) + expect(regexRewriter.strip(original)).to.eq(expected) }) it('replaces jira window getter', () => { @@ -207,17 +207,17 @@ while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(parentOf.parent)) { }\ ` - expect(security.strip(jira)).to.eq(`\ + expect(regexRewriter.strip(jira)).to.eq(`\ for (; !function (n) { return n === n.parent || n.parent.__Cypress__ }(n)\ `) - expect(security.strip(jira2)).to.eq(`\ + expect(regexRewriter.strip(jira2)).to.eq(`\ function(n){for(;!function(l){return l===l.parent || l.parent.__Cypress__}(l)&&function(l){try{if(void 0==l.location.href)return!1}catch(l){return!1}return!0}(l.parent);)l=l.parent;return l}\ `) - expect(security.strip(jira3)).to.eq(`\ + expect(regexRewriter.strip(jira3)).to.eq(`\ function satisfiesSameOrigin(w) { try { // Accessing location.href from a window on another origin will throw an exception. @@ -317,8 +317,8 @@ while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(parentOf.parent)) { fs.readFile(pathToLib, 'utf8', cb) }) .catch(downloadFile) - .then((libCode) => { - let stripped = security.strip(libCode) + .then((libCode: string) => { + let stripped = regexRewriter.strip(libCode) // nothing should have changed! // TODO: this is currently failing but we're @@ -348,7 +348,7 @@ while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(parentOf.parent)) { it('replaces obstructive code', (done) => { const haystacks = original.split('\n') - const replacer = security.stripStream() + const replacer = regexRewriter.stripStream() replacer.pipe(concatStream({ encoding: 'string' }, (str) => { const string = str.toString().trim() diff --git a/packages/rewriter/README.md b/packages/rewriter/README.md new file mode 100644 index 000000000000..bfc99c7af1ab --- /dev/null +++ b/packages/rewriter/README.md @@ -0,0 +1,15 @@ +# rewriter + +This package contains logic for rewriting JS/HTML that flows through the Cypress proxy. + +## Testing + +Tests are located in [`./test`](./test) + +To run tests: + +```shell +yarn test +``` + +Additionally, the `server` and `proxy` packages contain integration tests that exercise the `rewriter`. diff --git a/packages/rewriter/__snapshots__/deferred-source-map-cache-spec.ts.js b/packages/rewriter/__snapshots__/deferred-source-map-cache-spec.ts.js new file mode 100644 index 000000000000..f453bfe2ea6d --- /dev/null +++ b/packages/rewriter/__snapshots__/deferred-source-map-cache-spec.ts.js @@ -0,0 +1,27 @@ +exports['DeferredSourceMapCache #resolve sourcemap generation for JS with no original sourcemap 1'] = { + "version": 3, + "sources": [ + "bar (original)" + ], + "names": [], + "mappings": "AAAA,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC", + "file": "bar (original).map", + "sourceRoot": ".", + "sourcesContent": [ + "console.log()" + ] +} + +exports['composed sourcemap'] = { + "version": 3, + "sources": [ + "test.coffee" + ], + "names": [], + "mappings": ";AAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA;EAAA,CAAA,EAAA,CAAA,CAAA,CAAA,EAAA,CAAA,EAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA,CAAA,EAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA,CAAA,EAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA;;;EAIA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAW,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA,EAAA;IACT,CAAA,CAAA,CAAA,CAAA,CAAA,CAAA;IAAA,CAAA,CAAA,EAAA,CAAA,CAAA,CAAA;WACA,CAAA,CAAA,EAAA,EAAM,CAAA,CAAA,2DAAG,CAAA,CAAA,CAAA,CAAA,CAAA,SAAU,CAAC,CAAA,CAAA,CAAd,CAAA;EAFG,CAAX,EAGE,CAAA,CAAA,CAAA,CAHF,CAAA", + "file": "foo.js (original).map", + "sourceRoot": "http://somedomain.net/dir", + "sourcesContent": [ + "# just an example of transpilation w/ sourcemap -\n# `test.coffee` is not directly transpiled/executed by any test code\n# regenerate JS + sourcemap with `coffee -c -m test.coffee`\n\nsetTimeout ->\n window\n foo = \"#{window.top.foo}\"\n, 1000\n" + ] +} diff --git a/packages/rewriter/__snapshots__/html-spec.ts.js b/packages/rewriter/__snapshots__/html-spec.ts.js new file mode 100644 index 000000000000..4b124d2903a3 --- /dev/null +++ b/packages/rewriter/__snapshots__/html-spec.ts.js @@ -0,0 +1,35 @@ +exports['html rewriter .rewriteHtmlJs strips SRI 1'] = ` + +` + +exports['html rewriter .rewriteHtmlJs strips SRI 4'] = ` +\n \n" +} + +exports['html rewriter .rewriteHtmlJs with inline scripts rewrites inline JS with no type 1'] = ` + +` + +exports['html rewriter .rewriteHtmlJs with inline scripts rewrites inline JS with type 1'] = ` + +` + +exports['html rewriter .rewriteHtmlJs with inline scripts does not rewrite non-JS inline 1'] = ` + +` diff --git a/packages/rewriter/__snapshots__/js-spec.ts.js b/packages/rewriter/__snapshots__/js-spec.ts.js new file mode 100644 index 000000000000..8776664e8568 --- /dev/null +++ b/packages/rewriter/__snapshots__/js-spec.ts.js @@ -0,0 +1,18 @@ +exports['js rewriter .rewriteJs source maps emits sourceInfo as expected 1'] = { + "url": "http://example.com/foo.js", + "js": "window.top" +} + +exports['js rewriter .rewriteJs source maps emits info about existing inline sourcemap 1'] = { + "url": "http://example.com/foo.js", + "js": "// Generated by CoffeeScript 2.2.1\n(function() {\n // just an example of transpilation w/ sourcemap -\n // `test.coffee` is not directly transpiled/executed by any test code\n // regenerate JS + sourcemap with `coffee -c -m test.coffee`\n setTimeout(function() {\n window;\n var foo;\n return foo = `${window.top.foo}`;\n }, 1000);\n\n}).call(this);\n\n//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAiZmlsZSI6ICJ0ZXN0LmpzIiwKICAic291cmNlUm9vdCI6ICIiLAogICJzb3VyY2VzIjogWwogICAgInRlc3QuY29mZmVlIgogIF0sCiAgIm5hbWVzIjogW10sCiAgIm1hcHBpbmdzIjogIjtBQUFBO0VBQUE7OztFQUlBLFVBQUEsQ0FBVyxRQUFBLENBQUEsQ0FBQTtJQUNUO0FBQUEsUUFBQTtXQUNBLEdBQUEsR0FBTSxDQUFBLENBQUEsQ0FBRyxNQUFNLENBQUMsR0FBRyxDQUFDLEdBQWQsQ0FBQTtFQUZHLENBQVgsRUFHRSxJQUhGO0FBSkEiLAogICJzb3VyY2VzQ29udGVudCI6IFsKICAgICIjIGp1c3QgYW4gZXhhbXBsZSBvZiB0cmFuc3BpbGF0aW9uIHcvIHNvdXJjZW1hcCAtXG4jIGB0ZXN0LmNvZmZlZWAgaXMgbm90IGRpcmVjdGx5IHRyYW5zcGlsZWQvZXhlY3V0ZWQgYnkgYW55IHRlc3QgY29kZVxuIyByZWdlbmVyYXRlIEpTICsgc291cmNlbWFwIHdpdGggYGNvZmZlZSAtYyAtbSB0ZXN0LmNvZmZlZWBcblxuc2V0VGltZW91dCAtPlxuICB3aW5kb3dcbiAgZm9vID0gXCIje3dpbmRvdy50b3AuZm9vfVwiXG4sIDEwMDBcbiIKICBdCn0=" +} + +exports['js rewriter .rewriteJs source maps emits info about existing external sourcemap 1'] = { + "url": "http://example.com/foo.js", + "js": "// Generated by CoffeeScript 2.2.1\n(function() {\n // just an example of transpilation w/ sourcemap -\n // `test.coffee` is not directly transpiled/executed by any test code\n // regenerate JS + sourcemap with `coffee -c -m test.coffee`\n setTimeout(function() {\n window;\n var foo;\n return foo = `${window.top.foo}`;\n }, 1000);\n\n}).call(this);\n\n//# sourceMappingURL=test.js.map\n" +} + +exports['js rewriter .rewriteJs transformations throws an error via the driver if AST visiting throws an error 1'] = ` +window.top.Cypress.utils.throwErrByPath('proxy.js_rewriting_failed', { args: {"errMessage":"foo","errStack":"stack","url":"http://example.com/foo.js"} }) +` diff --git a/packages/rewriter/index.js b/packages/rewriter/index.js new file mode 100644 index 000000000000..99166f024f49 --- /dev/null +++ b/packages/rewriter/index.js @@ -0,0 +1,5 @@ +if (process.env.CYPRESS_ENV !== 'production') { + require('@packages/ts/register') +} + +module.exports = require('./lib') diff --git a/packages/rewriter/lib/async-rewriters.ts b/packages/rewriter/lib/async-rewriters.ts new file mode 100644 index 000000000000..1b74385654a9 --- /dev/null +++ b/packages/rewriter/lib/async-rewriters.ts @@ -0,0 +1,32 @@ +import { queueRewriting } from './threads' +import { DeferSourceMapRewriteFn } from './js' + +// these functions are not included in `./js` or `./html` because doing so +// would mean that `./threads/worker` would unnecessarily end up loading in the +// `./threads` module for each worker + +export function rewriteHtmlJsAsync (url: string, html: string, deferSourceMapRewrite: DeferSourceMapRewriteFn): Promise { + return queueRewriting({ + url, + deferSourceMapRewrite, + source: html, + isHtml: true, + }) +} + +export function rewriteJsAsync (url: string, js: string, deferSourceMapRewrite: DeferSourceMapRewriteFn): Promise { + return queueRewriting({ + url, + deferSourceMapRewrite, + source: js, + }) +} + +export function rewriteJsSourceMapAsync (url: string, js: string, inputSourceMap: any): Promise { + return queueRewriting({ + url, + inputSourceMap, + sourceMap: true, + source: js, + }) +} diff --git a/packages/rewriter/lib/deferred-source-map-cache.ts b/packages/rewriter/lib/deferred-source-map-cache.ts new file mode 100644 index 000000000000..0dfebe7101ab --- /dev/null +++ b/packages/rewriter/lib/deferred-source-map-cache.ts @@ -0,0 +1,127 @@ +import _ from 'lodash' +import Debug from 'debug' +import { rewriteJsSourceMapAsync } from './async-rewriters' +import * as sourceMaps from './util/source-maps' +import url from 'url' + +const debug = Debug('cypress:rewriter:deferred-source-map-cache') + +export type DeferredSourceMapRequest = { + uniqueId: string + url: string + js?: string + sourceMap?: any + resHeaders?: any +} + +const caseInsensitiveGet = (obj, lowercaseProperty) => { + for (let key of Object.keys(obj)) { + if (key.toLowerCase() === lowercaseProperty) { + return obj[key] + } + } +} + +const getSourceMapHeader = (headers) => { + // sourcemap has precedence + // @see https://searchfox.org/mozilla-central/rev/dc4560dcaafd79375b9411fdbbaaebb0a59a93ac/devtools/shared/DevToolsUtils.js#611-619 + return caseInsensitiveGet(headers, 'sourcemap') || caseInsensitiveGet(headers, 'x-sourcemap') +} + +/** + * Holds on to data necessary to rewrite user JS to maybe generate a sourcemap at a later time, + * potentially composed with the user's own sourcemap if one is present. + * + * The purpose of this is to avoid wasting CPU time and network I/O on generating, composing, and + * sending a sourcemap along with every single rewritten JS snippet, since the source maps are + * going to be unused and discarded most of the time. + */ +export class DeferredSourceMapCache { + _idCounter = 0 + requests: DeferredSourceMapRequest[] = [] + requestLib: any + + constructor (requestLib) { + this.requestLib = requestLib + } + + defer = (request: DeferredSourceMapRequest) => { + if (this._getRequestById(request.uniqueId)) { + // prevent duplicate uniqueIds from ever existing + throw new Error(`Deferred sourcemap key "${request.uniqueId}" is not unique`) + } + + // remove existing requests for this URL since they will not be loaded again + this._removeRequestsByUrl(request.url) + + this.requests.push(request) + } + + _removeRequestsByUrl (url: string) { + _.remove(this.requests, { url }) + } + + _getRequestById (uniqueId: string) { + return _.find(this.requests, { uniqueId }) + } + + async _getInputSourceMap (request: DeferredSourceMapRequest, headers: any) { + // prefer inline sourceMappingURL over headers + const sourceMapUrl = sourceMaps.getMappingUrl(request.js!) || getSourceMapHeader(request.resHeaders) + + if (!sourceMapUrl) { + return + } + + // try to decode it as a base64 string + const inline = sourceMaps.tryDecodeInlineUrl(sourceMapUrl) + + if (inline) { + return inline + } + + // try to load it from the web + const req = { + url: url.resolve(request.url, sourceMapUrl), + // TODO: this assumes that the sourcemap is on the same base domain, so it's safe to send the same headers + // the browser sent for this sourcemap request - but if sourcemap is on a different domain, this will not + // be true. need to use browser's cookiejar instead. + headers, + timeout: 5000, + } + + try { + const { body } = await this.requestLib(req, true) + + return body + } catch (error) { + // eslint-disable-next-line no-console + debug('got an error loading user-provided sourcemap, serving proxy-generated sourcemap only %o', { url: request.url, headers, error }) + } + } + + async resolve (uniqueId: string, headers: any) { + const request = this._getRequestById(uniqueId) + + if (!request) { + throw new Error(`Missing request with ID '${uniqueId}'`) + } + + if (request.sourceMap) { + return request.sourceMap + } + + if (!request.js) { + throw new Error('Missing JS for source map rewrite') + } + + const inputSourceMap = await this._getInputSourceMap(request, headers) + + // cache the sourceMap so we don't need to regenerate it + request.sourceMap = await rewriteJsSourceMapAsync(request.url, request.js, inputSourceMap) + delete request.js // won't need this again + delete request.resHeaders + + return request.sourceMap + } +} diff --git a/packages/rewriter/lib/html-rules.ts b/packages/rewriter/lib/html-rules.ts new file mode 100644 index 000000000000..21481479ebd3 --- /dev/null +++ b/packages/rewriter/lib/html-rules.ts @@ -0,0 +1,55 @@ +import find from 'lodash/find' +import RewritingStream from 'parse5-html-rewriting-stream' +import * as js from './js' + +export function install (url: string, rewriter: RewritingStream, deferSourceMapRewrite?: js.DeferSourceMapRewriteFn) { + let currentlyInsideJsScriptTag = false + let inlineJsIndex = 0 + + rewriter.on('startTag', (startTag, raw) => { + if (startTag.tagName !== 'script') { + currentlyInsideJsScriptTag = false + + return rewriter.emitRaw(raw) + } + + const typeAttr = find(startTag.attrs, { name: 'type' }) + + if (typeAttr && typeAttr.value !== 'text/javascript' && typeAttr.value !== 'module') { + // we don't care about intercepting non-JS + + diff --git a/packages/rewriter/test/fixtures/test.js b/packages/rewriter/test/fixtures/test.js new file mode 100644 index 000000000000..109cd3cee729 --- /dev/null +++ b/packages/rewriter/test/fixtures/test.js @@ -0,0 +1,14 @@ +// Generated by CoffeeScript 2.2.1 +(function() { + // just an example of transpilation w/ sourcemap - + // `test.coffee` is not directly transpiled/executed by any test code + // regenerate JS + sourcemap with `coffee -c -m test.coffee` + setTimeout(function() { + window; + var foo; + return foo = `${window.top.foo}`; + }, 1000); + +}).call(this); + +//# sourceMappingURL=test.js.map diff --git a/packages/rewriter/test/fixtures/test.js.map b/packages/rewriter/test/fixtures/test.js.map new file mode 100644 index 000000000000..97db10401eae --- /dev/null +++ b/packages/rewriter/test/fixtures/test.js.map @@ -0,0 +1,13 @@ +{ + "version": 3, + "file": "test.js", + "sourceRoot": "", + "sources": [ + "test.coffee" + ], + "names": [], + "mappings": ";AAAA;EAAA;;;EAIA,UAAA,CAAW,QAAA,CAAA,CAAA;IACT;AAAA,QAAA;WACA,GAAA,GAAM,CAAA,CAAA,CAAG,MAAM,CAAC,GAAG,CAAC,GAAd,CAAA;EAFG,CAAX,EAGE,IAHF;AAJA", + "sourcesContent": [ + "# just an example of transpilation w/ sourcemap -\n# `test.coffee` is not directly transpiled/executed by any test code\n# regenerate JS + sourcemap with `coffee -c -m test.coffee`\n\nsetTimeout ->\n window\n foo = \"#{window.top.foo}\"\n, 1000\n" + ] +} \ No newline at end of file diff --git a/packages/rewriter/test/mocha.opts b/packages/rewriter/test/mocha.opts new file mode 100644 index 000000000000..b981d6da213e --- /dev/null +++ b/packages/rewriter/test/mocha.opts @@ -0,0 +1,4 @@ +test/unit/* +-r @packages/ts/register +--timeout 10000 +--recursive diff --git a/packages/rewriter/test/unit/deferred-source-map-cache-spec.ts b/packages/rewriter/test/unit/deferred-source-map-cache-spec.ts new file mode 100644 index 000000000000..b26dfb439cab --- /dev/null +++ b/packages/rewriter/test/unit/deferred-source-map-cache-spec.ts @@ -0,0 +1,150 @@ +import { DeferredSourceMapCache } from '../../lib/deferred-source-map-cache' +import sinon from 'sinon' +import chai, { expect } from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinonChai from 'sinon-chai' +import { + testSourceWithExternalSourceMap, + testSourceWithInlineSourceMap, + testSourceMap, + testSourceWithNoSourceMap, +} from '../fixtures' +import snapshot from 'snap-shot-it' + +chai.use(chaiAsPromised) +chai.use(sinonChai) + +describe('DeferredSourceMapCache', function () { + let cache: DeferredSourceMapCache + + beforeEach(() => { + cache = new DeferredSourceMapCache(sinon.stub()) + }) + + afterEach(() => { + sinon.restore() + }) + + context('#defer', () => { + it('adds to requests', () => { + const request = { uniqueId: 'foo', url: 'bar' } + + cache.defer(request) + expect(cache.requests).to.deep.eq([request]) + }) + + it('replaces existing requests for same URL', () => { + const request0 = { uniqueId: 'kung-fu', url: 'http://other.url/foo.js' } + const request1 = { uniqueId: 'foo', url: 'http://bar.baz/quux.js' } + const request2 = { uniqueId: 'kung-foo', url: 'http://bar.baz/quux.js' } + + cache.defer(request0) + cache.defer(request1) + cache.defer(request2) + expect(cache.requests).to.deep.eq([request0, request2]) + }) + + it('throws if uniqueId is duplicated', () => { + cache.defer({ uniqueId: 'foo', url: 'bar' }) + expect(() => { + cache.defer({ uniqueId: 'foo', url: 'baz' }) + }).to.throw + }) + }) + + context('#resolve', () => { + it('rejects if unknown uniqueId', async () => { + cache.defer({ + uniqueId: 'baz', + url: 'quux', + }) + + await expect(cache.resolve('foo', {})).to.be.rejectedWith('Missing request with ID \'foo\'') + }) + + it('rejects if request missing JS', async () => { + cache.defer({ + uniqueId: 'foo', + url: 'bar', + }) + + await expect(cache.resolve('foo', {})).to.be.rejectedWith(/^Missing JS/) + }) + + context('sourcemap generation', () => { + it('for JS with no original sourcemap', async () => { + cache.defer({ + uniqueId: 'foo', + url: 'bar', + js: 'console.log()', + resHeaders: {}, + }) + + snapshot(await cache.resolve('foo', {})) + }) + + it('resolves with cached sourceMap on retry', async () => { + cache.defer({ + uniqueId: 'foo', + url: 'bar', + js: 'console.log()', + resHeaders: {}, + }) + + const result0 = await cache.resolve('foo', {}) + const result1 = await cache.resolve('foo', {}) + + expect(result0).to.eq(result1) // same object reference + }) + + context('composition', () => { + const URL = 'http://somedomain.net/dir/foo.js' + + const testExternalSourceMap = (js, resHeaders, expectRequest = true) => { + return async () => { + cache.defer({ + uniqueId: 'foo', + url: URL, + js, + resHeaders, + }) + + // @ts-ignore: https://github.com/bahmutov/snap-shot-it/issues/522 + snapshot('composed sourcemap', await cache.resolve('foo', {}), { allowSharedSnapshot: true }) + + if (!expectRequest) { + return + } + + expect(cache.requestLib).to.be.calledWith({ + url: 'http://somedomain.net/dir/test.js.map', + headers: {}, + timeout: 5000, + }) + } + } + + beforeEach(() => { + cache.requestLib.resolves({ body: testSourceMap }) + }) + + it('with inlined base64 sourceMappingURL', testExternalSourceMap(testSourceWithInlineSourceMap, {}, false)) + + it('with external sourceMappingURL', testExternalSourceMap(testSourceWithExternalSourceMap, { + // sourceMappingURL should override headers + 'SOURCEmap': 'garbage', + 'x-sourceMAP': 'garbage', + })) + + it('with map referenced by sourcemap header', testExternalSourceMap(testSourceWithNoSourceMap, { + 'SOURCEmap': 'test.js.map', + 'x-sourceMAP': 'garbage', // SourceMap header should override x-sourcemap + })) + + it('with map referenced by x-sourcemap header', testExternalSourceMap(testSourceWithNoSourceMap, { + 'x-sourceMAP': 'test.js.map', + })) + }) + }) + }) +}) diff --git a/packages/rewriter/test/unit/html-spec.ts b/packages/rewriter/test/unit/html-spec.ts new file mode 100644 index 000000000000..3d655049ad23 --- /dev/null +++ b/packages/rewriter/test/unit/html-spec.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai' +import { rewriteHtmlJs } from '../../lib/html' +import snapshot from 'snap-shot-it' +import { testHtml } from '../fixtures' + +const URL = 'http://example.com/foo.html' + +const rewriteNoSourceMap = (html) => rewriteHtmlJs(URL, html) + +describe('html rewriter', function () { + context('.rewriteHtmlJs', function () { + // https://github.com/cypress-io/cypress/issues/2393 + it('strips SRI', function () { + snapshot(rewriteNoSourceMap('')) + + // should preserve namespaced attrs and still rewrite if no `type` + snapshot(rewriteNoSourceMap('')) + }) + + it('rewrites inline JS with type', function () { + snapshot(rewriteNoSourceMap('')) + }) + + it('does not rewrite non-JS inline', function () { + snapshot(rewriteNoSourceMap('')) + }) + + it('ignores invalid inline JS', function () { + const str = '' + + expect(rewriteNoSourceMap(str)).to.eq(str) + }) + }) + }) +}) diff --git a/packages/rewriter/test/unit/js-spec.ts b/packages/rewriter/test/unit/js-spec.ts new file mode 100644 index 000000000000..ee0c474f4450 --- /dev/null +++ b/packages/rewriter/test/unit/js-spec.ts @@ -0,0 +1,342 @@ +import _ from 'lodash' +import { expect } from 'chai' +import { _rewriteJsUnsafe } from '../../lib/js' +import fse from 'fs-extra' +import Bluebird from 'bluebird' +import rp from '@cypress/request-promise' +import snapshot from 'snap-shot-it' +import * as astTypes from 'ast-types' +import sinon from 'sinon' +import { + testSourceWithExternalSourceMap, + testSourceWithInlineSourceMap, +} from '../fixtures' + +const URL = 'http://example.com/foo.js' + +function match (varName, prop) { + return `globalThis.top.Cypress.resolveWindowReference(globalThis, ${varName}, '${prop}')` +} + +function matchLocation () { + return `globalThis.top.Cypress.resolveLocationReference(globalThis)` +} + +function testExpectedJs (string: string, expected: string) { + // use _rewriteJsUnsafe so exceptions can cause the test to fail + const actual = _rewriteJsUnsafe(URL, string) + + expect(actual).to.eq(expected) +} + +describe('js rewriter', function () { + afterEach(() => { + sinon.restore() + }) + + context('.rewriteJs', function () { + context('transformations', function () { + context('injects Cypress window property resolver', () => { + [ + ['window.top', match('window', 'top')], + ['window.parent', match('window', 'parent')], + ['window[\'top\']', match('window', 'top')], + ['window[\'parent\']', match('window', 'parent')], + ['window["top"]', match('window', 'top')], + ['window["parent"]', match('window', 'parent')], + ['foowindow.top', match('foowindow', 'top')], + ['foowindow[\'top\']', match('foowindow', 'top')], + ['window.topfoo'], + ['window[\'topfoo\']'], + ['window[\'top\'].foo', `${match('window', 'top')}.foo`], + ['window.top.foo', `${match('window', 'top')}.foo`], + ['window.top["foo"]', `${match('window', 'top')}["foo"]`], + ['window[\'top\']["foo"]', `${match('window', 'top')}["foo"]`], + [ + 'if (window["top"] != window["parent"]) run()', + `if (${match('window', 'top')} != ${match('window', 'parent')}) run()`, + ], + [ + 'if (top != self) run()', + `if (${match('globalThis', 'top')} != self) run()`, + ], + [ + 'if (window != top) run()', + `if (window != ${match('globalThis', 'top')}) run()`, + ], + [ + 'if (top.location != self.location) run()', + `if (${match('top', 'location')} != ${match('self', 'location')}) run()`, + ], + [ + 'n = (c = n).parent', + `n = ${match('c = n', 'parent')}`, + ], + [ + 'e.top = "0"', + `globalThis.top.Cypress.resolveWindowReference(globalThis, e, 'top', "0")`, + ], + ['e.top += 0'], + [ + 'e.bottom += e.top', + `e.bottom += ${match('e', 'top')}`, + ], + [ + 'if (a = (e.top = "0")) { }', + `if (a = (globalThis.top.Cypress.resolveWindowReference(globalThis, e, 'top', "0"))) { }`, + ], + // test that double quotes remain double-quoted + [ + 'a = "b"; window.top', + `a = "b"; ${match('window', 'top')}`, + ], + ['({ top: "foo", parent: "bar" })'], + ['top: "foo"; parent: "bar";'], + ['top: break top'], + ['top: continue top;'], + [ + 'function top() { window.top }; function parent(...top) { window.top }', + `function top() { ${match('window', 'top')} }; function parent(...top) { ${match('window', 'top')} }`, + ], + [ + '(top, ...parent) => { window.top }', + `(top, ...parent) => { ${match('window', 'top')} }`, + ], + [ + '(function top() { window.top }); (function parent(...top) { window.top })', + `(function top() { ${match('window', 'top')} }); (function parent(...top) { ${match('window', 'top')} })`, + ], + [ + 'top += 4', + ], + [ + // test that arguments are not replaced + 'function foo(location) { location.href = \'bar\' }', + ], + [ + // test that global variables are replaced + 'function foo(notLocation) { location.href = \'bar\' }', + `function foo(notLocation) { ${matchLocation()}.href = \'bar\' }`, + ], + [ + // test that scoped declarations are not replaced + 'let location = "foo"; location.href = \'bar\'', + ], + [ + 'location.href = "bar"', + `${matchLocation()}.href = "bar"`, + ], + [ + 'location = "bar"', + `${matchLocation()}.href = "bar"`, + ], + [ + 'window.location.href = "bar"', + `${match('window', 'location')}.href = "bar"`, + ], + [ + 'window.location = "bar"', + `globalThis.top.Cypress.resolveWindowReference(globalThis, window, 'location', "bar")`, + ], + ] + .forEach(([string, expected]) => { + if (!expected) { + expected = string + } + + it(`${string} => ${expected}`, () => { + testExpectedJs(string, expected) + }) + }) + }) + + it('throws an error via the driver if AST visiting throws an error', () => { + // if astTypes.visit throws, that indicates a bug in our js-rules, and so we should stop rewriting + const err = new Error('foo') + + err.stack = 'stack' + + sinon.stub(astTypes, 'visit').throws(err) + + const actual = _rewriteJsUnsafe(URL, 'console.log()') + + snapshot(actual) + }) + + it('replaces jira window getter', () => { + const jira = `\ + for (; !function (n) { + return n === n.parent + }(n);) {}\ + ` + + const jira2 = `\ + (function(n){for(;!function(l){return l===l.parent}(l)&&function(l){try{if(void 0==l.location.href)return!1}catch(l){return!1}return!0}(l.parent);)l=l.parent;return l})\ + ` + + const jira3 = `\ + function satisfiesSameOrigin(w) { + try { + // Accessing location.href from a window on another origin will throw an exception. + if ( w.location.href == undefined) { + return false; + } + } catch (e) { + return false; + } + return true; + } + + function isTopMostWindow(w) { + return w === w.parent; + } + + while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(parentOf.parent)) { + parentOf = parentOf.parent; + }\ + ` + + testExpectedJs(jira, `\ + for (; !function (n) { + return n === ${match('n', 'parent')}; + }(n);) {}\ + `) + + testExpectedJs(jira2, `\ + (function(n){for(;!function(l){return l===${match('l', 'parent')};}(l)&&function(l){try{if(void 0==${match('l', 'location')}.href)return!1}catch(l){return!1}return!0}(${match('l', 'parent')});)l=${match('l', 'parent')};return l})\ + `) + + testExpectedJs(jira3, `\ + function satisfiesSameOrigin(w) { + try { + // Accessing location.href from a window on another origin will throw an exception. + if ( ${match('w', 'location')}.href == undefined) { + return false; + } + } catch (e) { + return false; + } + return true; + } + + function isTopMostWindow(w) { + return w === ${match('w', 'parent')}; + } + + while (!isTopMostWindow(parentOf) && satisfiesSameOrigin(${match('parentOf', 'parent')})) { + parentOf = ${match('parentOf', 'parent')}; + }\ + `) + }) + + describe('libs', () => { + const cdnUrl = 'https://cdnjs.cloudflare.com/ajax/libs' + + const needsDash = ['backbone', 'underscore'] + + let libs = { + jquery: `${cdnUrl}/jquery/3.3.1/jquery.js`, + jqueryui: `${cdnUrl}/jqueryui/1.12.1/jquery-ui.js`, + angular: `${cdnUrl}/angular.js/1.6.5/angular.js`, + bootstrap: `${cdnUrl}/twitter-bootstrap/4.0.0/js/bootstrap.js`, + moment: `${cdnUrl}/moment.js/2.20.1/moment.js`, + lodash: `${cdnUrl}/lodash.js/4.17.5/lodash.js`, + vue: `${cdnUrl}/vue/2.5.13/vue.js`, + backbone: `${cdnUrl}/backbone.js/1.3.3/backbone.js`, + cycle: `${cdnUrl}/cyclejs-core/7.0.0/cycle.js`, + d3: `${cdnUrl}/d3/4.13.0/d3.js`, + underscore: `${cdnUrl}/underscore.js/1.8.3/underscore.js`, + foundation: `${cdnUrl}/foundation/6.4.3/js/foundation.js`, + require: `${cdnUrl}/require.js/2.3.5/require.js`, + rxjs: `${cdnUrl}/rxjs/5.5.6/Rx.js`, + bluebird: `${cdnUrl}/bluebird/3.5.1/bluebird.js`, + } + + libs = _ + .chain(libs) + .clone() + .reduce((memo, url, lib) => { + memo[lib] = url + memo[`${lib}Min`] = url + .replace(/js$/, 'min.js') + .replace(/css$/, 'min.css') + + if (needsDash.includes(lib)) { + memo[`${lib}Min`] = url.replace('min', '-min') + } + + return memo + } + , {}) + .extend({ + knockoutDebug: `${cdnUrl}/knockout/3.4.2/knockout-debug.js`, + knockoutMin: `${cdnUrl}/knockout/3.4.2/knockout-min.js`, + emberMin: `${cdnUrl}/ember.js/2.18.2/ember.min.js`, + emberProd: `${cdnUrl}/ember.js/2.18.2/ember.prod.js`, + reactDev: `${cdnUrl}/react/16.2.0/umd/react.development.js`, + reactProd: `${cdnUrl}/react/16.2.0/umd/react.production.min.js`, + vendorBundle: 'https://s3.amazonaws.com/internal-test-runner-assets.cypress.io/vendor.bundle.js', + hugeApp: 'https://s3.amazonaws.com/internal-test-runner-assets.cypress.io/huge_app.js', + }) + .value() as unknown as typeof libs + + _.each(libs, (url, lib) => { + it(`does not corrupt code from '${lib}'`, function () { + // may have to download and rewrite large files + this.timeout(20000) + + const pathToLib = `/tmp/${lib}` + + const downloadFile = () => { + return rp(url) + .then((resp) => { + return Bluebird.fromCallback((cb) => { + fse.writeFile(pathToLib, resp, cb) + }) + .return(resp) + }) + } + + return fse + .readFile(pathToLib, 'utf8') + .catch(downloadFile) + .then((libCode) => { + const stripped = _rewriteJsUnsafe(url, libCode) + + expect(() => eval(stripped), 'is valid JS').to.not.throw + }) + }) + }) + }) + }) + + context('source maps', function () { + it('emits sourceInfo as expected', function (done) { + _rewriteJsUnsafe(URL, 'window.top', (sourceInfo) => { + snapshot(sourceInfo) + done() + + return '' + }) + }) + + it('emits info about existing inline sourcemap', function (done) { + _rewriteJsUnsafe(URL, testSourceWithInlineSourceMap, (sourceInfo) => { + snapshot(sourceInfo) + done() + + return '' + }) + }) + + it('emits info about existing external sourcemap', function (done) { + _rewriteJsUnsafe(URL, testSourceWithExternalSourceMap, (sourceInfo) => { + snapshot(sourceInfo) + done() + + return '' + }) + }) + }) + }) +}) diff --git a/packages/rewriter/tsconfig.json b/packages/rewriter/tsconfig.json new file mode 100644 index 000000000000..6e4f8367c920 --- /dev/null +++ b/packages/rewriter/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./../ts/tsconfig.json", + "include": [ + "*.ts", + "lib/*.ts", + "lib/**/*.ts" + ], + "files": [ + "./../ts/index.d.ts" + ] +} diff --git a/packages/server/__snapshots__/6_visit_spec.coffee.js b/packages/server/__snapshots__/6_visit_spec.coffee.js index 4b6fe6d8d3a1..49e83784ac9d 100644 --- a/packages/server/__snapshots__/6_visit_spec.coffee.js +++ b/packages/server/__snapshots__/6_visit_spec.coffee.js @@ -748,3 +748,86 @@ exports['e2e visit / low response timeout / calls onBeforeLoad when overwriting ` + +exports['e2e visit / low response timeout / passes with experimentalSourceRewriting'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (source_rewriting_spec.js) │ + │ Searched: cypress/integration/source_rewriting_spec.js │ + │ Experiments: experimentalSourceRewriting=true │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: source_rewriting_spec.js (1 of 1) + + + source rewriting spec + ✓ obstructive code is replaced + issue 3975 + ✓ can relative redirect in a xhr onload + ✓ can relative redirect in a onclick handler + ✓ can relative redirect in a settimeout with a base tag + - Login demo + it can relative redirect in a settimeout + ✓ with location.href + ✓ with window.location.href + ✓ with location.replace() + ✓ with location.assign() + ✓ with location = ... + ✓ with window.location = ... + ✓ with location.search + ✓ with location.pathname + can load some well-known sites in a timely manner + - http://google.com + - http://facebook.com + - http://cypress.io + - http://docs.cypress.io + - http://github.com + + + 12 passing + 6 pending + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 18 │ + │ Passing: 12 │ + │ Failing: 0 │ + │ Pending: 6 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: source_rewriting_spec.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/source_rewriting_spec.js.mp4 (X second) + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✔ source_rewriting_spec.js XX:XX 18 12 - 6 - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✔ All specs passed! XX:XX 18 12 - 6 - + + +` diff --git a/packages/server/lib/config.coffee b/packages/server/lib/config.coffee index c4ac710cb0e0..a5082a763ca0 100644 --- a/packages/server/lib/config.coffee +++ b/packages/server/lib/config.coffee @@ -100,7 +100,7 @@ systemConfigKeys = toWords """ # Know experimental flags / values # each should start with "experimental" and be camel cased # example: experimentalComponentTesting -experimentalConfigKeys = ['experimentalGetCookiesSameSite', "experimentalComponentTesting"] +experimentalConfigKeys = ['experimentalGetCookiesSameSite', 'experimentalSourceRewriting', 'experimentalComponentTesting'] CONFIG_DEFAULTS = { port: null @@ -165,6 +165,7 @@ CONFIG_DEFAULTS = { # TODO: example for component testing with subkeys # experimentalComponentTesting: { componentFolder: 'cypress/component' } experimentalGetCookiesSameSite: false + experimentalSourceRewriting: false } validationRules = { @@ -210,6 +211,7 @@ validationRules = { componentFolder: v.isStringOrFalse # experimental flag validation below experimentalGetCookiesSameSite: v.isBoolean + experimentalSourceRewriting: v.isBoolean } convertRelativeToAbsolutePaths = (projectRoot, obj, defaults = {}) -> diff --git a/packages/server/lib/experiments.ts b/packages/server/lib/experiments.ts index 3cb5628dbe57..3064e3c551e1 100644 --- a/packages/server/lib/experiments.ts +++ b/packages/server/lib/experiments.ts @@ -52,6 +52,7 @@ interface StringValues { */ const _summaries: StringValues = { experimentalComponentTesting: 'Framework-specific component testing, uses `componentFolder` to load component specs', + experimentalSourceRewriting: 'Enables AST-based JS/HTML rewriting. This may fix issues caused by the existing regex-based JS/HTML replacement algorithm.', experimentalGetCookiesSameSite: 'Adds `sameSite` values to the objects yielded from `cy.setCookie()`, `cy.getCookie()`, and `cy.getCookies()`. This will become the default behavior in Cypress 5.0.', } @@ -67,6 +68,7 @@ const _summaries: StringValues = { */ const _names: StringValues = { experimentalComponentTesting: 'Component Testing', + experimentalSourceRewriting: 'Improved source rewriting', experimentalGetCookiesSameSite: 'Set `sameSite` property when retrieving cookies', } diff --git a/packages/server/lib/request.js b/packages/server/lib/request.js index 53751ec5b06d..f0de65ac2b1f 100644 --- a/packages/server/lib/request.js +++ b/packages/server/lib/request.js @@ -741,12 +741,6 @@ module.exports = function (options = {}) { return this.create(options, true) .then(this.normalizeResponse.bind(this, push)) .then((resp) => { - // TODO: move duration somewhere...? - // does node store this somewhere? - // we could probably calculate this ourselves - // by using the date headers - let loc - resp.duration = Date.now() - ms resp.allRequestResponses = requestResponses @@ -754,10 +748,10 @@ module.exports = function (options = {}) { resp.redirects = redirects } - if ((options.followRedirect === false) && (loc = resp.headers.location)) { + if ((options.followRedirect === false) && resp.headers.location) { // resolve the new location head against // the current url - resp.redirectedToUrl = url.resolve(options.url, loc) + resp.redirectedToUrl = url.resolve(options.url, resp.headers.location) } return this.setCookiesOnBrowser(resp, currentUrl, automationFn) diff --git a/packages/server/lib/routes.js b/packages/server/lib/routes.js index b94389a1af5c..6c69630b8739 100644 --- a/packages/server/lib/routes.js +++ b/packages/server/lib/routes.js @@ -53,6 +53,10 @@ module.exports = ({ app, config, getRemoteState, networkProxy, project, onError xhrs.handle(req, res, config, next) }) + app.get('/__cypress/source-maps/:id.map', (req, res) => { + networkProxy.handleSourceMapRequest(req, res) + }) + // special fallback - serve local files from the project's root folder app.get('/__root/*', (req, res) => { const file = path.join(config.projectRoot, req.params[0]) diff --git a/packages/server/lib/server.coffee b/packages/server/lib/server.coffee index e70205d4fff3..1c942f84f61e 100644 --- a/packages/server/lib/server.coffee +++ b/packages/server/lib/server.coffee @@ -20,6 +20,7 @@ debug = require("debug")("cypress:server:server") uri } = require("@packages/network") { NetworkProxy } = require("@packages/proxy") +{ createInitialWorkers } = require("@packages/rewriter") origin = require("./util/origin") ensureUrl = require("./util/ensure-url") appData = require("./util/app_data") @@ -183,6 +184,9 @@ class Server @_networkProxy = new NetworkProxy({ config, getRemoteState, getFileServerToken, request: @_request }) + if config.experimentalSourceRewriting + createInitialWorkers() + @createHosts(config.hosts) @createRoutes({ diff --git a/packages/server/test/e2e/6_visit_spec.coffee b/packages/server/test/e2e/6_visit_spec.coffee index 08772eea1de9..00174294bc24 100644 --- a/packages/server/test/e2e/6_visit_spec.coffee +++ b/packages/server/test/e2e/6_visit_spec.coffee @@ -99,15 +99,6 @@ describe "e2e visit", -> } }) - ## this tests that hashes are applied during a visit - ## which forces the browser to scroll to the div - ## additionally this tests that jquery.js is not truncated - ## due to __cypress.initial cookies not being cleared by - ## the hash.html response - - ## additionally this tests that xhr request headers + body - ## can reach the backend without being modified or changed - ## by the cypress proxy in any way e2e.it "passes", { spec: "visit_spec.coffee" snapshot: true @@ -119,6 +110,20 @@ describe "e2e visit", -> serv.destroy() } + e2e.it "passes with experimentalSourceRewriting", { + spec: "source_rewriting_spec.js" + config: { + experimentalSourceRewriting: true + } + snapshot: true + onRun: (exec) -> + startTlsV1Server(6776) + .then (serv) -> + exec() + .then -> + serv.destroy() + } + e2e.it "fails when network connection immediately fails", { spec: "visit_http_network_error_failing_spec.coffee" snapshot: true diff --git a/packages/server/test/integration/http_requests_spec.coffee b/packages/server/test/integration/http_requests_spec.coffee index 50f21222d085..f6f5cd97d675 100644 --- a/packages/server/test/integration/http_requests_spec.coffee +++ b/packages/server/test/integration/http_requests_spec.coffee @@ -1101,7 +1101,7 @@ describe "Routes", -> .get("/gzip") .matchHeader("accept-encoding", "gzip") .replyWithFile(200, Fixtures.path("server/gzip.html.gz"), { - "Content-Type": "application/javascript" + "Content-Type": "text/html" "Content-Encoding": "gzip" }) @@ -1132,6 +1132,7 @@ describe "Routes", -> js += chunk res.write(chunk) + ## note - this is unintentionally invalid JS, just try executing it anywhere write("function ") _.times 100, => write("😡😈".repeat(10)) diff --git a/packages/server/test/support/fixtures/projects/e2e/cypress/integration/source_rewriting_spec.js b/packages/server/test/support/fixtures/projects/e2e/cypress/integration/source_rewriting_spec.js new file mode 100644 index 000000000000..06dd41f00831 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/cypress/integration/source_rewriting_spec.js @@ -0,0 +1,101 @@ +describe('source rewriting spec', function () { + it('obstructive code is replaced', function () { + // based off of driver e2e security_spec + cy.visit('/obstructive_code.html') + cy.contains('html ran') + cy.contains('js ran') + cy.get('body').then(([body]) => { + expect(body.innerText).to.not.contain('security triggered') + }) + }) + + // @see https://github.com/cypress-io/cypress/issues/3975 + context('issue 3975', function () { + it('can relative redirect in a xhr onload', function () { + cy.visit('/static/xhr_onload_redirect.html') + cy.location('pathname').should('eq', '/static/index.html') + }) + + context('it can relative redirect in a settimeout', function () { + it('with location.href', function () { + cy.visit('/static/settimeout_redirect_href.html') + cy.location('pathname').should('eq', '/static/index.html') + }) + + it('with window.location.href', function () { + cy.visit('/static/settimeout_redirect_window_href.html') + cy.location('pathname').should('eq', '/static/index.html') + }) + + it('with location.replace()', function () { + cy.visit('/static/settimeout_redirect_replace.html') + cy.location('pathname').should('eq', '/static/index.html') + }) + + it('with location.assign()', function () { + cy.visit('/static/settimeout_redirect_assign.html') + cy.location('pathname').should('eq', '/static/index.html') + }) + + it('with location = ...', function () { + cy.visit('/static/settimeout_redirect_set_location.html') + cy.location('pathname').should('eq', '/static/index.html') + }) + + it('with window.location = ...', function () { + cy.visit('/static/settimeout_redirect_set_window_location.html') + cy.location('pathname').should('eq', '/static/index.html') + }) + + it('with location.search', function () { + cy.visit('/static/settimeout_redirect_search.html') + cy.location().should('include', { + pathname: '/static/settimeout_redirect_search.html', + search: '?foo', + }) + }) + + it('with location.pathname', function () { + cy.visit('/static/settimeout_redirect_pathname.html') + cy.location('pathname').should('eq', '/index.html') + }) + }) + + it('can relative redirect in a onclick handler', function () { + cy.visit('/static/onclick_redirect.html') + cy.get('button').click() + cy.location('pathname').should('eq', '/static/index.html') + }) + + it('can relative redirect in a settimeout with a base tag', function () { + cy.visit('/static/settimeout_basetag_redirect.html') + cy.location('pathname').should('eq', '/static/foo/bar/index.html') + }) + + // NOTE: user's repro + it.skip('Login demo', function () { + // cy.on('fail', console.error) + cy.visit('https://apex.oracle.com/pls/apex/f?p=54707:LOGIN_DESKTOP', { timeout: 60000 }) + cy.get('#P9999_USERNAME').type('ApexUser') + cy.get('#P9999_PASSWORD').type('Oradoc_db1') + cy.get('.t-Button').click() + }) + }) + + // NOTE: skip in CI for now - can be flaky + context.skip('can load some well-known sites in a timely manner', () => { + [ + // FIXME: has to be HTTPS - https://github.com/cypress-io/cypress/issues/7268 + // 'http://apple.com', + 'http://google.com', + 'http://facebook.com', + 'http://cypress.io', + 'http://docs.cypress.io', + 'http://github.com', + ].forEach((url) => { + it(url, () => { + cy.visit(url, { timeout: 60000 }) + }) + }) + }) +}) diff --git a/packages/server/test/support/fixtures/projects/e2e/obstructive_code.html b/packages/server/test/support/fixtures/projects/e2e/obstructive_code.html new file mode 100644 index 000000000000..79e1a6f23f29 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/obstructive_code.html @@ -0,0 +1,70 @@ + + + + testing security clickjacking and framebusting + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/obstructive_code.js b/packages/server/test/support/fixtures/projects/e2e/static/obstructive_code.js new file mode 100644 index 000000000000..9cfd494f70ba --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/obstructive_code.js @@ -0,0 +1,61 @@ +/* eslint-disable */ + +(function () { + function run () { + const div = document.createElement('div') + + div.innerText = `security triggered ${(new Error).stack.split('\n', 3)[2]}` + document.body.appendChild(div) + } + + window.topFoo = "foo" + window.parentFoo = "foo" + + ;(function() { + const top = 'foo' + const parent = 'foo' + const self = 'foo' + + // should stay local + if (top !== self) run() + if (parent !== self) run() + if (self !== top) run() + if (self !== parent) run() + })() + + // TODO: replace object pattern destructuring + // ;(function() { + // const { top, parent, location } = window + + // if (location != top.location) run() + // if (parent != top.parent) run() + // if (top != globalThis.top) run() + // })() + + if (top != self) run() + if (top!=self) run() + if (top.location != self.location) run() + if (top.location != location) run() + if (parent.frames.length > 0) run() + if (window != top) run() + if (window.top !== window.self) run() + if (window.top!==window.self) run() + if (window.self != window.top) run() + if (window.top != window.self) run() + if (window["top"] != window["parent"]) run() + if (window['top'] != window['parent']) run() + if (window["top"] != self['parent']) run() + if (parent && parent != window) run() + if (parent && parent != self) run() + if (parent && window.topFoo != topFoo) run() + if (parent && window.parentFoo != parentFoo) run() + if (parent && window != parent) run() + if (parent && self != parent) run() + if (parent && parent.frames && parent.frames.length > 0) run() + if ((self.parent && !(self.parent === self)) && (self.parent.frames.length != 0)) run() + + const div = document.createElement('div') + + div.innerText = 'js ran' + document.body.appendChild(div) +})() diff --git a/packages/server/test/support/fixtures/projects/e2e/static/onclick_redirect.html b/packages/server/test/support/fixtures/projects/e2e/static/onclick_redirect.html new file mode 100644 index 000000000000..f44bc0cfcdf7 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/onclick_redirect.html @@ -0,0 +1,14 @@ + + + + Document + + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_basetag_redirect.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_basetag_redirect.html new file mode 100644 index 000000000000..b0b684695d3a --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_basetag_redirect.html @@ -0,0 +1,15 @@ + + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_assign.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_assign.html new file mode 100644 index 000000000000..67bfd71b785e --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_assign.html @@ -0,0 +1,13 @@ + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_href.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_href.html new file mode 100644 index 000000000000..5718635ff096 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_href.html @@ -0,0 +1,13 @@ + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_pathname.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_pathname.html new file mode 100644 index 000000000000..81f017f7f744 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_pathname.html @@ -0,0 +1,13 @@ + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_replace.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_replace.html new file mode 100644 index 000000000000..e5f3658a1bc3 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_replace.html @@ -0,0 +1,13 @@ + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_search.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_search.html new file mode 100644 index 000000000000..3580dd73acbb --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_search.html @@ -0,0 +1,15 @@ + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_set_location.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_set_location.html new file mode 100644 index 000000000000..90b1ce3ee8c9 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_set_location.html @@ -0,0 +1,13 @@ + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_set_window_location.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_set_window_location.html new file mode 100644 index 000000000000..04e7b72c0d19 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_set_window_location.html @@ -0,0 +1,13 @@ + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_window_href.html b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_window_href.html new file mode 100644 index 000000000000..b95d6f0125c2 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/settimeout_redirect_window_href.html @@ -0,0 +1,13 @@ + + + + Document + + + + + diff --git a/packages/server/test/support/fixtures/projects/e2e/static/xhr_onload_redirect.html b/packages/server/test/support/fixtures/projects/e2e/static/xhr_onload_redirect.html new file mode 100644 index 000000000000..81c29d152bee --- /dev/null +++ b/packages/server/test/support/fixtures/projects/e2e/static/xhr_onload_redirect.html @@ -0,0 +1,18 @@ + + + + Document + + + + + + diff --git a/packages/server/test/unit/config_spec.coffee b/packages/server/test/unit/config_spec.coffee index d854df44c058..ba3a66525d1e 100644 --- a/packages/server/test/unit/config_spec.coffee +++ b/packages/server/test/unit/config_spec.coffee @@ -798,6 +798,7 @@ describe "lib/config", -> responseTimeout: { value: 30000, from: "default" }, execTimeout: { value: 60000, from: "default" }, experimentalGetCookiesSameSite: { value: false, from: "default" }, + experimentalSourceRewriting: { value: false, from: "default" }, taskTimeout: { value: 60000, from: "default" }, numTestsKeptInMemory: { value: 50, from: "default" }, waitForAnimations: { value: true, from: "default" }, @@ -870,6 +871,7 @@ describe "lib/config", -> responseTimeout: { value: 30000, from: "default" }, execTimeout: { value: 60000, from: "default" }, experimentalGetCookiesSameSite: { value: false, from: "default" }, + experimentalSourceRewriting: { value: false, from: "default" }, taskTimeout: { value: 60000, from: "default" }, numTestsKeptInMemory: { value: 50, from: "default" }, waitForAnimations: { value: true, from: "default" }, diff --git a/yarn.lock b/yarn.lock index a3d370857427..f7a67534ce26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3581,13 +3581,20 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== -"@sinonjs/commons@^1", "@sinonjs/commons@^1.2.0", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0": +"@sinonjs/commons@^1", "@sinonjs/commons@^1.2.0", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2" integrity sha512-+DUO6pnp3udV/v2VfUWgaY5BIE1IfT7lLfeDzPVeMT1XKkaAp9LgSI9x5RtrFQoZ9Oi0PgXQQHPaoKu7dCjVxw== dependencies: type-detect "4.0.8" +"@sinonjs/fake-timers@^6.0.0", "@sinonjs/fake-timers@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40" + integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/formatio@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-2.0.0.tgz#84db7e9eb5531df18a8c5e0bfb6e449e55e654b2" @@ -3611,6 +3618,14 @@ "@sinonjs/commons" "^1" "@sinonjs/samsam" "^4.2.0" +"@sinonjs/formatio@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-5.0.1.tgz#f13e713cb3313b1ab965901b01b0828ea6b77089" + integrity sha512-KaiQ5pBf1MpS09MuA0kp6KBQt2JUOQycqVG1NZXvzeaXe5LGFqAKueIS0bw4w0P9r7KuBSVdUk5QjXsUdu2CxQ== + dependencies: + "@sinonjs/commons" "^1" + "@sinonjs/samsam" "^5.0.2" + "@sinonjs/samsam@^3.0.2", "@sinonjs/samsam@^3.1.0", "@sinonjs/samsam@^3.3.1", "@sinonjs/samsam@^3.3.3": version "3.3.3" resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-3.3.3.tgz#46682efd9967b259b81136b9f120fd54585feb4a" @@ -3629,6 +3644,15 @@ lodash.get "^4.4.2" type-detect "^4.0.8" +"@sinonjs/samsam@^5.0.2", "@sinonjs/samsam@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.0.3.tgz#86f21bdb3d52480faf0892a480c9906aa5a52938" + integrity sha512-QucHkc2uMJ0pFGjJUDP3F9dq5dx8QIaqISl9QgwLOh6P9yv877uONPGXh/OH/0zmM3tW1JjuJltAZV2l7zU+uQ== + dependencies: + "@sinonjs/commons" "^1.6.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + "@sinonjs/text-encoding@^0.7.1": version "0.7.1" resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" @@ -4050,6 +4074,26 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/parse5-html-rewriting-stream@5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@types/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.1.tgz#a53feb6070b02193b5fc64e3cd81937a0f2503a9" + integrity sha512-mjD4nx8WudMCR8EQVlU7Trc2uwKND7LMuftzgsbMmiFTeeW+viKjeqg7VnE0TNjSSQv3z9588K8BsT+N1EBLlg== + dependencies: + "@types/parse5-sax-parser" "*" + +"@types/parse5-sax-parser@*": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/parse5-sax-parser/-/parse5-sax-parser-5.0.1.tgz#f1e26e82bb09e48cb0c16ff6d1e88aea1e538fd5" + integrity sha512-wBEwg10aACLggnb44CwzAA27M1Jrc/8TR16zA61/rKO5XZoi7JSfLjdpXbshsm7wOlM6hpfvwygh40rzM2RsQQ== + dependencies: + "@types/node" "*" + "@types/parse5" "*" + +"@types/parse5@*": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.2.tgz#a877a4658f8238c8266faef300ae41c84d72ec8a" + integrity sha512-BOl+6KDs4ItndUWUFchy3aEqGdHhw0BC4Uu+qoDonN/f0rbUnJbm71Ulj8Tt9jLFRaAxPLKvdS1bBLfx1qXR9g== + "@types/parsimmon@^1.3.0": version "1.10.1" resolved "https://registry.yarnpkg.com/@types/parsimmon/-/parsimmon-1.10.1.tgz#d46015ad91128fce06a1a688ab39a2516507f740" @@ -5441,6 +5485,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +at-least-node@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" + integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== + atob-lite@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696" @@ -12111,6 +12160,16 @@ fs-extra@8.1.0, fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.0.tgz#b6afc31036e247b2466dc99c29ae797d5d4580a3" + integrity sha512-pmEYSk3vYsG/bF651KPUXZ+hvjpgWYw/Gc7W9NFUe3ZVLczKKWIij3IKpOrQcdw4TILtibFslZ0UmR8Vvzig4g== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^1.0.0" + fs-extra@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-1.0.0.tgz#cd3ce5f7e7cb6145883fcae3191e9877f8587950" @@ -13555,16 +13614,7 @@ http-proxy-agent@^2.1.0: agent-base "4" debug "3.1.0" -http-proxy@^1.17.0: - version "1.18.0" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" - integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== - dependencies: - eventemitter3 "^4.0.0" - follow-redirects "^1.0.0" - requires-port "^1.0.0" - -http-proxy@cypress-io/node-http-proxy#9322b4b69b34f13a6f3874e660a35df3305179c6: +http-proxy@^1.17.0, http-proxy@cypress-io/node-http-proxy#9322b4b69b34f13a6f3874e660a35df3305179c6: version "1.18.0" resolved "https://codeload.github.com/cypress-io/node-http-proxy/tar.gz/9322b4b69b34f13a6f3874e660a35df3305179c6" dependencies: @@ -15634,6 +15684,15 @@ jsonfile@^4.0.0: optionalDependencies: graceful-fs "^4.1.6" +jsonfile@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.0.1.tgz#98966cba214378c8c84b82e085907b40bf614179" + integrity sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg== + dependencies: + universalify "^1.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + jsonify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" @@ -17857,6 +17916,17 @@ nise@^3.0.1: lolex "^5.0.1" path-to-regexp "^1.7.0" +nise@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/nise/-/nise-4.0.3.tgz#9f79ff02fa002ed5ffbc538ad58518fa011dc913" + integrity sha512-EGlhjm7/4KvmmE6B/UFsKh7eHykRl9VH+au8dduHLCyWUO/hr7+N+WtTvDUwc9zHuM1IaIJs/0lQ6Ag1jDkQSg== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/fake-timers" "^6.0.0" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + no-case@^2.2.0: version "2.3.2" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" @@ -17874,6 +17944,16 @@ nock@12.0.2: lodash "^4.17.13" propagate "^2.0.0" +nock@12.0.3: + version "12.0.3" + resolved "https://registry.yarnpkg.com/nock/-/nock-12.0.3.tgz#83f25076dbc4c9aa82b5cdf54c9604c7a778d1c9" + integrity sha512-QNb/j8kbFnKCiyqi9C5DD0jH/FubFGj5rt9NQFONXwQm3IPB0CULECg/eS3AU1KgZb/6SwUa4/DTRKhVxkGABw== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash "^4.17.13" + propagate "^2.0.0" + node-abi@^2.7.0: version "2.16.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.16.0.tgz#7df94e9c0a7a189f4197ab84bac8089ef5894992" @@ -19084,6 +19164,21 @@ parse-url@^5.0.0: parse-path "^4.0.0" protocols "^1.4.0" +parse5-html-rewriting-stream@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-5.1.1.tgz#fc18570ba0d09b5091250956d1c3f716ef0a07b7" + integrity sha512-rbXBeMlJ3pk3tKxLKAUaqvQTZM5KTohXmZvYEv2gU9sQC70w65BxPsh3PVVnwiVNCnNYDtNZRqCKmiMlfdG07Q== + dependencies: + parse5 "^5.1.1" + parse5-sax-parser "^5.1.1" + +parse5-sax-parser@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-5.1.1.tgz#02834a9d08b23ea2d99584841c38be09d5247a15" + integrity sha512-9HIh6zd7bF1NJe95LPCUC311CekdOi55R+HWXNCsGY6053DWaMijVKOv1oPvdvPTvFicifZyimBVJ6/qvG039Q== + dependencies: + parse5 "^5.1.1" + parse5@4.0.0, parse5@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" @@ -19101,6 +19196,11 @@ parse5@^3.0.1: dependencies: "@types/node" "*" +parse5@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" + integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== + parseqs@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" @@ -20764,6 +20864,16 @@ recast@0.10.33: private "~0.1.5" source-map "~0.5.0" +recast@0.18.8: + version "0.18.8" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.18.8.tgz#e745d8b7d6da549a03099ff648c957288f4649a4" + integrity sha512-pxiq+ZAF0mYQuhQI+qqr8nFjgmEOFYA3YUVV8dXM7Mz20vs2WyKM1z2W0v80RZ/WICeNw2EeORg+QdDIgAX2ng== + dependencies: + ast-types "0.13.3" + esprima "~4.0.0" + private "^0.1.8" + source-map "~0.6.1" + recast@^0.10.10: version "0.10.43" resolved "https://registry.yarnpkg.com/recast/-/recast-0.10.43.tgz#b95d50f6d60761a5f6252e15d80678168491ce7f" @@ -22168,6 +22278,11 @@ sinon-chai@3.4.0: resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.4.0.tgz#06fb88dee80decc565106a3061d380007f21e18d" integrity sha512-BpVxsjEkGi6XPbDXrgWUe7Cb1ZzIfxKUbu/MmH5RoUnS7AXpKo3aIYIyQUg0FMvlUL05aPt7VZuAdaeQhEnWxg== +sinon-chai@3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.5.0.tgz#c9a78304b0e15befe57ef68e8a85a00553f5c60e" + integrity sha512-IifbusYiQBpUxxFJkR3wTU68xzBN0+bxCScEaKMjBvAQERg6FnTTc1F17rseLb1tjmkJ23730AXpFI0c47FgAg== + sinon@1.17.7: version "1.17.7" resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.7.tgz#4542a4f49ba0c45c05eb2e9dd9d203e2b8efe0bf" @@ -22256,6 +22371,19 @@ sinon@8.1.1: nise "^3.0.1" supports-color "^7.1.0" +sinon@9.0.2: + version "9.0.2" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.0.2.tgz#b9017e24633f4b1c98dfb6e784a5f0509f5fd85d" + integrity sha512-0uF8Q/QHkizNUmbK3LRFqx5cpTttEVXudywY9Uwzy8bTfZUhljZ7ARzSxnRHWYWtVTeh4Cw+tTb3iU21FQVO9A== + dependencies: + "@sinonjs/commons" "^1.7.2" + "@sinonjs/fake-timers" "^6.0.1" + "@sinonjs/formatio" "^5.0.1" + "@sinonjs/samsam" "^5.0.3" + diff "^4.0.2" + nise "^4.0.1" + supports-color "^7.1.0" + sisteransi@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -24369,6 +24497,11 @@ universalify@^0.1.0, universalify@^0.1.2: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" + integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"