Rewrite JS/HTML using AST-based approach (#5273)

* Add winPropAccessor to security.js, remove other replacers

* Add start of Cypress.resolveWindowReference

* Add regexes for dot and bracket access

* Some security_spec tests pass with new injection

* Add resolveWindowReference unit tests

* Old security_spec now passes with resolveWindowReference

* Inject stub resolveWindowReference so proxy still works outside of Cypress

* wip: rewrite HTML + JS with tokenizer

* Move to using esprima + hyntax to rewrite JS + HTML

* remove comment; oneLine makes the whole thing commented

* Fix tests, apple.com edge case

* wip: add getOrSet

* Revert "wip: add getOrSet"

This reverts commit a5c647c00f.

* release 3.5.0 [skip ci]

* use recast to replace window property accesses

* replace assignments to top properly

* fix yarn.lock

* bump deps

* update integration tests

* remove old security ts?

* fix integration spec

* always ignore js interception failure

* use globalThis instead of window

* add experimentalSourceRewriting flag

* restore regex-writer spec

* fix types

* update config_spec

* add source rewriting spec

* cleanup

* simplify rewriting logic, move rules into rewriter package

* create threaded rewriting tool for non-streaming use

* update @packages/rewriter to use threads for async

* use async rewriting where convenient

* add worker-shim.js

* add performance info to debug logs

* properly handle +=, -=, ...

* add proxy, rewriter to unit-tests stage

* cleanup

* use parse5 to rewrite HTML, strip SRI

* update tests

* reorganization, cleanup

* rewrite ALL parent, top identifiers except in a few cases

* handle many JS edge cases

* ensure parse5@5.1.1 is installed

* update yarn.lock

* update tests

* add debugging, add tests

* add attempted repro for .href issue

* implement source maps + extending inline source maps

* update opts passing in proxy layer

* fix sourcemap naming structure

* update tests to account for sourcemaps

* sourcemap tests

* remote source maps work

* comment

* update rewriter tests

* clean up TODOs in resolveWindowReference

* remove @types/nock

* clean up todos in deferred-source-map-cache

* fix rewriter build script

* fix concatStream import

* bump expectedresultcount

* clean up js-rules

* threading improvements, workaround for Electron segfault

* no visit_spec for now

* fix 6_visit_spec

* update MAX_WORKER_THREADS

* add repro for #3975

* cleanup

* cleanup

* make better use of namedTypes and builders

* get rid of the horrific closureDetectionTernary

ast-types keeps track of scope, so it is unneeded

* fix #3975, #3994

* add x-sourcemap, sourcemap header support

* snap-shot-it 7.9.3

* add deferred-source-map-cache-spec

* add tests

* Throw error in driver if AST rewriting fails

* Fix "location = 'relative-url'"

* fix max recursion depth

* slim down some fixtures

* fix window.location usage

* don't mess with `frames` at all

* no integration tests

* skip testing apple.com for now

* update wording: regex-based vs. ast-based

* skip real-world tests for now

* add some padding to process.exit workaround

* fix resolvers_spec

* fix html-spec

* cleanup

* Update packages/rewriter/lib/js-rules.ts

* Update packages/driver/src/cypress/resolvers.ts

* just import find by itself

* privatize typedefs for Cypress.state, remove .gitignore, remove dead code

Co-authored-by: Ben Kucera <14625260+Bkucera@users.noreply.github.com>
This commit is contained in:
Zach Bloomquist
2020-05-11 12:54:14 -04:00
committed by GitHub
parent 9ab4db80de
commit 6960f7cd78
70 changed files with 2980 additions and 66 deletions

View File

@@ -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

View File

@@ -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."
}
}
}

10
cli/types/index.d.ts vendored
View File

