Files
cypress/packages/server/lib/controllers/proxy.coffee
Chris Breiding 6ba8d7cc93 Electron v5.0.10 (#4720)
* fix specs

* use debugger protocol for cookie handling in electron

* use latest gulp

* use rimraf instead of gulp-clean

* use electron 3.1.8 and node 10.2.1

* use gulp 4 in packages/static

* fix sendCommandAsync, log Schema.getDomains on CDP connect

* autofill e2e test name [skip ci]

* electron@5.0.7, see what new failures exist

* --no-sandbox for launching Electron

* update cookies logic for electron

* node 12

* update snapshot for new node

* update error message for new node

* stub sendCommandAsync

* only connect to socket if path has been replaced, fixes #4776

* update node-sass to support node 12

* skip wacky socket tests for now

* snapshot

* fix run_plugins_spec snapshot, don't include stack trace

* use --no-sandbox on linux to run as root

* allow sendCommandAsync to resolve

* use euid for root check

* log domains even if undefined

* don't worry about ending 1xx responses immediately anymore

* use --max-http-header-size, change max size from 8kb to 1mb, fix #76

* do not send 502 on failed websocket, just send back ECONNRESET

* update websocket spec port to not collide with other test

* update outdated expect

* Revert "only connect to socket if path has been replaced, fixes #4776"

This reverts commit f179eda5ca.

* update gulp in root

* update https-proxy unit tests

* update network spec to properly close server

* update reporter spec

* update https-proxy-agent to fix node 10.10.0 change

discussion: https://github.com/nodejs/node/issues/24474\#issuecomment-511963799

* only pass --max-http-header-size on node >=12

* use own server-destroy implementation that supports secureConnect events

* oops

* update socket_spec

* electron 6.0.0

* console.table introduced in node 10

* change browserify entry to init.js

* handle edge case when no response body

* console.table added in node 10

* do not exit app when all BrowserWindows are closed

* update e2e snapshots

* value may not be null

* update plugins spec

* correct cookie expiry, use browser.getversion for CDP version check

* fix snapshotting for require stacks

* reorder cookies in spec

* warn when depreated electron callback apis are used

* only report 1 plugin error per process

* cleanup

* node 12.4.0, cypress/browsers:node12.4.0-chrome76 docker image

* update shell.openExternal to promisified

* update dialog.showOpenDialog to promisified

* update webContents.session.setProxy to promisified

* updating native dependencies since we don't need ancient node ABI support anymore

* WIP: switch cookies to simpler, jar-less approach

* WIP: switch cookies to simpler, jar-less approach

* making tests pass

* improve cookie filtering logic

* Remove unneeded Promise.try

* filter what makes it to the extension

* properly re-set superdomain cookies on cross-origin cy.visit

* allow comma-separated list of e2e tests

* sort cookies in order of expiration date, ascending

* updating tests, cleanup

* update tests

* version electron as a devDependency, electron@6.0.1

* cleanup, remove old automation

* cleanup, remove old automation

* bump chokidar to fix win10 + node12 issue

was seeing this on windows:
https://github.com/nuxt/nuxt.js/issues/6035

fixed with version bump

* enable now-supported quit role, re-enable old tests

* don't need that arg there

* remove last deprecated callback electron invocations

* Delete cypress.json

* responding to PR feedback

* cleanup

* invoke

* use 'quit' role

* Use new appMenu role for Cypress menu on mac

* electron@6.0.2

* electron@6.0.3

* remove domain: cookie.domain and see what happens

* remove setErrorHandler

* Revert "remove domain: cookie.domain and see what happens"

This reverts commit 49e916896d.

* add unit tests for cookies

* ci

* fix project-content css

* electron@6.0.4

* fix specs_list test

* electron@6.0.7

* some cleanup

* electron@6.0.9

* Update 8_reporters_spec.coffee.js

* electron@5.0.10 - Chromium 73, Node 12

* cli: fix the STDIN pipe on Windows (#5045)

* cli: pipe stdin

* uggh, here is the actual change

* update cli unit tests

* add unit test

* more permissive check for json to include application/vnd.api+j… (#5166)

* more permissive check for json to include

* add json test for content-type application/vnd.api+json

* cruder solution passes e2e tests locally, so let's go with that

* Remove 'charset' from content-type before checking if JSON

* fix eslint for fixture specs (#5176)

* update eslint to lint files within 'fixtures' in support files

- ignore some edge cases like jquery, jsx and obvious js files we wrote
with broken code

* Fixes from eslint to 'fixtures' files

* Catch env variable with reserved name CYPRESS_ENV 1621 (#1626)

* server: check CYPRESS_ENV variable when merging configs

* catch invalid CYPRESS_ENV value in CLI, close #1621

* linting

* sanitize platform in test snapshot

* linting

* update error message text

* add missing comma

* fix finally merge in JS code

* pass CLI linter

* fix log reference, should be debug

* use correct sinon reference

* update message, show first part in red

* update error message text

* Addresses #2953 (#5174)

* Addresses #2953

* Added proper test for new error message

* Didn't realize it ran this test as well, whoops

* Implementing changes as suggested by @jennifer-shehane

* Fixing tests and error output. Moved the checks to the start of the get command to ensure we always catch improper options

* Removing issue test since the querying spec covers it

* Using coffescript isArray check

* depromisify things that were promisified b/t electron 5 <=> 6

Revert "update shell.openExternal to promisified"

This reverts commit 8b6460d015.

Revert "update dialog.showOpenDialog to promisified"

This reverts commit 5f178b075b.

Revert "update webContents.session.setProxy to promisified"

This reverts commit 727df3a4e5.

* node12.4.0-chrome76 => node12.0.0-chrome75

* fix tests for electron downgrade

* node12.0.0-chrome75 => node12.0.0-chrome73


Co-authored-by: Zach Bloomquist <github@chary.us>
Co-authored-by: Brian Mann <brian.mann86@gmail.com>
2019-09-24 14:35:24 -04:00

412 lines
13 KiB
CoffeeScript

_ = require("lodash")
zlib = require("zlib")
charset = require("charset")
concat = require("concat-stream")
iconv = require("iconv-lite")
Promise = require("bluebird")
accept = require("http-accept")
debug = require("debug")("cypress:server:proxy")
cwd = require("../cwd")
cors = require("../util/cors")
buffers = require("../util/buffers")
rewriter = require("../util/rewriter")
blacklist = require("../util/blacklist")
conditional = require("../util/conditional_stream")
{ passthruStream } = require("../util/passthru_stream")
REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308]
NO_BODY_STATUS_CODES = [204, 304]
zlib = Promise.promisifyAll(zlib)
zlibOptions = {
flush: zlib.Z_SYNC_FLUSH
finishFlush: zlib.Z_SYNC_FLUSH
}
## https://github.com/cypress-io/cypress/issues/1543
getNodeCharsetFromResponse = (headers, body) ->
httpCharset = (charset(headers, body, 1024) || '').toLowerCase()
debug("inferred charset from response %o", { httpCharset })
if iconv.encodingExists(httpCharset)
return httpCharset
## browsers default to latin1
return "latin1"
isGzipError = (err) ->
Object.prototype.hasOwnProperty.call(zlib.constants, err.code)
## https://github.com/cypress-io/cypress/issues/4298
## https://tools.ietf.org/html/rfc7230#section-3.3.3
## HEAD, 1xx, 204, and 304 responses should never contain anything after headers
responseMustHaveEmptyBody = (method, statusCode) ->
_.some([
_.includes(NO_BODY_STATUS_CODES, statusCode),
_.invoke(method, 'toLowerCase') == 'head',
])
setCookie = (res, key, val, domainName) ->
## cannot use res.clearCookie because domain
## is not sent correctly
options = {
domain: domainName
}
if not val
val = ""
## force expires to be the epoch
options.expires = new Date(0)
res.cookie(key, val, options)
reqNeedsBasicAuthHeaders = (req, remoteState) ->
{ auth, origin } = remoteState
auth &&
not req.headers["authorization"] &&
cors.urlMatchesOriginProtectionSpace(req.proxiedUrl, origin)
module.exports = {
handle: (req, res, config, getRemoteState, request, nodeProxy) ->
remoteState = getRemoteState()
debug("handling proxied request %o", {
url: req.url
proxiedUrl: req.proxiedUrl
headers: req.headers
remoteState
})
## if we have an unload header it means
## our parent app has been navigated away
## directly and we need to automatically redirect
## to the clientRoute
if req.cookies["__cypress.unload"]
return res.redirect config.clientRoute
## when you access cypress from a browser which has not
## had its proxy setup then req.url will match req.proxiedUrl
## and we'll know to instantly redirect them to the correct
## client route
if req.url is req.proxiedUrl and not remoteState.visiting
## if we dont have a remoteState.origin that means we're initially
## requesting the cypress app and we need to redirect to the
## root path that serves the app
return res.redirect(config.clientRoute)
## if we have black listed hosts
if blh = config.blacklistHosts
## and url matches any of our blacklisted hosts
if matched = blacklist.matches(req.proxiedUrl, blh)
## then bail and return with 503
## and set a custom header
res.set("x-cypress-matched-blacklisted-host", matched)
debug("blacklisting request %o", {
url: req.proxiedUrl
matched: matched
})
return res.status(503).end()
thr = passthruStream()
@getHttpContent(thr, req, res, remoteState, config, request)
.pipe(res)
getHttpContent: (thr, req, res, remoteState, config, request) ->
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"
isInitial = req.cookies["__cypress.initial"] is "true"
wantsInjection = null
wantsSecurityRemoved = null
resContentTypeIs = (respHeaders, str) ->
contentType = respHeaders["content-type"]
## make sure the response includes string type
contentType and contentType.includes(str)
resContentTypeIsJavaScript = (respHeaders) ->
_.some [
'application/javascript',
'application/x-javascript',
'text/javascript'
].map(_.partial(resContentTypeIs, respHeaders))
reqAcceptsHtml = ->
## don't inject if this is an XHR from jquery
return if req.headers["x-requested-with"]
types = accept.parser(req.headers.accept) ? []
find = (type) ->
type in types
## bail if we didn't find both text/html and application/xhtml+xml
## https://github.com/cypress-io/cypress/issues/288
find("text/html") and find("application/xhtml+xml")
resMatchesOriginPolicy = (respHeaders) ->
switch remoteState.strategy
when "http"
cors.urlMatchesOriginPolicyProps(req.proxiedUrl, remoteState.props)
when "file"
req.proxiedUrl.startsWith(remoteState.origin)
setCookies = (value) ->
## dont modify any cookies if we're trying to clear
## the initial cookie and we're not injecting anything
return if (not value) and (not wantsInjection)
## dont set the cookies if we're not on the initial request
return if not isInitial
setCookie(res, "__cypress.initial", value, remoteState.domainName)
setBody = (str, statusCode, headers) ->
## set the status to whatever the incomingRes statusCode is
res.status(statusCode)
## turn off __cypress.initial by setting false here
setCookies(false, wantsInjection)
encoding = headers["content-encoding"]
isGzipped = encoding and encoding.includes("gzip")
debug("received response for %o", {
url: req.proxiedUrl
headers,
statusCode,
isGzipped
wantsInjection,
wantsSecurityRemoved,
})
if responseMustHaveEmptyBody(req.method, statusCode)
return res.end()
## if there is nothing to inject then just
## bypass the stream buffer and pipe this back
if wantsInjection
rewrite = (body) ->
## transparently decode their body to a node string and then re-encode
nodeCharset = getNodeCharsetFromResponse(headers, body)
decodedBody = iconv.decode(body, nodeCharset)
rewrittenBody = rewriter.html(decodedBody, remoteState.domainName, wantsInjection, wantsSecurityRemoved)
iconv.encode(rewrittenBody, nodeCharset)
## TODO: we can probably move this to the new
## replacestream rewriter instead of using
## a buffer
injection = concat (body) ->
## concat-stream yields an empty array if nothing is written
if _.isEqual(body, [])
body = Buffer.from('')
## if we're gzipped that means we need to unzip
## this content first, inject, and the rezip
if isGzipped
zlib.gunzipAsync(body, zlibOptions)
.then(rewrite)
.then(zlib.gzipAsync)
.then(thr.end)
## if we have an error here there's nothing
## to do but log it out and end the socket
## because we cannot inject into content
## that failed rewriting gzip
## which is the same thing we do below
## on regular proxied network requests
.catch(endWithNetworkErr)
else
thr.end rewrite(body)
str.pipe(injection)
else
## only rewrite if we should
if wantsSecurityRemoved
gunzip = zlib.createGunzip(zlibOptions)
gunzip.setEncoding("utf8")
onError = (err) ->
gzipError = isGzipError(err)
debug("failed to proxy response %o", {
url: req.proxiedUrl
headers
statusCode
isGzipped
gzipError
wantsInjection
wantsSecurityRemoved
err
})
endWithNetworkErr(err)
## only unzip when it is already gzipped
return str
.pipe(conditional(isGzipped, gunzip))
.on("error", onError)
.pipe(rewriter.security())
.on("error", onError)
.pipe(conditional(isGzipped, zlib.createGzip()))
.on("error", onError)
.pipe(thr)
.on("error", onError)
return str.pipe(thr)
endWithNetworkErr = (err) ->
debug('request failed in proxy layer %o', {
res: _.pick(res, 'headersSent', 'statusCode', 'headers')
req: _.pick(req, 'url', 'proxiedUrl', 'headers', 'method')
err
})
req.socket.destroy()
onResponse = (str, incomingRes) =>
{headers, statusCode} = incomingRes
originalSetHeader = res.setHeader
## express does all kinds of silly/nasty stuff to the content-type...
## but we don't want to change it at all!
res.setHeader = (k, v) ->
if k == 'content-type'
v = incomingRes.headers['content-type']
originalSetHeader.call(res, k, v)
wantsInjection ?= do ->
return false if not resContentTypeIs(headers, "text/html")
return false if not resMatchesOriginPolicy(headers)
return "full" if isInitial
return false if not reqAcceptsHtml()
return "partial"
wantsSecurityRemoved = do ->
## we want to remove security IF we're doing a full injection
## on the response or its a request for any javascript script tag
config.modifyObstructiveCode and (
(wantsInjection is "full") or
resContentTypeIsJavaScript(headers)
)
@setResHeaders(req, res, incomingRes, wantsInjection)
## always proxy the cookies coming from the incomingRes
if cookies = headers["set-cookie"]
## normalize into array
for c in [].concat(cookies)
try
res.append("Set-Cookie", c)
catch err
## noop
if REDIRECT_STATUS_CODES.includes(statusCode)
newUrl = headers.location
## set cookies to initial=true
setCookies(true)
debug("redirecting to new url %o", { status: statusCode, url: newUrl })
## finally redirect our user agent back to our domain
## by making this an absolute-path-relative redirect
return res.redirect(statusCode, newUrl)
if headers["x-cypress-file-server-error"]
wantsInjection or= "partial"
setBody(str, statusCode, headers)
if obj = buffers.take(req.proxiedUrl)
wantsInjection = "full"
onResponse(obj.stream, obj.response)
else
opts = {
timeout: null
strictSSL: false
followRedirect: false
retryIntervals: [0, 100, 200, 200]
}
## strip unsupported accept-encoding headers
encodings = accept.parser(req.headers["accept-encoding"]) ? []
if "gzip" in encodings
## we only want to support gzip right now
req.headers["accept-encoding"] = "gzip"
else
## else just delete them since we cannot
## properly decode them
delete req.headers["accept-encoding"]
if remoteState.strategy is "file" and req.proxiedUrl.startsWith(remoteState.origin)
opts.url = req.proxiedUrl.replace(remoteState.origin, remoteState.fileServer)
else
opts.url = req.proxiedUrl
## if we have auth headers and this request matches our origin
## protection space and the user has not supplied auth headers
if reqNeedsBasicAuthHeaders(req, remoteState)
{ auth } = remoteState
base64 = Buffer
.from(auth.username + ":" + auth.password)
.toString("base64")
req.headers["authorization"] = "Basic #{base64}"
rq = request.create(opts)
rq.on("error", endWithNetworkErr)
rq.on "response", (incomingRes) ->
onResponse(rq, incomingRes)
## if our original request has been
## aborted, then ensure we forward
## this onto the proxied request
## https://github.com/cypress-io/cypress/issues/2612
## this can happen on permanent connections
## like SSE, but also on any regular ol'
## http request
req.on "aborted", ->
rq.abort()
## proxy the request body, content-type, headers
## to the new rq
req.pipe(rq)
return thr
setResHeaders: (req, res, incomingRes, wantsInjection) ->
return if res.headersSent
## omit problematic headers
headers = _.omit incomingRes.headers, "set-cookie", "x-frame-options", "content-length", "content-security-policy", "connection"
## do not cache when we inject content into responses
## later on we should switch to an etag system so we dont
## have to download the remote http responses if the etag
## hasnt changed
if wantsInjection
headers["cache-control"] = "no-cache, no-store, must-revalidate"
## proxy the headers
res.set(headers)
}