Skip to content

Commit

Permalink
fix Can't login with Testcafe (close #2166) (#2307)
Browse files Browse the repository at this point in the history
  • Loading branch information
LavrovArtem committed Apr 28, 2020
1 parent aaf9d05 commit c6ae1e1
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 90 deletions.
2 changes: 1 addition & 1 deletion src/client/sandbox/xhr.ts
Expand Up @@ -121,7 +121,7 @@ export default class XhrSandbox extends SandboxBase {
// Access-Control_Allow_Origin flag and skip "preflight" requests.
// eslint-disable-next-line no-restricted-properties
nativeMethods.xhrSetRequestHeader.call(this, INTERNAL_HEADERS.origin, getOriginHeader());
nativeMethods.xhrSetRequestHeader.call(this, INTERNAL_HEADERS.credentials, this.withCredentials.toString());
nativeMethods.xhrSetRequestHeader.call(this, INTERNAL_HEADERS.credentials, this.withCredentials ? 'include' : 'same-origin');

nativeMethods.xhrSend.apply(this, arguments);

Expand Down
30 changes: 16 additions & 14 deletions src/request-pipeline/context.ts
Expand Up @@ -21,6 +21,8 @@ import * as headerTransforms from './header-transforms';
import { RequestInfo } from '../session/events/info';
import SERVICE_ROUTES from '../proxy/service-routes';
import BUILTIN_HEADERS from './builtin-header-names';
import SAME_ORIGIN_CHECK_FAILED_STATUS_CODE from './xhr/same-origin-check-failed-status-code';
import { processSetCookieHeader } from './header-transforms/transforms';

interface DestInfo {
url: string;
Expand Down Expand Up @@ -75,8 +77,7 @@ export default class RequestPipelineContext {
destRes: http.IncomingMessage | FileStream | IncomingMessageMock = null;
isDestResReadableEnded = false;
destResBody: Buffer = null;
isXhr: boolean = false;
isFetch: boolean = false;
isAjax: boolean = false;
isPage: boolean = false;
isHTMLPage: boolean = false;
isHtmlImport: boolean = false;
Expand Down Expand Up @@ -105,9 +106,8 @@ export default class RequestPipelineContext {

const acceptHeader = req.headers[BUILTIN_HEADERS.accept] as string;

this.isXhr = RequestPipelineContext._isXhr(req.headers[INTERNAL_HEADERS.credentials] as string);
this.isFetch = !!req.headers[INTERNAL_HEADERS.credentials] && !this.isXhr;
this.isPage = !this.isXhr && !this.isFetch && !!acceptHeader && contentTypeUtils.isPage(acceptHeader);
this.isAjax = typeof req.headers[INTERNAL_HEADERS.credentials] === 'string';
this.isPage = !this.isAjax && !!acceptHeader && contentTypeUtils.isPage(acceptHeader);

this.parsedClientSyncCookie = req.headers.cookie && parseClientSyncCookieStr(req.headers.cookie);
}
Expand Down Expand Up @@ -156,7 +156,7 @@ export default class RequestPipelineContext {

this.isWebSocket = this.dest.isWebSocket;
this.isHtmlImport = this.dest.isHtmlImport;
this.isPage = !this.isXhr && !this.isFetch && !this.isWebSocket && acceptHeader &&
this.isPage = !this.isAjax && !this.isWebSocket && acceptHeader &&
contentTypeUtils.isPage(acceptHeader) || this.isHtmlImport;
this.isIframe = this.dest.isIframe;
this.isSpecialPage = urlUtils.isSpecialPage(this.dest.url);
Expand Down Expand Up @@ -232,17 +232,13 @@ export default class RequestPipelineContext {
return (str[0] === '/' ? '' : '/') + str;
}

private static _isXhr(credentialsHeader: string | undefined) {
return typeof credentialsHeader === 'string' && (credentialsHeader === 'true' || credentialsHeader === 'false');
}

buildContentInfo () {
const contentType = this.destRes.headers[BUILTIN_HEADERS.contentType] as string || '';
const accept = this.req.headers[BUILTIN_HEADERS.accept] as string || '';
const encoding = this.destRes.headers[BUILTIN_HEADERS.contentEncoding] as string;

if (this.isPage && contentType)
this.isPage = !this.isXhr && !this.isFetch && contentTypeUtils.isPage(contentType);
this.isPage = !this.isAjax && contentTypeUtils.isPage(contentType);

const isCSS = contentTypeUtils.isCSSResource(contentType, accept);
const isManifest = contentTypeUtils.isManifest(contentType);
Expand All @@ -256,7 +252,7 @@ export default class RequestPipelineContext {
const isNotModified = this.req.method === 'GET' && this.destRes.statusCode === 304 &&
!!(this.req.headers[BUILTIN_HEADERS.ifModifiedSince] ||
this.req.headers[BUILTIN_HEADERS.ifNoneMatch]);
const requireProcessing = !this.isXhr && !this.isFetch && !isFormWithEmptyResponse && !isRedirect &&
const requireProcessing = !this.isAjax && !isFormWithEmptyResponse && !isRedirect &&
!isNotModified && (this.isPage || this.isIframe || requireAssetsProcessing);
const isFileDownload = this._isFileDownload() && !this.dest.isScript;
const isIframeWithImageSrc = this.isIframe && !this.isPage && /^\s*image\//.test(contentType);
Expand Down Expand Up @@ -329,6 +325,13 @@ export default class RequestPipelineContext {
}

closeWithError (statusCode: number, resBody: string | Buffer = ''): void {
if (statusCode === SAME_ORIGIN_CHECK_FAILED_STATUS_CODE) {
const processedCookie = processSetCookieHeader(this.destRes.headers[BUILTIN_HEADERS.setCookie], this);

if (processedCookie && processedCookie.length)
(this.res as http.ServerResponse).setHeader(BUILTIN_HEADERS.setCookie, processedCookie);
}

if ('setHeader' in this.res && !this.res.headersSent) {
this.res.statusCode = statusCode;
this.res.setHeader(BUILTIN_HEADERS.contentType, 'text/html');
Expand Down Expand Up @@ -359,8 +362,7 @@ export default class RequestPipelineContext {
}

isPassSameOriginPolicy (): boolean {
const isAjaxRequest = this.isXhr || this.isFetch;
const shouldPerformCORSCheck = isAjaxRequest && !this.contentInfo.isNotModified;
const shouldPerformCORSCheck = this.isAjax && !this.contentInfo.isNotModified;

return !shouldPerformCORSCheck || checkSameOriginPolicy(this);
}
Expand Down
4 changes: 2 additions & 2 deletions src/request-pipeline/destination-request/index.ts
Expand Up @@ -41,7 +41,7 @@ export default class DestinationRequest extends EventEmitter implements Destinat
private readonly protocolInterface: any;

static TIMEOUT = 25 * 1000;
static XHR_TIMEOUT = 2 * 60 * 1000;
static AJAX_TIMEOUT = 2 * 60 * 1000;

constructor (opts: RequestOptions) {
super();
Expand All @@ -64,7 +64,7 @@ export default class DestinationRequest extends EventEmitter implements Destinat

_send (waitForData?: boolean): void {
connectionResetGuard(() => {
const timeout = this.opts.isXhr ? DestinationRequest.XHR_TIMEOUT : DestinationRequest.TIMEOUT;
const timeout = this.opts.isAjax ? DestinationRequest.AJAX_TIMEOUT : DestinationRequest.TIMEOUT;
const storedHeaders = this.opts.headers;

// NOTE: The headers are converted to raw headers because some sites ignore headers in a lower case. (GH-1380)
Expand Down
47 changes: 11 additions & 36 deletions src/request-pipeline/header-transforms/transforms.ts
Expand Up @@ -3,6 +3,7 @@ import BUILTIN_HEADERS from '../builtin-header-names';
import INTERNAL_HEADERS from '../internal-header-names';
import * as urlUtils from '../../utils/url';
import { parse as parseUrl, resolve as resolveUrl } from 'url';
import { shouldOmitCredentials } from '../xhr/same-origin-policy';
import {
formatSyncCookie,
generateDeleteSyncCookieStr,
Expand All @@ -17,36 +18,8 @@ function skipIfStateSnapshotIsApplied (src: string, ctx: RequestPipelineContext)
return ctx.restoringStorages ? void 0 : src;
}

function isCrossDomainXhrWithoutCredentials (ctx: RequestPipelineContext): boolean {
return ctx.req.headers[INTERNAL_HEADERS.credentials] === 'false' && ctx.dest.reqOrigin !== ctx.dest.domain;
}

function transformCookieForFetch (src: string, ctx: RequestPipelineContext): string | undefined {
const requestCredentials = ctx.req.headers[INTERNAL_HEADERS.credentials];

switch (requestCredentials) {
case 'omit':
return void 0;
case 'same-origin':
return ctx.dest.reqOrigin === ctx.dest.domain ? src : void 0;
case 'include':
return src;
default:
return void 0;
}
}

function transformAuthorizationHeader (src: string, ctx: RequestPipelineContext): string | undefined {
return ctx.isXhr && isCrossDomainXhrWithoutCredentials(ctx) ? void 0 : src;
}

function transformCookie (src: string, ctx: RequestPipelineContext): string {
if (ctx.isXhr)
return isCrossDomainXhrWithoutCredentials(ctx) ? void 0 : src;
else if (ctx.isFetch)
return transformCookieForFetch(src, ctx);

return src;
return shouldOmitCredentials(ctx) ? void 0 : src;
}

function generateSyncCookie (ctx: RequestPipelineContext, parsedServerCookies) {
Expand Down Expand Up @@ -105,6 +78,12 @@ function transformRefreshHeader (src: string, ctx: RequestPipelineContext) {
return src.replace(/(url=)(.*)$/i, (_match, prefix, url) => prefix + resolveAndGetProxyUrl(url, ctx));
}

export function processSetCookieHeader (src: string | string[], ctx: RequestPipelineContext) {
let parsedCookies = src && !shouldOmitCredentials(ctx) ? ctx.session.cookies.setByServer(ctx.dest.url, src) : [];

return generateSyncCookie(ctx, parsedCookies);
}

// Request headers
export const requestTransforms = {
[BUILTIN_HEADERS.host]: (_src, ctx) => ctx.dest.host,
Expand All @@ -124,12 +103,12 @@ export const requestTransforms = {

export const forcedRequestTransforms = {
[BUILTIN_HEADERS.cookie]: (_src: string, ctx: RequestPipelineContext) =>
transformCookie(ctx.session.cookies.getHeader(ctx.dest.url) || void 0, ctx),
shouldOmitCredentials(ctx) ? void 0 : ctx.session.cookies.getHeader(ctx.dest.url) || void 0,

// NOTE: All browsers except Chrome don't send the 'Origin' header in case of the same domain XHR requests.
// So, if the request is actually cross-domain, we need to force the 'Origin' header to support CORS. (B234325)
[BUILTIN_HEADERS.origin]: (src: string, ctx: RequestPipelineContext) => {
const force = (ctx.isXhr || ctx.isFetch) && !src && ctx.dest.domain !== ctx.dest.reqOrigin;
const force = ctx.isAjax && !src && ctx.dest.domain !== ctx.dest.reqOrigin;

return force ? ctx.dest.reqOrigin : src;
},
Expand Down Expand Up @@ -204,11 +183,7 @@ export const responseTransforms = {
};

export const forcedResponseTransforms = {
[BUILTIN_HEADERS.setCookie]: (src: string, ctx: RequestPipelineContext) => {
let parsedCookies = src ? ctx.session.cookies.setByServer(ctx.dest.url, src) : [];

return generateSyncCookie(ctx, parsedCookies);
},
[BUILTIN_HEADERS.setCookie]: processSetCookieHeader,

[INTERNAL_HEADERS.wwwAuthenticate]: (_src: string, ctx: RequestPipelineContext) =>
ctx.destRes.headers[BUILTIN_HEADERS.wwwAuthenticate],
Expand Down
4 changes: 2 additions & 2 deletions src/request-pipeline/request-options.ts
Expand Up @@ -16,7 +16,7 @@ export default class RequestOptions {
method: string;
credentials: Credentials;
body: Buffer;
isXhr: boolean;
isAjax: boolean;
rawHeaders: string[];
headers: IncomingHttpHeaders;
auth: string | void;
Expand Down Expand Up @@ -46,7 +46,7 @@ export default class RequestOptions {
this.method = ctx.req.method;
this.credentials = ctx.session.getAuthCredentials();
this.body = ctx.reqBody;
this.isXhr = ctx.isXhr;
this.isAjax = ctx.isAjax;
this.rawHeaders = ctx.req.rawHeaders;
this.headers = headers;

Expand Down
2 changes: 1 addition & 1 deletion src/request-pipeline/utils.ts
Expand Up @@ -66,7 +66,7 @@ export function sendRequest (ctx: RequestPipelineContext) {
export function error (ctx: RequestPipelineContext, err: string) {
if (ctx.isPage && !ctx.isIframe)
ctx.session.handlePageError(ctx, err);
else if (ctx.isFetch || ctx.isXhr)
else if (ctx.isAjax)
ctx.req.destroy();
else
ctx.closeWithError(500, err.toString());
Expand Down
15 changes: 14 additions & 1 deletion src/request-pipeline/xhr/same-origin-policy.ts
Expand Up @@ -15,7 +15,7 @@ export function check (ctx: RequestPipelineContext): boolean {
if (ctx.req.method === 'OPTIONS')
return true;

const withCredentials = ctx.req.headers[INTERNAL_HEADERS.credentials] === (ctx.isXhr ? 'true' : 'include');
const withCredentials = ctx.req.headers[INTERNAL_HEADERS.credentials] === 'include';
const allowOriginHeader = ctx.destRes.headers[BUILTIN_HEADERS.accessControlAllowOrigin];
const allowCredentialsHeader = ctx.destRes.headers[BUILTIN_HEADERS.accessControlAllowCredentials];
const allowCredentials = String(allowCredentialsHeader).toLowerCase() === 'true';
Expand All @@ -35,3 +35,16 @@ export function check (ctx: RequestPipelineContext): boolean {
// FINAL CHECK: The request origin should match one of the allowed origins.
return wildcardAllowed || allowedOrigins.includes(reqOrigin);
}

export function shouldOmitCredentials (ctx: RequestPipelineContext): boolean {
switch (ctx.req.headers[INTERNAL_HEADERS.credentials]) {
case 'omit':
return true;
case 'same-origin':
return ctx.dest.reqOrigin !== ctx.dest.domain;
case 'include':
return false;
default:
return false;
}
}
2 changes: 1 addition & 1 deletion src/session/events/info.ts
Expand Up @@ -18,7 +18,7 @@ export class RequestInfo {
this.userAgent = ctx.reqOpts.headers['user-agent'] || '';
this.url = ctx.reqOpts.url;
this.method = ctx.reqOpts.method.toLowerCase();
this.isAjax = ctx.isXhr || ctx.isFetch;
this.isAjax = ctx.isAjax;
this.headers = ctx.reqOpts.headers;
this.body = ctx.reqOpts.body;
this.sessionId = ctx.session.id;
Expand Down

0 comments on commit c6ae1e1

Please sign in to comment.