@@ -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
}
/**

View File

@@ -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}'\"",

View File

@@ -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

View File

@@ -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."

View File

@@ -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
},
})
}

View File

@@ -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)
})
})
})

17
packages/driver/ts/internal-types.d.ts vendored Normal file
View File

@@ -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<HTMLIFrameElement>): JQuery<HTMLIFrameElement> | undefined
}
}

View File

@@ -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()
}

View File

@@ -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<T> = {
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<any> = {
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()
}

View File

@@ -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()
}

View File

@@ -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,
)
}

View File

@@ -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 = /(<head(?!er).*?>)/i
const bodyRe = /(<body.*?>)/i
const htmlRe = /(<html.*?>)/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)
}

View File

@@ -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)
}

View File

@@ -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 = `\
<html>
@@ -168,10 +168,10 @@ const expected = `\
</html>\
`
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()

View File

@@ -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`.

View File

@@ -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"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -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"} })
`

View File

@@ -0,0 +1,5 @@
if (process.env.CYPRESS_ENV !== 'production') {
require('@packages/ts/register')
}
module.exports = require('./lib')

View File

@@ -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<string> {
return queueRewriting({
url,
deferSourceMapRewrite,
source: html,
isHtml: true,
})
}
export function rewriteJsAsync (url: string, js: string, deferSourceMapRewrite: DeferSourceMapRewriteFn): Promise<string> {
return queueRewriting({
url,
deferSourceMapRewrite,
source: js,
})
}
export function rewriteJsSourceMapAsync (url: string, js: string, inputSourceMap: any): Promise<string> {
return queueRewriting({
url,
inputSourceMap,
sourceMap: true,
source: js,
})
}

View File

@@ -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
}
}

View File

@@ -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 <script> tags
currentlyInsideJsScriptTag = false
return rewriter.emitRaw(raw)
}
currentlyInsideJsScriptTag = true
// rename subresource integrity attr since cypress's rewriting will invalidate SRI hashes
// @see https://github.com/cypress-io/cypress/issues/2393
const sriAttr = find(startTag.attrs, { name: 'integrity' })
if (sriAttr) {
sriAttr.name = 'cypress:stripped-integrity'
}
return rewriter.emitStartTag(startTag)
})
rewriter.on('endTag', (_endTag, raw) => {
currentlyInsideJsScriptTag = false
return rewriter.emitRaw(raw)
})
rewriter.on('text', (_textToken, raw) => {
if (!currentlyInsideJsScriptTag) {
return rewriter.emitRaw(raw)
}
// rewrite inline JS in <script> tags
// create a unique filename per inline script
const fakeJsUrl = [url, inlineJsIndex++].join(':')
return rewriter.emitRaw(js.rewriteJs(fakeJsUrl, raw, deferSourceMapRewrite))
})
}

View File

@@ -0,0 +1,28 @@
import RewritingStream from 'parse5-html-rewriting-stream'
import * as htmlRules from './html-rules'
import stream from 'stream'
import { DeferSourceMapRewriteFn } from './js'
// the HTML rewriter passes inline JS to the JS rewriter, hence
// the lack of basic `rewriteHtml` or `HtmlRewriter` exports here
export function HtmlJsRewriter (url: string, deferSourceMapRewrite?: DeferSourceMapRewriteFn): stream.Transform {
const rewriter = new RewritingStream()
htmlRules.install(url, rewriter, deferSourceMapRewrite)
return rewriter
}
export function rewriteHtmlJs (url: string, html: string, deferSourceMapRewrite?: DeferSourceMapRewriteFn): string {
let out = ''
const rewriter = HtmlJsRewriter(url, deferSourceMapRewrite)
rewriter.on('data', (chunk) => {
out += chunk
})
rewriter.end(html)
return out
}

View File

@@ -0,0 +1,7 @@
export { HtmlJsRewriter } from './html'
export { rewriteJsAsync, rewriteHtmlJsAsync } from './async-rewriters'
export { DeferredSourceMapCache } from './deferred-source-map-cache'
export { createInitialWorkers, terminateAllWorkers } from './threads'

View File

@@ -0,0 +1,200 @@
import {
Visitor,
namedTypes as n,
builders as b,
} from 'ast-types'
import { ExpressionKind } from 'ast-types/gen/kinds'
// use `globalThis` instead of `window`, `self`... to lower chances of scope conflict
// users can technically override even this, but it would be very rude
// "[globalThis] provides a way for polyfills/shims, build tools, and portable code to have a reliable non-eval means to access the global..."
// @see https://github.com/tc39/proposal-global/blob/master/NAMING.md
const globalIdentifier = b.identifier('globalThis')
/**
* Generate a CallExpression for Cypress.resolveWindowReference
* @param accessedObject object being accessed
* @param prop name of property being accessed
* @param maybeVal if an assignment is being made, this is the RHS of the assignment
*/
function resolveWindowReference (accessedObject: ExpressionKind, prop: string, maybeVal?: ExpressionKind) {
const args = [
globalIdentifier,
accessedObject,
b.stringLiteral(prop),
]
if (maybeVal) {
args.push(maybeVal)
}
return b.callExpression(
b.memberExpression(
b.memberExpression(
b.memberExpression(
globalIdentifier,
b.identifier('top'),
),
b.identifier('Cypress'),
),
b.identifier('resolveWindowReference'),
),
args,
)
}
/**
* Generate a CallExpression for Cypress.resolveLocationReference
*/
function resolveLocationReference () {
return b.callExpression(
b.memberExpression(
b.memberExpression(
b.memberExpression(
globalIdentifier,
b.identifier('top'),
),
b.identifier('Cypress'),
),
b.identifier('resolveLocationReference'),
),
[globalIdentifier],
)
}
/**
* Given an Identifier or a Literal, return a property name that should use `resolveWindowReference`.
* @param node
*/
function getReplaceablePropOfMemberExpression (node: n.MemberExpression) {
const { property } = node
// something.(top|parent)
if (n.Identifier.check(property) && ['parent', 'top', 'location'].includes(property.name)) {
return property.name
}
// something['(top|parent)']
if (n.Literal.check(property) && ['parent', 'top', 'location'].includes(String(property.value))) {
return String(property.value)
}
// NOTE: cases where a variable is used for the prop will not be replaced
// for example, `bar = 'top'; window[bar];` will not be replaced
// this would most likely be too slow
return
}
/**
* An AST Visitor that applies JS transformations required for Cypress.
* @see https://github.com/benjamn/ast-types#ast-traversal for details on how the Visitor is implemented
* @see https://astexplorer.net/#/gist/7f1e645c74df845b0e1f814454e9bbdf/f443b701b53bf17fbbf40e9285cb8b65a4066240
* to explore ASTs generated by recast
*/
export const jsRules: Visitor<{}> = {
// replace member accesses like foo['top'] or bar.parent with resolveWindowReference
visitMemberExpression (path) {
const { node } = path
const prop = getReplaceablePropOfMemberExpression(node)
if (!prop) {
return this.traverse(path)
}
path.replace(resolveWindowReference(path.get('object').node, prop))
return false
},
// replace lone identifiers like `top`, `parent`, with resolveWindowReference
visitIdentifier (path) {
const { node } = path
if (path.parentPath) {
const parentNode = path.parentPath.node
// like `identifer = 'foo'`
const isAssignee = n.AssignmentExpression.check(parentNode) && parentNode.left === node
if (isAssignee && node.name === 'location') {
// `location = 'something'`, rewrite to intercepted href setter since relative urls can break this
path.replace(b.memberExpression(resolveLocationReference(), b.identifier('href')))
return false
}
// some Identifiers do not refer to a scoped variable, depending on how they're used
if (
// like `var top = 'foo'`
(n.VariableDeclarator.check(parentNode) && parentNode.id === node)
|| (isAssignee)
|| (
[
'LabeledStatement', // like `top: foo();`
'ContinueStatement', // like 'continue top'
'BreakStatement', // like 'break top'
'Property', // like `{ top: 'foo' }`
'FunctionDeclaration', // like `function top()`
'RestElement', // like (...top)
'ArrowFunctionExpression', // like `(top, ...parent) => { }`
'ArrowExpression', // MDN Parser docs mention this being used for () => {}
'FunctionExpression', // like `(function top())`,
].includes(parentNode.type)
)
) {
return false
}
}
if (path.scope.declares(node.name)) {
// identifier has been declared in local scope, don't care about replacing
return this.traverse(path)
}
if (node.name === 'location') {
path.replace(resolveLocationReference())
return false
}
if (['parent', 'top'].includes(node.name)) {
path.replace(resolveWindowReference(globalIdentifier, node.name))
return false
}
this.traverse(path)
},
visitAssignmentExpression (path) {
const { node } = path
const finish = () => {
this.traverse(path)
}
if (!n.MemberExpression.check(node.left)) {
return finish()
}
const propBeingSet = getReplaceablePropOfMemberExpression(node.left)
if (!propBeingSet) {
return finish()
}
if (node.operator !== '=') {
// in the case of +=, -=, |=, etc., assume they're not doing something like
// `window.top += 4` since that would be invalid anyways, just continue down the RHS
this.traverse(path.get('right'))
return false
}
const objBeingSetOn = node.left.object
path.replace(resolveWindowReference(objBeingSetOn, propBeingSet, node.right))
return false
},
}

View File

@@ -0,0 +1,87 @@
import * as astTypes from 'ast-types'
import Debug from 'debug'
import { jsRules } from './js-rules'
import * as recast from 'recast'
import * as sourceMaps from './util/source-maps'
const debug = Debug('cypress:rewriter:js')
const defaultPrintOpts: recast.Options = {
// will only affect reprinted quotes
quote: 'single',
}
type OriginalSourceInfo = { url: string, js: string }
function _generateDriverError (url: string, err: Error) {
const args = JSON.stringify({
errMessage: err.message,
errStack: err.stack,
url,
})
return `window.top.Cypress.utils.throwErrByPath('proxy.js_rewriting_failed', { args: ${args} })`
}
// a function that, given source info, returns an id that can be used to build the sourcemap later
export type DeferSourceMapRewriteFn = (sourceInfo: OriginalSourceInfo) => string
export function rewriteJsSourceMap (url: string, js: string, inputSourceMap: any): any {
try {
const { sourceFileName, sourceMapName, sourceRoot } = sourceMaps.getPaths(url)
const ast = recast.parse(js, { sourceFileName })
astTypes.visit(ast, jsRules)
return recast.print(ast, {
inputSourceMap,
sourceMapName,
sourceRoot,
...defaultPrintOpts,
}).map
} catch (err) {
debug('error while parsing JS %o', { err, js: js.slice ? js.slice(0, 500) : js })
return { err }
}
}
export function _rewriteJsUnsafe (url: string, js: string, deferSourceMapRewrite?: DeferSourceMapRewriteFn): string {
const ast = recast.parse(js)
try {
astTypes.visit(ast, jsRules)
} catch (err) {
// if visiting fails, it points to a bug in our rewriting logic, so raise the error to the driver
return _generateDriverError(url, err)
}
const { code } = recast.print(ast, defaultPrintOpts)
if (!deferSourceMapRewrite) {
// no sourcemaps
return sourceMaps.stripMappingUrl(code)
}
// get an ID that can be used to lazy-generate the source map later
const sourceMapId = deferSourceMapRewrite({ url, js })
return sourceMaps.urlFormatter(
// using a relative URL ensures that required cookies + other headers are sent along
// and can be reused if the user's sourcemap requires an HTTP request to be made
`/__cypress/source-maps/${sourceMapId}.map`,
code,
)
}
export function rewriteJs (url: string, js: string, deferSourceMapRewrite?: DeferSourceMapRewriteFn): string {
try {
// rewriting can throw on invalid JS or if there are bugs in the js-rules, so always wrap it
return _rewriteJsUnsafe(url, js, deferSourceMapRewrite)
} catch (err) {
debug('error while parsing JS %o', { err, js: js.slice ? js.slice(0, 500) : js })
return js
}
}

View File

@@ -0,0 +1,249 @@
import _ from 'lodash'
import Bluebird from 'bluebird'
import Debug from 'debug'
import * as path from 'path'
import os from 'os'
import { MessageChannel, Worker } from 'worker_threads'
import { RewriteRequest, RewriteResponse } from './types'
import { DeferSourceMapRewriteFn } from '../js'
const debug = Debug('cypress:rewriter:threads')
const _debugWorker = !debug.enabled ? _.noop : (worker: WorkerInfo) => {
return { ..._.pick(worker, 'isBusy', 'id'), freeWorkers: _.filter(workers, { isBusy: false }).length }
}
const _debugOpts = !debug.enabled ? _.noop : (opts: RewriteOpts) => {
return { ..._.pick(opts, 'isHtml'), sourceLength: opts.source.length }
}
// in production, it is preferable to use the transpiled version of `worker.ts`
// because it does not require importing @packages/ts like development does.
// this has a huge performance impact, bringing the `responsiveMs` for threads
// from ~1s to about ~300ms on my system
const WORKER_FILENAME = process.env.CYPRESS_INTERNAL_ENV === 'production' ? 'worker.js' : 'worker-shim.js'
const WORKER_PATH = path.join(__dirname, WORKER_FILENAME)
// spawn up to `os.cpus().length` threads (default to 4 if this call fails)
const MAX_WORKER_THREADS = _.get(os.cpus(), 'length') || 4
// spawn up to 4 threads at startup
const INITIAL_WORKER_THREADS = Math.min(MAX_WORKER_THREADS, 4)
type DeferredPromise<T> = { p: Promise<T>, resolve: () => {}, reject: () => {} }
type WorkerInfo = {
id: number
thread: Worker
isBusy: boolean
}
type QueuedRewrite = {
deferred: DeferredPromise<string>
opts: RewriteOpts
}
type RewriteOpts = Pick<RewriteRequest, 'url' | 'source' | 'isHtml' | 'sourceMap' | 'inputSourceMap'> & {
deferSourceMapRewrite?: DeferSourceMapRewriteFn
}
const workers: WorkerInfo[] = []
const queued: QueuedRewrite[] = []
let originalProcessExit
// HACK: electron can SIGABRT if exiting while worker_threads are active, so overwrite process.exit
// to ensure that all worker threads are killed *before* exiting.
// @see https://github.com/electron/electron/issues/23366
function wrapProcessExit () {
if (originalProcessExit) {
return
}
originalProcessExit = process.exit
// note - process.exit is normally synchronous, so this could potentially cause strange behavior
// @ts-ignore
process.exit = _.once(async (...args) => {
debug('intercepted process.exit called, closing worker threads')
terminateAllWorkers()
.delay(100)
.finally(() => {
debug('all workers terminated, exiting for real')
originalProcessExit.call(process, ...args)
})
})
}
function createWorker () {
const startedAt = Date.now()
let onlineMs: number
const thread = new Worker(WORKER_PATH)
.on('exit', (exitCode) => {
debug('worker exited %o', { exitCode, worker: _debugWorker(worker) })
_.remove(workers, worker)
})
.on('online', () => {
onlineMs = Date.now() - startedAt
})
.on('message', () => {
debug('received initial ready message from worker %o', {
onlineMs, // time for JS to start executing
responsiveMs: Date.now() - startedAt, // time for worker to be ready for commands
worker: _debugWorker(worker),
})
})
const worker = {
id: thread.threadId,
isBusy: false,
thread,
}
workers.push(worker)
wrapProcessExit()
return worker
}
export function createInitialWorkers () {
// since workers take a little bit of time to start up (due to loading Node and `require`s),
// performance can be gained by letting them start before user tests run
if (workers.length > 0) {
return
}
_.times(INITIAL_WORKER_THREADS, createWorker)
}
export function terminateAllWorkers () {
return Bluebird.all([
workers.map((worker) => worker.thread.terminate()),
])
}
async function sendRewrite (worker: WorkerInfo, opts: RewriteOpts): Promise<string> {
const startedAt = Date.now()
debug('sending rewrite to worker %o', { worker: _debugWorker(worker), opts: _debugOpts(opts) })
if (worker.isBusy) {
throw new Error('worker is already busy')
}
worker.isBusy = true
if (!getFreeWorker() && workers.length < MAX_WORKER_THREADS) {
// create a worker in anticipation of another rewrite coming in
createWorker()
}
const { port1, port2 } = new MessageChannel()
const req: RewriteRequest = {
port: port1,
..._.omit(opts, 'deferSourceMapRewrite'),
}
worker.thread.postMessage(req, [req.port])
const code = await new Promise((resolve, reject) => {
const onExit = (exitCode) => {
reject(new Error(`worker exited with exit code ${exitCode}`))
}
worker.thread.once('exit', onExit)
worker.thread.once('error', reject)
port2.on('message', (res: RewriteResponse) => {
if (res.deferredSourceMap) {
return opts.deferSourceMapRewrite!(res.deferredSourceMap)
}
const totalMs = Date.now() - startedAt
debug('received response from worker %o', {
error: res.error,
totalMs: Date.now() - startedAt,
threadMs: res.threadMs, // time taken to run rewriting in thread
overheadMs: totalMs - res.threadMs, // time not accounted for by `threadMs`
worker: _debugWorker(worker),
opts: _debugOpts(opts),
})
worker.thread.removeListener('exit', onExit)
worker.thread.removeListener('error', reject)
if (res.error) {
return reject(res.error)
}
return resolve(res.output)
})
})
.finally(() => {
port2.close()
worker.isBusy = false
maybeRunNextInQueue()
}) as Promise<string>
return code
}
function maybeRunNextInQueue () {
const next = queued.shift()
if (!next) {
return
}
debug('running next rewrite in queue', { opts: _debugOpts() })
queueRewriting(next.opts)
.then(next.deferred.resolve)
.catch(next.deferred.reject)
}
function getFreeWorker (): WorkerInfo | undefined {
return _.find(workers, { isBusy: false })
}
export function queueRewriting (opts: RewriteOpts): Promise<string> {
// if a worker is free now, use it
const freeWorker = getFreeWorker()
if (freeWorker) {
debug('sending source to free worker')
return sendRewrite(freeWorker, opts)
}
// if there's room, create a new thread
if (workers.length < MAX_WORKER_THREADS) {
debug('creating new worker')
const newWorker = createWorker()
return sendRewrite(newWorker, opts)
}
// otherwise enqueue
debug('enqueuing source for rewriting %o', { opts: _debugOpts(opts), prevQueueLength: queued.length })
const deferred = getDeferredPromise()
queued.push({ opts, deferred })
return deferred.p
}
function getDeferredPromise (): DeferredPromise<any> {
let resolve; let reject
const p = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { p, resolve, reject }
}

View File

@@ -0,0 +1,33 @@
import { MessagePort } from 'worker_threads'
export type RewriteRequest = {
/**
* used for resolving references in sourcemaps
*/
url: string
port: MessagePort
isHtml?: boolean
source: string
/**
* If true, return the sourcemap and not the generated source.
*/
sourceMap?: true
inputSourceMap?: string
}
export type RewriteResponse = {
threadMs: number
/**
* If set, this message is a notification that a source map may need to be generated for a given
* JS snippet, and should be set aside for later.
*/
deferredSourceMap?: {
url: string
js: string
}
/**
* If set, this is the final output of the rewrite in progress.
*/
output?: any
error?: Error
}

View File

@@ -0,0 +1,6 @@
if (process.env.CYPRESS_INTERNAL_ENV === 'production') {
throw new Error(`${__filename} should only run outside of prod`)
}
require('@packages/ts/register')
require('./worker.ts')

View File

@@ -0,0 +1,65 @@
// this is designed to run as its own thread, managed by `threads.ts`
// WARNING: take care to not over-import modules here - the upfront
// mem/CPU cost is paid up to threads.MAX_WORKER_THREADS times
import { parentPort, isMainThread, threadId } from 'worker_threads'
if (isMainThread) {
throw new Error(`${__filename} should only be run as a worker thread`)
}
import { rewriteJs, rewriteJsSourceMap } from '../js'
import { rewriteHtmlJs } from '../html'
import { RewriteRequest, RewriteResponse } from './types'
parentPort!.postMessage(true)
let _idCounter = 0
parentPort!.on('message', (req: RewriteRequest) => {
const startedAt = Date.now()
function _deferSourceMapRewrite (deferredSourceMap) {
const uniqueId = [threadId, _idCounter++].join('.')
_reply({
threadMs: _getThreadMs(),
deferredSourceMap: {
uniqueId,
...deferredSourceMap,
},
})
return uniqueId
}
function _reply (res: RewriteResponse) {
req.port.postMessage(res)
}
function _getThreadMs () {
return Date.now() - startedAt
}
function _getOutput () {
if (req.isHtml) {
return rewriteHtmlJs(req.url, req.source, _deferSourceMapRewrite)
}
if (req.sourceMap) {
return rewriteJsSourceMap(req.url, req.source, req.inputSourceMap)
}
return rewriteJs(req.url, req.source, _deferSourceMapRewrite)
}
try {
const output = _getOutput()
_reply({ output, threadMs: _getThreadMs() })
} catch (error) {
_reply({ error, threadMs: _getThreadMs() })
}
return req.port.close()
})

View File

@@ -0,0 +1,61 @@
import path from 'path'
import url from 'url'
const sourceMapRe = /\n\/\/[ \t]*(?:#|@) sourceMappingURL=([^\s]+)\s*$/
const dataUrlRe = /^data:application\/json;(?:charset=utf-8;)base64,([^\s]+)\s*$/
export const getMappingUrl = (js: string) => {
const matches = sourceMapRe.exec(js)
if (matches) {
return matches[1]
}
return undefined
}
export const stripMappingUrl = (js: string) => {
return js.replace(sourceMapRe, '')
}
export const tryDecodeInlineUrl = (url: string) => {
const matches = dataUrlRe.exec(url)
if (matches) {
try {
const base64 = matches[1]
// theoretically we could capture the charset properly and use it in the toString call here
// but it is unlikely that non-utf-8 charsets will be encountered in the wild, and handling all
// possible charsets is complex
return JSON.parse(Buffer.from(base64, 'base64').toString())
} catch {
return
}
}
}
export const getPaths = (urlStr: string) => {
try {
const parsed = url.parse(urlStr, false)
// if the sourceFileName is the same as the real filename, Chromium appends a weird "? [sm]" suffix to the filename
// avoid this by appending some text to the filename
// @see https://cs.chromium.org/chromium/src/third_party/devtools-frontend/src/front_end/sdk/SourceMap.js?l=445-447&rcl=a0c450d5b58f71b67134306b2e1c29a75326d3db
const sourceFileName = `${path.basename(parsed.path || '')} (original)`
parsed.pathname = path.dirname(parsed.pathname || '')
delete parsed.search
return { sourceRoot: parsed.format(), sourceFileName, sourceMapName: `${sourceFileName}.map` }
} catch {
return { sourceRoot: undefined, sourceFileName: 'source.js', sourceMapName: 'source.js.map' }
}
}
export const urlFormatter = (url: string, js: string): string => {
return [
stripMappingUrl(js),
`//# sourceMappingURL=${url}`,
].join('\n')
}

View File

@@ -0,0 +1,42 @@
{
"name": "@packages/rewriter",
"version": "0.0.0",
"private": true,
"main": "index.js",
"scripts": {
"build-prod": "tsc --project .",
"build-test": "yarn build-prod --noEmit",
"clean-deps": "rm -rf node_modules",
"test": "mocha --reporter mocha-multi-reporters --reporter-options configFile=../../mocha-reporter-config.json"
},
"dependencies": {
"ast-types": "0.13.3",
"bluebird": "3.7.2",
"chai-as-promised": "7.1.1",
"debug": "4.1.1",
"lodash": "4.17.15",
"parse5-html-rewriting-stream": "5.1.1",
"recast": "0.18.8"
},
"devDependencies": {
"@cypress/request-promise": "4.2.6",
"@types/parse5-html-rewriting-stream": "5.1.1",
"fs-extra": "9.0.0",
"nock": "12.0.3",
"sinon": "9.0.2",
"sinon-chai": "3.5.0",
"snap-shot-it": "7.9.3"
},
"files": [
"lib"
],
"types": "./lib/index.ts",
"workspaces": {
"nohoist": [
"parse5-html-rewriting-stream",
"parse5-html-rewriting-stream/**",
"@types/parse5-html-rewriting-stream",
"@types/parse5-html-rewriting-stream/**"
]
}
}

View File

@@ -0,0 +1,5 @@
{
"extends": [
"plugin:@cypress/dev/tests"
]
}

View File

@@ -0,0 +1,15 @@
import fs from 'fs'
import * as sourceMaps from '../lib/util/source-maps'
export const testSourceMap = fs.readFileSync(`${__dirname}/fixtures/test.js.map`).toString()
export const testSourceWithExternalSourceMap = fs.readFileSync(`${__dirname}/fixtures/test.js`).toString()
export const testSourceWithNoSourceMap = sourceMaps.stripMappingUrl(testSourceWithExternalSourceMap)
export const testSourceWithInlineSourceMap = sourceMaps.urlFormatter(
`data:application/json;base64,${Buffer.from(testSourceMap).toString('base64')}`,
testSourceWithNoSourceMap,
)
export const testHtml = fs.readFileSync(`${__dirname}/fixtures/test.html`).toString()

View File

@@ -0,0 +1,8 @@
# 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 ->
window
foo = "#{window.top.foo}"
, 1000

View File

@@ -0,0 +1,62 @@
<html>
<body>
top1
settop
settopbox
parent1
grandparent
grandparents
topFoo
topFoo.window
topFoo.window != topFoo
parentFoo
parentFoo.window
parentFoo.window != parentFoo
<div style="left: 1500px; top: 0px;"></div>
<div style="left: 1500px; top : 0px;"></div>
<div style="left: 1500px; top : 0px;"></div>
parent()
foo.parent()
top()
foo.top()
foo("parent")
foo("top")
const parent = () => { bar: 'bar' }
parent.bar
<script type="text/javascript">
if (top != self) run()
if (top!=self) run()
if (self !== top) run()
if (self!==top) run()
if (self === top) return
if (top.location!=self.location&&(top.location.href=self.location.href)) run()
if (top.location != self.location) run()
if (top.location != location) run()
if (self.location != top.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 != 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()
if (parent !== null && parent.tag !== 'HostComponent' && parent.tag !== 'HostRoot') { }
if (null !== parent && parent.tag !== 'HostComponent' && parent.tag !== 'HostRoot') { }
if (top===self) return
if (top==self) return
</script>
</body>
</html>

14
packages/rewriter/test/fixtures/test.js vendored Normal file
View File

@@ -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

View File

@@ -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"
]
}

View File

@@ -0,0 +1,4 @@
test/unit/*
-r @packages/ts/register
--timeout 10000
--recursive

View File

@@ -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',
}))
})
})
})
})

View File

@@ -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('<script type="text/javascript" integrity="foo" src="bar">'))
snapshot(rewriteNoSourceMap('<script type="text/javascript" integrity="foo" src="bar"/>'))
snapshot(rewriteNoSourceMap('<script type="text/javascript" integrity="foo" src="bar">foo</script>'))
// should preserve namespaced attrs and still rewrite if no `type`
snapshot(rewriteNoSourceMap('<script foo:bar="baz" integrity="foo" src="bar">'))
})
it('rewrites a real-ish document with sourcemaps for inline js', () => {
const actual: any = {}
actual.html = rewriteHtmlJs(URL, testHtml, (sourceInfo) => {
actual.sourceInfo = sourceInfo
return 'foo'
})
snapshot(actual)
})
context('with inline scripts', function () {
it('rewrites inline JS with no type', function () {
snapshot(rewriteNoSourceMap('<script>window.top</script>'))
})
it('rewrites inline JS with type', function () {
snapshot(rewriteNoSourceMap('<script type="text/javascript">window.top</script>'))
})
it('does not rewrite non-JS inline', function () {
snapshot(rewriteNoSourceMap('<script type="x-foo/bar">window.top</script>'))
})
it('ignores invalid inline JS', function () {
const str = '<script>(((((((((((</script>'
expect(rewriteNoSourceMap(str)).to.eq(str)
})
})
})
})

View File

@@ -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 ''
})
})
})
})
})

View File

@@ -0,0 +1,11 @@
{
"extends": "./../ts/tsconfig.json",
"include": [
"*.ts",
"lib/*.ts",
"lib/**/*.ts"
],
"files": [
"./../ts/index.d.ts"
]
}

View File

@@ -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 -
`

View File

@@ -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 = {}) ->

View File

@@ -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',
}

View File

@@ -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)

View File

@@ -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])

View File

@@ -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({

View File

@@ -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

View File

@@ -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))

View File

@@ -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 })
})
})
})
})

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<body>
testing security clickjacking and framebusting
<script type="text/javascript" src="/static/obstructive_code.js"></script>
<script type="text/javascript">
(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 = 'html ran'
document.body.appendChild(div)
})()
</script>
</body>
</html>

View File

@@ -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)
})()

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<button>click me</button>
<script type="text/javascript">
document.querySelector('button').onclick = () => {
location.href = 'index.html'
}
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="/static/foo/bar/">
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
debugger
location.href = 'index.html';
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
location.assign('index.html');
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
location.href = 'index.html';
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
location.pathname = 'index.html';
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
location.replace('index.html');
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
if (!location.search) {
location.search = '?foo';
}
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
location = 'index.html';
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
window.location = 'index.html';
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<script type="text/javascript">
setTimeout(() => {
window.location.href = 'index.html';
}, 1)
</script>
</body>
</html>

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
</head>
<body>
<button>click me</button>
<script type="text/javascript">
const x = new XMLHttpRequest()
x.open('GET', '/cypress.json')
x.onload = () => {
location.href = 'index.html';
console.log(location);
}
x.send()
</script>
</body>
</html>

View File

@@ -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" },

155
yarn.lock
View File

@@ -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"