@openziti/ziti-browzer-sw-workbox-strategies
Version:
Custom Workbox Strategies used as part of the OpenZiti browZer stack
1,885 lines • 73.3 kB
text/typescript
import {WorkboxError} from 'workbox-core/_private/WorkboxError.js';
import {StrategyHandler} from 'workbox-strategies/StrategyHandler.js';
import {CacheFirst} from 'workbox-strategies/CacheFirst.js';
import {StrategyOptions} from 'workbox-strategies/Strategy.js';
import {Mutex, withTimeout, Semaphore} from 'async-mutex';
import { isUndefined, isEqual } from 'lodash-es';
import * as cheerio from 'cheerio';
import {
ZitiBrowzerCore,
ZITI_CONSTANTS
} from '@openziti/ziti-browzer-core';
import pjson from '../package.json';
export interface ZitiFirstOptions extends StrategyOptions {
zitiBrowzerServiceWorkerGlobalScope?: any;
logLevel?: string;
eruda?: boolean;
controllerApi?: string;
zitiNetworkTimeoutSeconds?: number;
uuid?: string;
}
type ZitiShouldRouteResult = {
routeOverZiti?: boolean | false;
serviceName?: string | '';
serviceScheme?: string | '';
serviceConnectAppData?: object | undefined;
url?: string | '';
}
var regexZBR = new RegExp( /ziti-browzer-runtime-\w{8}\.js/, 'g' );
var regexZBRnaked = new RegExp( /ziti-browzer-runtime\.js/, 'gi' );
var regexZBRLogo = new RegExp( /ziti-browzer-logo/, 'g' );
var regexZBRcss = new RegExp( /ziti-browzer-css-\w{8}\.css/, 'g' );
var regexZBRCORS = new RegExp( /ziti-cors-proxy/, 'g' );
var regexEdgeClt = new RegExp( /\/edge\/client\/v1/, 'g' );
var regexZBWASM = new RegExp( /libcrypto.*.wasm/, 'g' );
var regexPolipop = new RegExp( /polipop/, 'g' );
var regexCannySetup = new RegExp( /canny-setup/, 'g' );
var regexOAUTHTOKEN = new RegExp( /\/oauth\/token/, 'g' );
var regexFavicon = new RegExp( /\/favicon\.ico/, 'g' );
var regexJSDelivr = new RegExp( /jsdelivr.net/, 'g' );
var regexZentaoRefreshRandom = new RegExp( /\/index\.php\?m\=user\&f\=refreshRandom/, 'g' );
var regexZentaoLogin = new RegExp( /\/index\.php\?m\=user\&f\=login/, 'g' );
var regexZentaoLoginReferer = new RegExp( /\/index\.php\?m\=user\&f\=login\&referer\=/, 'g' );
var regexLibRdpHtml = new RegExp( /librdphtml/, 'g' );
var regexAtImport = new RegExp( /\@import/, 'gi' );
var regexSlash = new RegExp( /^\/$/, 'g' );
var regexDotSlash = new RegExp( /^\.\//, 'g' );
var regexTextHtml = new RegExp( /text\/html/, 'i' );
var regexTextXml = new RegExp( /text\/xml/, 'i' );
var regexMstsXml = new RegExp( /application\/x\-msts\-radc\+xml/, 'i' );
var regexAppJS = new RegExp( /application\/javascript/, 'i' );
var regexAppJSON = new RegExp( /application\/json/, 'i' );
var regexVideo = new RegExp( /video/, 'i' );
var regexMpeg = new RegExp( /mpeg/, 'i' );
var regexImage = new RegExp( /image\//, 'i' );
var regexCSS = new RegExp( /^.*\.css$/, 'i' );
var regexJS = new RegExp( /^.*\.js$/, 'i' );
var regexPNG = new RegExp( /^.*\.png$/, 'i' );
var regexJPG = new RegExp( /^.*\.jpg$/, 'i' );
var regexSVG = new RegExp( /^.*\.svg$/, 'i' );
var regexControllerAPI: any;
const keycloakJs = `https://cdn.jsdelivr.net/npm/keycloak-js@23.0.1/dist/keycloak.min.js`;
const erudaJs = `https://cdn.jsdelivr.net/npm/eruda@3.0.1/eruda.min.js`;
interface PolicyResult {
[key: string]: string[];
}
interface PolicyBuilderOptions {
directives: Readonly<Record<string, string[] | string | boolean>>;
}
/**
* An implementation of a Ziti network request strategy.
*
* If the network request fails, and there is no cache match, this will throw
* a `WorkboxError` exception.
*
*/
class ZitiFirstStrategy extends CacheFirst /* NetworkFirst */ {
_zitiBrowzerServiceWorkerGlobalScope: any;
private readonly _zitiNetworkTimeoutSeconds: number;
private readonly _logLevel: string;
private readonly _controllerApi: string;
private _core: any;
private logger: any;
private _zitiContext: any;
private _initialized: boolean;
private _initializationMutex: any;
private _uuid: any;
private _rootPaths: any;
private _targetServiceHost: string;
/**
* @param {Object} [options]
* @param {string} [options._zitiBrowzerServiceWorkerGlobalScope] config dsts
* @param {string} [options._logLevel] Which level to log at
* @param {string} [options._controllerApi] Location of Ziti Controller
* @param {number} [options.zitiNetworkTimeoutSeconds] If set, any network requests
* that fail to respond within the timeout will fallback to the cache.
*
*/
constructor(options: ZitiFirstOptions = {}) {
super(options);
this._zitiBrowzerServiceWorkerGlobalScope = options.zitiBrowzerServiceWorkerGlobalScope || 0;
this._zitiNetworkTimeoutSeconds = options.zitiNetworkTimeoutSeconds || 0;
this._logLevel = options.logLevel || 'Silent';
this._controllerApi = options.controllerApi || '<controllerApi-not-configured>';
this._initialized = false;
this._targetServiceHost = '';
var controllerAPIURL = new URL( this._controllerApi );
regexControllerAPI = new RegExp( controllerAPIURL.host, 'g' );
this._initializationMutex = new Mutex();
this._uuid = options.uuid;
this._rootPaths = [];
this._core = new ZitiBrowzerCore({});
this.logger = this._core.createZitiLogger({
logLevel: this._logLevel,
suffix: 'ZBSW',
useSWPostMessage: options.eruda,
zitiBrowzerServiceWorkerGlobalScope: this._zitiBrowzerServiceWorkerGlobalScope,
});
this.logger.trace(`ZitiFirstStrategy ctor completed`);
}
parseCSP(policy: any): PolicyResult {
const result: PolicyResult = {};
policy.split(";").forEach((directive: any) => {
const [directiveKey, ...directiveValue] = directive.trim().split(/\s+/g);
if (
directiveKey &&
!Object.prototype.hasOwnProperty.call(result, directiveKey)
) {
result[directiveKey] = directiveValue;
}
});
return result;
};
buildCSP({ directives }: Readonly<PolicyBuilderOptions>): string {
const namesSeen = new Set<string>();
const result: string[] = [];
Object.keys(directives).forEach((originalName) => {
const name = originalName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
if (namesSeen.has(name)) {
throw new Error(`${originalName} is specified more than once`);
}
namesSeen.add(name);
let value = directives[originalName];
if (Array.isArray(value)) {
value = value.join(" ");
} else if (value === true) {
value = "";
}
if (value) {
result.push(`${name} ${value}`);
} else if (value !== false) {
result.push(name);
}
});
return result.join("; ");
};
generateNewCSP(val:string|undefined) {
let origCSP = this.parseCSP(val);
this.logger.trace( `generateNewCSP() origCSP: `, origCSP);
let idpURL = new URL(this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.idp.host);
let idpHost = idpURL.host;
let controllerURL = new URL(this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.controller.api);
let controllerHost = controllerURL.host;
if (origCSP['default-src']) {
origCSP['default-src'].push(`https://*.netfoundry.io:*`);
origCSP['default-src'].push(`https://*.cloudziti.io`);
origCSP['default-src'].push(`wss://*.netfoundry.io:*`);
origCSP['default-src'].push("data:");
origCSP['default-src'].push("https://opencollective.com");
}
if (origCSP['script-src']) {
origCSP['script-src'].push(`${this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.idp.host}`.replace('https://',''));
origCSP['script-src'].push(`canny.io`);
if (!origCSP['script-src'].includes("'unsafe-eval'")) {
origCSP['script-src'].push("'unsafe-eval'");
}
}
if (origCSP['connect-src']) {
origCSP['connect-src'].push(`${idpHost}`);
origCSP['connect-src'].push(`${this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host}`);
origCSP['connect-src'].push(`${controllerHost}`);
origCSP['connect-src'].push(`https://*.netfoundry.io:*`);
origCSP['connect-src'].push(`https://*.cloudziti.io`);
origCSP['connect-src'].push(`wss://*.netfoundry.io:*`);
origCSP['connect-src'].push(`wss://localhost:*`);
if (!origCSP['connect-src'].includes("data:")) {
origCSP['connect-src'].push("data:");
}
}
if (origCSP['img-src']) {
origCSP['img-src'].push(`data:`);
origCSP['img-src'].push(`${this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host}`);
origCSP['img-src'].push(`*`);
}
if (origCSP['font-src']) {
if (!origCSP['font-src'].includes("data:")) {
origCSP['font-src'].push("data:");
}
}
let directives:any = {}
if (!isUndefined(origCSP['child-src'])) { directives.childSrc = origCSP['child-src'];}
if (!isUndefined(origCSP['connect-src'])) { directives.connectSrc = origCSP['connect-src'];}
if (!isUndefined(origCSP['default-src'])) { directives.defaultSrc = origCSP['default-src'];}
if (!isUndefined(origCSP['font-src'])) { directives.fontSrc = origCSP['font-src'];}
if (!isUndefined(origCSP['frame-ancestors'])) { directives.frameAncestors = origCSP['frame-ancestors'];}
if (!isUndefined(origCSP['frame-src'])) { directives.frameSrc = origCSP['frame-src'];}
if (!isUndefined(origCSP['img-src'])) { directives.imgSrc = origCSP['img-src'];}
if (!isUndefined(origCSP['media-src'])) { directives.mediaSrc = origCSP['media-src'];}
if (!isUndefined(origCSP['object-src'])) { directives.objectSrc = origCSP['object-src'];}
if (!isUndefined(origCSP['script-src'])) { directives.scriptSrc = origCSP['script-src'];}
if (!isUndefined(origCSP['style-src'])) { directives.styleSrc = origCSP['style-src'];}
if (!isUndefined(origCSP['worker-src'])) { directives.workerSrc = origCSP['worker-src'];}
let newCSP = this.buildCSP({ directives });
let newCSParray = this.parseCSP(newCSP);
this.logger.trace( `generateNewCSP() newCSP: `, newCSParray);
return newCSP;
}
/**
* Remain in lazy-sleepy loop until z-b-runtime sends us the _zitiConfig
*
*/
async await_zitiConfig(requestUrl: string, _handler: StrategyHandler) {
let self = this;
let ctr = 0;
let waitTime = 100;
return new Promise((resolve: any, _reject: any) => {
(async function waitFor_zitiConfig() {
if (isUndefined(self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig) || isUndefined(self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.access_token)) {
ctr++;
self.logger.trace(`await_zitiConfig: ...waiting [${ctr}] for [${requestUrl}]`);
if (ctr == 5) { // kick the ZBR, and ask for the config
self.logger.trace( 'await_zitiConfig: sending ZITI_CONFIG_NEEDED msg to ZBR');
self._zitiBrowzerServiceWorkerGlobalScope._sendMessageToClients( { type: 'ZITI_CONFIG_NEEDED'} );
setTimeout(waitFor_zitiConfig, waitTime);
}
else if (ctr == 10) { // only do the unregister once
self.logger.trace(`await_zitiConfig: initiating unregister`);
// Let's try and 'reboot' the ZBR/SW pair
await self._zitiBrowzerServiceWorkerGlobalScope._unregister();
return resolve( -1 );
}
else {
setTimeout(waitFor_zitiConfig, waitTime);
}
} else {
self.logger.trace(`await_zitiConfig: config acquired for [${requestUrl}]`);
return resolve( 0 );
}
})();
});
}
/**
* Remain in lazy-sleepy loop until z-b-runtime notifies us that it has completed initialization
*
*/
async await_zbrInitialized(request: Request) {
let self = this;
let ctr = 0;
return new Promise((resolve: any, reject: any) => {
(function waitFor_zbrInitialized() {
if (self._zitiBrowzerServiceWorkerGlobalScope._zbrReloadPending) { // this gets reset when ZBR sends the SW the
self.logger.trace(`await_zbrInitialized: ...waiting for [${request.url}]`);
ctr++;
if (ctr > 40) {return reject();}
setTimeout(waitFor_zbrInitialized, 250);
} else {
self.logger.trace(`await_zbrInitialized: ...acquired for [${request.url}]`);
self.logger.trace(`await_zbrInitialized: ...setting logLevel to [${self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.sw.logLevel}]`);
self.logger.logLevel = self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.sw.logLevel;
return resolve();
}
})();
});
}
accessTokenRefreshedEventHandler(accessTokenRefreshedEvent: any) {
this.logger.trace(`accessTokenRefreshedEventHandler() ${accessTokenRefreshedEvent}`);
this._zitiBrowzerServiceWorkerGlobalScope._currentAPISession = this._zitiBrowzerServiceWorkerGlobalScope._zitiContext.getCurrentAPISession();
this._zitiBrowzerServiceWorkerGlobalScope._accessTokenRefreshed(); // This will cause ZBR to ask us for the new data
}
idpAuthHealthEventHandler(idpAuthHealthEvent: any) {
this.logger.trace(`idpAuthHealthEventHandler() ${idpAuthHealthEvent}`);
if (idpAuthHealthEvent.expired) {
this.logger.trace( `idpAuthHealthEventHandler: authToken has expired and will be torn down`);
setTimeout(function(_zitiBrowzerServiceWorkerGlobalScope: any) {
_zitiBrowzerServiceWorkerGlobalScope._accessTokenExpired(); // This will cause a logout with the IdP
}, 10, this._zitiBrowzerServiceWorkerGlobalScope);
setTimeout(function(_zitiBrowzerServiceWorkerGlobalScope: any) {
_zitiBrowzerServiceWorkerGlobalScope._unregister(); // Let's try and 'reboot' the ZBR/SW pair
}, 500, this._zitiBrowzerServiceWorkerGlobalScope);
}
}
async noConfigForServiceEventHandler(noConfigForServiceEvent: any) {
this.logger.trace(`noConfigForServiceEventHandler() `, noConfigForServiceEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._noConfigForService(noConfigForServiceEvent);
}
async noConfigProtocolForServiceEventHandler(noConfigProtocolForServiceEvent: any) {
this.logger.trace(`noConfigProtocolForServiceEventHandler() `, noConfigProtocolForServiceEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._noConfigProtocolForService(noConfigProtocolForServiceEvent);
}
async WSSEnabledEdgeRouterConnectionErrorEventHandler(wssERConnectionErrorEvent: any) {
this.logger.trace(`WSSEnabledEdgeRouterConnectionErrorEventHandler() `, wssERConnectionErrorEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._wssERConnectionError(wssERConnectionErrorEvent);
}
async ControllerConnectionErrorEventHandler(controllerConnectionErrorEvent: any) {
this.logger.trace(`ControllerConnectionErrorEventHandler() `, controllerConnectionErrorEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._controllerConnectionError(controllerConnectionErrorEvent);
}
async sessionCreationErrorEventHandler(sessionCreationErrorEvent: any) {
this.logger.trace(`sessionCreationErrorEventHandler() `, sessionCreationErrorEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._sessionCreationError(sessionCreationErrorEvent);
this._zitiBrowzerServiceWorkerGlobalScope._unregisterNoReload();
}
async noServiceEventHandler(noServiceEvent: any) {
this.logger.trace(`noServiceEventHandler() `, noServiceEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._noService(noServiceEvent);
this._zitiBrowzerServiceWorkerGlobalScope._unregisterNoReload();
}
async invalidAuthEventHandler(invalidAuthEvent: any) {
this.logger.trace(`invalidAuthEventHandler() `, invalidAuthEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._invalidAuth(invalidAuthEvent);
}
async noWSSRoutersEventHandler(noWSSRoutersEvent: any) {
this.logger.trace(`noWSSRoutersEventHandler() `, noWSSRoutersEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._noWSSRouters(noWSSRoutersEvent);
}
async channelConnectFailEventHandler(channelConnectFailEvent: any) {
this.logger.trace(`channelConnectFailEventHandler() `, channelConnectFailEvent);
await this._zitiBrowzerServiceWorkerGlobalScope._channelConnectFail(channelConnectFailEvent);
}
async xgressEventHandler(xgressEvent: any) {
this._zitiBrowzerServiceWorkerGlobalScope._xgressEvent(xgressEvent);
}
async nestedTLSHandshakeTimeoutEventHandler(nestedTLSHandshakeTimeoutEvent: any) {
this._zitiBrowzerServiceWorkerGlobalScope._nestedTLSHandshakeTimeout(nestedTLSHandshakeTimeoutEvent);
}
/**
* Do all work necessary to initialize the ZitiFirstStrategy instance.
*
*/
async _initialize() {
// Run the init sequence within a critical-section
await this._initializationMutex.runExclusive(async () => {
return new Promise( async (resolve, _) => {
if (!this._initialized) {
this.logger.trace(`_initialize: entered`);
if (isUndefined(this._zitiContext)) {
this._zitiContext = this._core.createZitiContext({
logger: this.logger,
controllerApi: this._controllerApi,
sdkType: pjson.name,
sdkVersion: pjson.version,
sdkBranch: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.sdkBranch,
sdkRevision: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.sdkRevision,
token_type: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.token_type,
id_token: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.id_token,
access_token: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.access_token,
bootstrapperTargetService: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.service,
bootstrapperHost: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host,
});
this.logger.trace(`_initialize: ZitiContext created`);
this._zitiBrowzerServiceWorkerGlobalScope._zitiContext = this._zitiContext;
// Make SW scope available to idpAuthHealthEventHandler
this._zitiContext._zitiBrowzerServiceWorkerGlobalScope = this._zitiBrowzerServiceWorkerGlobalScope;
this._zitiContext.setKeyTypeEC();
await this._zitiContext.initialize({
loadWASM: true, // unlike the ZBR, here in the ZBSW, we always instantiate the internal WebAssembly
doAuthenticate: true, // unlike the ZBR, here in the ZBSW, we always auth with Controller
jspi: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.jspi,
target: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target,
bootstrapperHost: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host
});
await this._zitiContext.listControllerVersion();
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_IDP_AUTH_HEALTH, this.idpAuthHealthEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_NO_CONFIG_FOR_SERVICE, this.noConfigForServiceEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_NO_SERVICE, this.noServiceEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_SESSION_CREATION_ERROR, this.sessionCreationErrorEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_INVALID_AUTH, this.invalidAuthEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_CHANNEL_CONNECT_FAIL, this.channelConnectFailEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_NO_WSS_ROUTERS, this.noWSSRoutersEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_XGRESS, this.xgressEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_NESTED_TLS_HANDSHAKE_TIMEOUT, this.nestedTLSHandshakeTimeoutEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_NO_CONFIG_PROTOCOL_FOR_SERVICE, this.noConfigProtocolForServiceEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_WSS_ROUTER_CONNECTION_ERROR, this.WSSEnabledEdgeRouterConnectionErrorEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_CONTROLLER_CONNECTION_ERROR, this.ControllerConnectionErrorEventHandler);
this._zitiContext.on(ZITI_CONSTANTS.ZITI_EVENT_ACCESS_TOKEN_REFRESHED, this.accessTokenRefreshedEventHandler);
this.logger.trace(`_initialize: ZitiContext '${this._uuid}' initialized`);
} else {
this.logger.trace(`_initialize: initiating unregister`);
await this._zitiBrowzerServiceWorkerGlobalScope._unregister(); // Let's try and 'reboot' the ZBR/SW pair
this.logger.trace(`_initialize: terminated`);
}
setTimeout(async function(self: any, resolve: any) {
let result = await self._zitiContext.enroll(); // this acquires an ephemeral Cert
if (!result) {
self.logger.trace(`_initialize: ephemeral Cert acquisition failed`);
// If we couldn't acquire a cert, it most likely means that the JWT from the IdP needs a refresh
self.logger.trace(`_initialize: initiating unregister`);
await self._zitiBrowzerServiceWorkerGlobalScope._unregister(); // Let's try and 'reboot' the ZBR/SW pair
self.logger.trace(`_initialize: terminated`);
} else {
self.logger.trace(`_initialize: ephemeral Cert acquisition succeeded`);
self._rootPaths.push(self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.path);
self._initialized = true;
self._zitiBrowzerServiceWorkerGlobalScope._currentAPISession = self._zitiContext.getCurrentAPISession();
self.logger.trace(`_initialize: ZitiContext '${self._uuid}' initialize complete`);
}
return resolve(null);
}, 500, this, resolve);
} else {
return resolve(null);
}
});
})
.catch(( err: any ) => {
this.logger.error(err);
return new Promise( async (_, reject) => {
reject( err );
});
});
}
_sendServiceUnavailable(_zitiBrowzerServiceWorkerGlobalScope: any, newUrl: any) {
_zitiBrowzerServiceWorkerGlobalScope._sendMessageToClients(
{
type: 'SERVICE_UNAVAILABLE_TO_IDENTITY',
payload: {
message: `Ziti Service ${newUrl.hostname} is unavailable to your identity; Notify your administrator.`
}
}
)
}
/**
* Determine if this request should be routed over Ziti, or over raw internet.
*
* @private
* @param {Request} request The request from the fetch event.
* @return {ZitiShouldRouteResult} If request should go over Ziti we return a (possibly adjusted) URL
*/
async _shouldRouteOverZiti(request: Request) {
let result: ZitiShouldRouteResult = {}
this.logger.trace(`_shouldRouteOverZiti starting`);
let url = new URL(request.url);
result.url = url.toString();
let targetHost = url.hostname;
let targetPort = url.port;
if (isEqual(targetHost, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host) && isEqual(targetPort,'')) {
targetPort = '443';
}
let targetPath = url.pathname;
this.logger.trace(`_shouldRouteOverZiti targetHost:port path is: ${targetHost}:${targetPort} ${targetPath}`);
try {
// We want to intercept fetch requests that target the Ziti BrowZer Bootstrapper... that is...
// ...we want to intercept any request from the web app that targets the server from
// which the app was loaded.
let targetserviceHost = await this._zitiContext.getConfigHostByServiceName (this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.service);
let connectAppData = await this._zitiContext.getConnectAppDataByServiceName (this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.service, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.scheme);
var targetServiceRegex = new RegExp( targetserviceHost , 'g' );
var browzerLoadBalancerRegex = new RegExp( this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.loadbalancer.host , 'g' );
if (
(isEqual(targetHost, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host) && (isEqual(targetPort, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.port))) // yes, the request is targeting the Ziti BrowZer Bootstrapper
||
(isEqual(targetHost, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host) && (isEqual(targetPort, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.loadbalancer.port))) // yes, the request is targeting the Ziti BrowZer LB
||
(this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.loadbalancer.host && request.url.match( browzerLoadBalancerRegex ))
) { // yes, the request is targeting the Ziti BrowZer LoadBalancer
var newUrl = new URL( request.url );
if ( isEqual(targetPath, '/')) {
result.routeOverZiti = true;
result.serviceName = this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.service;
result.serviceScheme = this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.scheme;
result.serviceConnectAppData = connectAppData;
}
else if ( (request.url.match( regexZBR )) || (request.url.match( regexZBRnaked )) || (request.url.match( regexZBWASM )) || (request.url.match( regexZBRLogo )) || (request.url.match( regexZBRcss )) || (request.url.match( regexZBRCORS ))) { // the request seeks z-b-r/wasm/logo/css/cors-proxy
this.logger.trace(`_shouldRouteOverZiti: z-b-r/css/wasm/logo, bypassing intercept of [${request.url}]`);
result.routeOverZiti = false;
}
else {
// Don't muck with URL only because top-level domain of target matches the top-level domain of the load-balancer.
// Only do that if the entire hostname matches, or else sub-domains represented by different Services will be routed
// to the wrong place.
if (isEqual(newUrl.hostname, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host)) {
newUrl.hostname = this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.service;
newUrl.port = this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.port;
if (
isEqual(newUrl.port, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.port) ||
isEqual(newUrl.port, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.loadbalancer.port)
) {
newUrl.port = '';
}
}
var pathnameArray = newUrl.pathname.split('/');
var targetpathnameArray = this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.path.split('/');
if (!isEqual(pathnameArray[1], targetpathnameArray[1])) {
newUrl.pathname = this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.path + newUrl.pathname;
newUrl.pathname = newUrl.pathname.replace('//','/');
}
this.logger.trace( '_shouldRouteOverZiti: transformed URL: ', newUrl.toString());
result.serviceName = await this._zitiContext.shouldRouteOverZiti( newUrl );
this.logger.trace(`_shouldRouteOverZiti result.serviceName[${result.serviceName}]`);
if (isUndefined(result.serviceName) || isEqual(result.serviceName, '')) { // If we have no config associated with the hostname:port, do not intercept
this.logger.warn(`_shouldRouteOverZiti: no associated Ziti config, bypassing intercept of [${request.url}]`);
setTimeout(this._sendServiceUnavailable, 250, this._zitiBrowzerServiceWorkerGlobalScope, newUrl);
} else {
result.routeOverZiti = true;
result.url = newUrl.toString();
result.serviceScheme = this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.scheme;
result.serviceConnectAppData = connectAppData;
}
}
}
// If no routing determination has been made yet
if (isUndefined(result.routeOverZiti)) {
result.serviceName = await this._zitiContext.shouldRouteOverZiti( url );
this.logger.trace(`_shouldRouteOverZiti result.serviceName[${result.serviceName}]`);
if (isUndefined(result.serviceName) || isEqual(result.serviceName, '')) { // If we have no config associated with the hostname:port, do not intercept
this.logger.warn(`_shouldRouteOverZiti: no associated Ziti config, bypassing intercept of [${request.url}]`);
result.routeOverZiti = false;
} else {
result.routeOverZiti = true;
result.url = url.toString();
result.serviceScheme = url.protocol;
let connectAppData = await this._zitiContext.getConnectAppDataByServiceName (result.serviceName, url.protocol);
result.serviceConnectAppData = connectAppData;
}
}
} catch (e) {
this.logger.error( e );
}
this.logger.trace(`_shouldRouteOverZiti result[${result.routeOverZiti}]`);
return result;
}
/**
* Determine if this request can used previously cached response, or be routed over Ziti/internet.
*
* @private
* @param {Request} request The request from the fetch event.
* @return {boolean} If request can used previously cached response
*/
_shouldUseCache(request: Request): boolean {
if (request.method !== 'GET') { // Only cache GET responses
this.logger.trace(`_shouldUseCache: handling ${request.method} method; NOT using cache`);
return false;
}
if (request.url.match( regexEdgeClt )) { // Never cache responses from Ziti Controller
this.logger.trace(`_shouldUseCache: handling request to Ziti Controller; NOT using cache`);
return false;
}
if (request.url.match( regexControllerAPI )) { // Never cache responses from Ziti Controller
this.logger.trace(`_shouldUseCache: handling request to Ziti Controller; NOT using cache`);
return false;
}
// We will allow the SW to cache teh ZBR/WASM files ...for the moment
//
// if ( (request.url.match( regexZBR )) || ((request.url.match( regexZBWASM ))) ) { // Do not cache the ZBR/WASM
// this.logger.trace(`_shouldUseCache: handling request for ZBR|WASM; NOT using cache`);
// return false;
// }
if (request.url.match( regexSlash ) ) { // Never cache responses for root path
this.logger.trace(`_shouldUseCache: handling request for '/'; NOT using cache`);
return false;
}
let url = new URL(request.url);
if ( url.pathname === '/' ) { // Do not cache the web app's root path
this.logger.trace(`_shouldUseCache: handling request for ROOT path; NOT using cache`);
return false;
}
let isRootPath = this._rootPaths.find((element: string) => element === `${url.pathname}`);
if ( isRootPath ) { // Do not cache the web app's root path
this.logger.trace(`_shouldUseCache: handling request for ROOT path; NOT using cache`);
return false;
}
if ( url.search !== '' ) { // Do not cache requests with search parms
this.logger.trace(`_shouldUseCache: handling request with search parms; NOT using cache`);
return false;
}
// if (request.url.match( regexLibRdpHtml )) {
// this.logger.trace(`_shouldUseCache: handling request with librdphtml; NOT using cache`);
// return false;
// }
// Cache everything else
this.logger.trace(`_shouldUseCache: we WILL cache response for ${request.url}`);
return true;
}
_isRootPATH(request: Request): boolean {
if (request.url.match( regexSlash ) ) {
return true;
}
let url = new URL(request.url);
if ( url.pathname === '/' ) {
return true;
}
let isRootPath = this._rootPaths.find((element: string) => element === `${url.pathname}`);
if ( isRootPath ) {
return true;
}
return false;
}
/**
* @private
* @param {Request|string} request A request to run this strategy for.
* @param {workbox-strategies.StrategyHandler} handler The event that triggered the request.
* @return {Promise<Response>}
*/
async _handle(request: Request, handler: StrategyHandler): Promise<Response> {
let tryZiti: boolean | false;
this.logger.trace(`_handle entered for: [${request.url}]`);
const requestURL = new URL(request.url);
// If hitting the Controller, or seeking z-b-runtime|WASM, then
// we never go over Ziti, and we let the browser route the request
// to the Controller or browZer Bootstrapper.
if (
(request.url.match( regexControllerAPI )) || // " " "
(request.url.match( regexZBR )) || // seeking Ziti BrowZer Runtime
(request.url.match( regexZBRnaked )) || // seeking Ziti BrowZer Runtime
(request.url.match( regexZBRLogo )) || // seeking Ziti BrowZer Logo
(request.url.match( regexZBRCORS )) || // seeking Ziti BrowZer CORS proxy
(request.url.match( regexZBRcss )) || // seeking Ziti BrowZer CSS
(request.url.match( regexPolipop )) || // seeking Ziti Polipop
(request.url.match( regexCannySetup )) || // seeking Canny setup
(request.url.match( regexOAUTHTOKEN )) || // seeking IdP token
(request.url.match( regexFavicon )) || // seeking favicon
(request.url.match( regexZBWASM )) || // seeking Ziti BrowZer WASM
(request.url.match( regexJSDelivr )) // seeking CDN content
) {
tryZiti = false;
} else {
tryZiti = true;
}
// if (request.url.match( regexLibRdpHtml )) {
// await this._zitiBrowzerServiceWorkerGlobalScope._sendMessageToClients( { type: 'REAPPLY_WEBSOCKET_INTERCEPT'} );
// }
if (this._isRootPATH(request)) {
const url = new URL(request.url)
const urlSearchParams = new URLSearchParams(url.search);
const codeParm = urlSearchParams.get('code');
const stateParm = urlSearchParams.get('state');
if (codeParm && stateParm) { // possible IdP-related URL
if (this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig && requestURL.hostname === this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host) { // ..but if hitting the protected web app itself
tryZiti = true; // ..then let it go over Ziti
} else {
tryZiti = false; // ..otherwise, route over raw internet since it's IdP-related
}
}
}
if (tryZiti && this._zitiBrowzerServiceWorkerGlobalScope._zbrReloadPending) {
if (request.url.match( regexZBWASM )) { // the ZBR loads the WASM during init, so we need to process that request; all others wait
/* NOP */
}
else if (request.url.match( regexControllerAPI )) { // the ZBR hits the Ziti Controller during init, so we need to process that request; all others wait
/* NOP */
}
else {
await this.await_zbrInitialized(request).catch( async ( _err: any ) => {
this.logger.debug(`ZBR init not responding`);
await this._zitiBrowzerServiceWorkerGlobalScope._unregister();
throw new WorkboxError('no-response', {url: request.url});
});
}
}
if (tryZiti && (!this._isRootPATH(request)) && (!request.url.match( regexZBR ))) { // if NOT in the process of bootstrapping from HTTP Agent
if (isUndefined(this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig) ) { // ...and we don't yet have the zitiConfig from ZBR
if (!request.url.match( regexEdgeClt )) { // ...and NOT hitting the controller
let result: any = await this.await_zitiConfig(request.url, handler); // ...then wait for ZBR to send zitiConfig to us
if (result < 0) {
let redirectResponse = new Response('', { // If ZBR is AWOL, initiate top-level page reboot
status: 302,
statusText: 'Found',
headers: {
Location: '/'
}
}
);
return redirectResponse;
};
}
}
}
let self = this;
let skipInject = false;
let useCache = this._shouldUseCache(request);
if (useCache) {
let cachResponse = await handler.cacheMatch(request);
if (cachResponse) {
return cachResponse;
}
}
const promises: Promise<Response | undefined>[] = [];
let timeoutId: number | undefined;
let bootstrappingZBRFromSW: boolean | false;
let bootstrappingZBRFromSWConfigNeeded: boolean | false;
let response: Response | undefined;
let shouldRoute: ZitiShouldRouteResult = {routeOverZiti: false}
if (tryZiti) {
if (this._isRootPATH(request) ) { // seeking root path
bootstrappingZBRFromSW = true;
if (isUndefined(this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig) ) { // ...but we don't yet have the zitiConfig from ZBR
tryZiti = false; // ...then we're bootstrapping, so load ZBR from HTTP Agent
bootstrappingZBRFromSWConfigNeeded = true;
}
}
}
if ( tryZiti ) {
if (isUndefined(this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig)) {
let result: any = await this.await_zitiConfig(request.url, handler);
if (result < 0) {
let redirectResponse = new Response('', { // If ZBR is AWOL, initiate top-level page reboot
status: 302,
statusText: 'Found',
headers: {
Location: '/'
}
}
);
return redirectResponse;
};
}
// If going over Ziti, we must first complete the work to ensure WASM is instantiated, we have a cert, etc
await this.await_zitiConfig('null', handler); // wait for ZBR to send zitiConfig to us
await this._initialize();
this._targetServiceHost = await this._zitiContext.getConfigHostByServiceName (this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.service);
// Now determine if we're going over Ziti or not
shouldRoute = await this._shouldRouteOverZiti(request);
}
if (this._zitiNetworkTimeoutSeconds) {
const {id, promise} = this._getZitiTimeoutPromise({request, handler});
timeoutId = id;
promises.push(promise);
}
let networkPromise;
let zitiNetworkPromise;
if (!shouldRoute.routeOverZiti) {
this.logger.trace(`_handle: ------- routing over raw internet ----------`);
networkPromise = this._getNetworkPromise({
timeoutId,
request,
handler,
useCache,
});
promises.push(networkPromise);
} else {
this.logger.trace(`_handle: ------- routing over Ziti ----------`);
zitiNetworkPromise = this._getZitiNetworkPromise({
timeoutId,
shouldRoute,
request,
handler,
useCache,
});
promises.push(zitiNetworkPromise);
}
response = await handler.waitUntil(
(async () => {
let netPromise = networkPromise || zitiNetworkPromise;
// Promise.race() will resolve as soon as the first promise resolves.
return (
(await handler.waitUntil(Promise.race(promises))) ||
// If Promise.race() resolved with null, it might be due to a network
// timeout + a cache miss. If that were to happen, we'd rather wait until
// the netPromise (which is either over Ziti or raw internet) resolves
// instead of returning null.
//
// Note that it's fine to await an already-resolved promise, so we don't
// have to check to see if it's still "in flight".
(await netPromise)
);
})(),
).catch(( err: any ) => {
this.logger.error(err);
return new Promise( async (_, reject) => {
reject( err );
});
});
if (!response) {
this.logger.error(`no-response when trying to reach URL [${request.url}]`);
await this._zitiBrowzerServiceWorkerGlobalScope._requestFailedWithNoResponse({
url: request.url
});
let errResponse = new Response('', {
status: 500,
statusText: 'ServerError',
}
);
return errResponse;
}
const location = response.headers.get('Location');
const contentType = response.headers.get('Content-Type');
if (request.url.match( regexZentaoRefreshRandom )) {
skipInject = true;
}
if (request.url.match( regexZentaoLogin ) && !request.url.match(regexZentaoLoginReferer)) {
skipInject = true;
}
if ( location && response.status >= 300 && response.status < 400 ) {
if (!this._rootPaths.find((element: string) => element === `${location}`)) {
this._rootPaths.push(location);
}
skipInject = true;
}
if ( response.status === 403 ) {
skipInject = true;
}
if (!contentType || !contentType.match( regexTextHtml )) {
skipInject = true;
}
if (contentType && contentType.match( regexTextHtml )) {
/**
* Jenkins thing
*/
useCache = false;
}
if (contentType && contentType.match( regexTextXml )) {
/**
* MSFT RDP thing
*/
// useCache = false;
}
if (contentType && contentType.match( regexMstsXml )) {
/**
* MSFT RDP thing
*/
// useCache = false;
}
if (contentType && contentType.match( regexAppJSON )) {
useCache = false;
}
if ((contentType && contentType.match( regexVideo )) || (contentType && contentType.match( regexMpeg ))) {
/**
* streaming media server thing
*/
useCache = false;
}
function streamingAttrReplace($:any, elementType:string, attrType:string) {
$(elementType).each( async (_:any, e:any) => {
let attr = $(e).attr(attrType);
if (attr) {
try {
let url = new URL(attr);
if (isEqual(url.host, self._targetServiceHost)) {
attr = attr.replace('http:', 'https:');
attr = attr.replace(self._targetServiceHost, self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host);
$(e).attr(attrType, attr);
}
} catch (e) {
let newUrl = new URL( `${requestURL.origin}` );
if (!attr.startsWith('/')) {
newUrl.pathname = `${requestURL.pathname}/${attr}`;
} else {
newUrl.pathname = `${requestURL.pathname}${attr}`;
}
newUrl.pathname = newUrl.pathname.replace('//','/');
attr = newUrl.toString();
self.logger.trace(`streamingAttrReplace: transformed attr[${attr}] `);
$(e).attr(attrType, attr);
}
}
});
}
function streamingImportReplace($:any, elementType:string) {
$(elementType).each( async (_:any, e:any) => {
let text = $(e).text();
if (text.match( regexAtImport ) ) {
let importUrl = text.slice(text.indexOf('@') + 7);
importUrl = importUrl.replaceAll('"', '');
importUrl = importUrl.replace(';', '');
let url = new URL(importUrl);
if (isEqual(url.host, self._targetServiceHost)) {
text = text.replace('http:', 'https:');
text = text.replace(self._targetServiceHost, self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host);
$(e).text(text);
}
}
});
}
if (shouldRoute.routeOverZiti && !skipInject) {
var ignore = false;
if (!ignore) {
if (response.body) {
let fromBootstrapper = false;
let fullDocument = true;
function _obtainBootStrapperURL() {
let url;
if (self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.loadbalancer.host) {
url = `https://${self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host}:${self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.loadbalancer.port}`;
} else {
url = `${self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.scheme}://${self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host}:${self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.port}`;
}
return url;
}
function streamingHEADReplace() {
let buffer = '';
return new TransformStream({
transform(chunk, _controller) {
try {
if (fullDocument && !fromBootstrapper) {
let zbrLocation = self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.runtime.src;
// Parse the HTML
const $ = cheerio.load(chunk,
{
decodeEntities: false // ensure we don't evaporate Angular-specific attributes like ng-app, ng-model, etc.
}
);
// Ensure any links in the HTML that point to the target service are using the correct scheme
streamingAttrReplace($, 'link', 'href');
streamingAttrReplace($, 'a', 'href');
streamingAttrReplace($, 'area', 'href');
streamingAttrReplace($, 'base', 'href');
streamingAttrReplace($, 'img', 'src');
streamingAttrReplace($, 'audio', 'src');
streamingAttrReplace($, 'embed', 'src');
streamingAttrReplace($, 'iframe', 'src');
streamingAttrReplace($, 'input', 'src');
streamingAttrReplace($, 'script', 'src');
streamingAttrReplace($, 'source', 'src');
streamingAttrReplace($, 'track', 'src');
streamingAttrReplace($, 'video', 'src');
// Ensure any @import's in the HTML that point to the target service are using the correct scheme
streamingImportReplace($, 'style');
self.logger.trace(`streamingHEADReplace: HTML before modifications is: ${$.html()}`);
let zbrElement = $('<script></script> ').attr('id', 'from-ziti-browzer-sw').attr('type', 'text/javascript').attr('src', `${_obtainBootStrapperURL()}/${zbrLocation}`); //.attr('defer', `defer`);
let cannyElement = $('<script></script> ')
.attr('id', 'ziti-browzer-canny-setup')
.attr('type', 'text/javascript')
.attr('src', `${_obtainBootStrapperURL()}/canny-setup.js`);
let ppElement = $('<script></script> ')
.attr('id', 'ziti-browzer-pp')
.attr('type', 'text/javascript')
.attr('src', `${_obtainBootStrapperURL()}/polipop.min.js`);
let ppCss1Element = $('<link> ')
.attr('id', 'ziti-browzer-ppcss')
.attr('rel', 'stylesheet')
.attr('href', `${_obtainBootStrapperURL()}/polipop.core.min.css`);
let ppCss2Element = $('<link> ')
.attr('rel', 'stylesheet')
.attr('href', `${_obtainBootStrapperURL()}/polipop.compact.min.css`);
// let otElement = $('<meta></meta> ')
// .attr('id', 'ziti-browzer-origin-trial')
// .attr('http-equiv', 'origin-trial')
// .attr('content', `${self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.runtime.originTrialToken}`);
let kcElement = $('<meta name="author" content="OpenZiti BrowZer" />')
// Locate the CSP
let cspElement = $('meta[http-equiv="content-security-policy"]');
// If we found a CSP
if (cspElement.length > 0) {
// self.logger.trace('streamingHEADReplace: CSP found in html with content: ', cspElement.attr('content'));
// then augment it to enable WASM load/xeq
let cspContent = cspElement.attr('content');
let newCspContent = self.generateNewCSP(cspContent);
cspElement.attr('content', newCspContent);
// self.logger.trace('streamingHEADReplace: CSP is now enhanced with content: ', cspElement.attr('content'));
// Inject the PP immediately after the CSP
cspElement.after(ppCss1Element);
cspElement.after(ppCss2Element);
cspElement.after(ppElement);
// cspElement.after(otElement);
// let otEl = $('meta[id="ziti-browzer-origin-trial"]');
// Inject the ZBR immediately after the origin trial meta
cspElement.after(zbrElement);
cspElement.after(cannyElement);
buffer += $.html();
}
// If we did NOT find a CSP
else {
// Locate the HEAD
let headElement = $('head');
headElement.prepend(cannyElement);
// Inject the Ziti browZer Runtime at the front of <head> element so we are prepared to intercept as soon as possible over on the browser
headElement.prepend(zbrElement);
headElement.prepend(ppElement);
headElement.prepend(ppCss2Element);
headElement.prepend(ppCss1Element);
// headElement.prepend(otElement);
buffer += $.html();
}
} else {
buffer += chunk;
}
}
catch (e) {
self.logger.error(e);
}
},
flush(controller) {
if (buffer) {
buffer = buffer.replace('document.domain', 'document.zitidomain');
self.logger.trace(`streamingHEADReplace: HTML after modifications is: ${buffer}`);
controller.enqueue(buffer);
}
}
});
}
const bodyStream = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(streamingHEADReplace())
.pipeThrough(new TextEncoderStream())
;
const newHeaders = new Headers(response.headers);
const newResponse = new Response(bodyStream, {
status: response.status,
statusText: response.statusText,
headers: newHeaders
});
response = newResponse;
}
}
}
else {
if (shouldRoute.routeOverZiti && (contentType && contentType.match( regexTextXml ))) {
if (response.body) {
function streamingXMLReplace() {
let buffer = '';
return new TransformStream({
transform(chunk, _controller) {
try {
// Parse the XML
const $ = cheerio.load(chunk, {
xmlMode: true,
})
self.logger.trace('streamingXMLReplace: XML before modifications is: ', $.html());
streamingAttrReplace($, 'RDWAPage', 'baseurl');
buffer += $.html();
}
catch (e) {
self.logger.error(e);
}
},
flush(controller) {
if (buffer) {
buffer = buffer.replace(self._targetServiceHost, self._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host);
self.logger.trace('streamingXMLReplace: XML after modifications is: ', buffer);
controller.enqueue(buffer);
}
}
});
}
const bodyStream = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(streamingXMLReplace())
.pipeThrough(new TextEncoderStream())
;
const newHeaders = new Headers(response.headers);
const newResponse = new Response(bodyStream, {
status: response.status,
statusText: response.statusText,
headers: newHeaders
});
response = newResponse;
}
}
}
if (useCache) {
await handler.waitUntil(handler.cachePut(request, response.clone()));
}
return response;
}
/**
* @param {Object} options
* @param {Request} options.request
* @param {Event} options.event
* @return {Promise<Response>}
*
* @private
*/
private _getZitiTimeoutPromise({
request,
handler,
}: {
request: Request;
handler: StrategyHandler;
}): {promise: Promise<Response | undefined>; id?: number} {
let timeoutId;
let self = this;
const timeoutPromise: Promise<Response | undefined> = new Promise(
(resolve) => {
const onNetworkTimeout = async () => {
self.logger.debug(`Timing out the network response at ${self._zitiNetworkTimeoutSeconds} seconds`);
resolve(await handler.cacheMatch(request));
};
timeoutId = setTimeout(
onNetworkTimeout,
this._zitiNetworkTimeoutSeconds * 1000,
);
},
);
return {
promise: timeoutPromise,
id: timeoutId,
};
}
/**
*
* @param zitiRequest
* @returns body|undefined
*/
async getRequestBody( zitiRequest: any ) {
var requestBlob = await zitiRequest.blob();
if (requestBlob.size > 0) {
return ( requestBlob );
}
return ( undefined );
}
/**
*
* @param headersObject
*/
dumpHeaders( headersObject: any) {
for (var pair of headersObject.entries()) {
this.logger.trace( 'dumpHeaders: ', pair[0], pair[1]);
}
}
/**
*
* @param headersObject
*/
generateCSP( headersObject: any) {
for (var pair of headersObject.entries()) {
this.logger.trace( 'dumpHeaders: ', pair[0], pair[1]);
}
}
/**
* @param {Object} options
* @param {number|undefined} options.timeoutId
* @param {string} options.zitiURL
* @param {Request} options.request
* @param {Event} options.event
* @return {Promise<Response>}
*
* @private
*/
async _getZitiNetworkPromise({
timeoutId,
shouldRoute,
request,
handler,
useCache,
}: {
timeoutId?: number;
shouldRoute: ZitiShouldRouteResult;
request: Request;
handler: StrategyHandler;
useCache: boolean;
}): Promise<Response | undefined> {
let error: any = null;
let response: Response | PromiseLike<Response | undefined> | undefined;
try {
this.logger.debug(`doing Ziti fetch for: ${request.url}`);
/**
* Instantiate a fresh HTTP Request object that we will push through the ziti-browzer-core which will:
*
* 1) contain re-routed host
* 2) have any headers we need to pile on
* 3) prepare to stream out any body data associated with the intercepted request
*/
const zitiRequest = new Request(request, {} );
var newHeaders = new Headers();
zitiRequest.headers.forEach(function (header, key) {
if (!isEqual(key, 'origin')) {
newHeaders.append( key, header );
}
});
if (!isEqual(request.referrer, '')) {
newHeaders.append( 'referer', request.referrer );
}
try {
newHeaders.append( 'Remote-User', await this._zitiContext.getAccessTokenEmail() );
} catch (e) {}
if (isEqual(request.mode,'navigate')) {
this.logger.trace(`Navigation event detected: [${request.url}]`);
newHeaders.append( 'Sec-Fetch-Mode', 'navigate' );
}
// Propagate any Cookie values we have accumulated
let cookieHeaderValue = '';
for (const cookie in this._zitiBrowzerServiceWorkerGlobalScope._cookieObject) {
if (cookie !== '' && !isEqual(cookie, '__ziti-browzer-config')) { // don't send the ZBR config cookie
if (this._zitiBrowzerServiceWorkerGlobalScope._cookieObject.hasOwnProperty(cookie)) {
cookieHeaderValue += cookie + '=' + this._zitiBrowzerServiceWorkerGlobalScope._cookieObject[cookie];
// this.logger.debug(`cookieHeaderValue: [${cookieHeaderValue}]`);
newHeaders.append( 'Cookie', cookieHeaderValue );
cookieHeaderValue = '';
}
}
}
var blob = await this.getRequestBody( zitiRequest );
let referrerUrlPathname = '/';
if (request.referrer) {
let referrerUrl = new URL(request.referrer);
referrerUrlPathname = referrerUrl.pathname;
}
var zitiResponse = await this._zitiContext.httpFetch(
shouldRoute.url, {
serviceName: shouldRoute.serviceName,
serviceScheme: shouldRoute.serviceScheme,
servicePath: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.path,
serviceConnectAppData: shouldRoute.serviceConnectAppData,
bootstrapperHost: this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host,
method: zitiRequest.method,
headers: newHeaders,
mode: zitiRequest.mode,
cache: zitiRequest.cache,
credentials: zitiRequest.credentials,
redirect: zitiRequest.redirect,
referrerPolicy: zitiRequest.referrerPolicy,
body: blob
}
);
this.logger.debug(`Got zitiResponse from [${zitiResponse.url}]`);
/**
* Now that ziti-browzer-core has returned us a ZitiResponse, instantiate a fresh native Response object that we
* will return to the Browser. This requires us to:
*
* 1) propagate the HTTP headers, status, etc
* 2) pipe the HTTP response body
*/
if (isUndefined(zitiResponse.headers.raw)) {
return zitiResponse;
}
var zitiHeaders = zitiResponse.headers.raw();
const contentType = zitiHeaders['content-type'];
let isTextHtml = false;
let isTextXml = false;
if (contentType && contentType[0] && contentType[0].match( regexTextHtml )) {
isTextHtml = true;
}
if (contentType && contentType[0] && contentType[0].match( regexTextXml )) {
isTextXml = true;
}
var headers = new Headers();
const keys = Object.keys(zitiHeaders);
for (let i = 0; i < keys.length; i++) {
let key = keys[i];
let val = zitiHeaders[key][0];
this.logger.trace(`ZitiFirstStrategy: zitiResponse.headers: [${key}] [${val}]`);
if (key.toLowerCase() === 'set-cookie') {
if (Array.isArray(val)) {
for (var ndx = 0; ndx < val.length; ndx++) {
this.logger.trace( 'ZitiFirstStrategy: sending SET_COOKIE cmd');
this._zitiBrowzerServiceWorkerGlobalScope._sendMessageToClients(
{
type: 'SET_COOKIE',
payload: val[ndx]
}
);
let parts = val[ndx].split('=');
function extractCookieVal(str: string) {
const match = str.match(/=(.*?);/);
return match ? match[1] : null;
}
const result = extractCookieVal(val[ndx]);
this._zitiBrowzerServiceWorkerGlobalScope._cookieObject[parts[0]] = result;
}
}
else {
headers.append( 'x-ziti-browzer-set-cookie', val );
this.logger.trace( 'ZitiFirstStrategy: sending SET_COOKIE cmd');
let resp = await this._zitiBrowzerServiceWorkerGlobalScope._sendMessageToClients(
{
type: 'SET_COOKIE',
payload: val
}
);
this.logger.trace( 'ZitiFirstStrategy: SET_COOKIE response: ', resp);
let parts = val.split('=');
this._zitiBrowzerServiceWorkerGlobalScope._cookieObject[parts[0]] = parts[1];
}
}
else if (key.toLowerCase() === 'location') {
this.logger.trace( `location header transform needed for: ${val}`);
function updateSignInWithGoogleRedirectUri(url: string, newValue: string, logger: any) {
let paramName = 'redirect_uri';
try {
let urlObj = new URL(url);
let searchParams = urlObj.searchParams;
// If url is doing a "Sign in with Google"
if (isEqual(urlObj.host, 'accounts.google.com') && isEqual(urlObj.pathname, '/o/oauth2/auth') && searchParams.has(paramName)) {
// Transform the redirect_uri host to be the browZer bootstrapper
let paramValue = searchParams.get(paramName);
if (paramValue) {
let redirect_uriObj = new URL(paramValue);
redirect_uriObj.host = newValue;
redirect_uriObj.port = '443';
redirect_uriObj.protocol = 'https';
searchParams.set(paramName, redirect_uriObj.toString());
}
}
logger.trace( `updateSignInWithGoogleRedirectUri(): location header transformed to: ${urlObj.toString()}`);
return urlObj.toString();
}
catch (e) {
return url;
}
}
// NOT YET
// val = updateSignInWithGoogleRedirectUri(val, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host, this.logger);
let pathname;
let skipTransform = false;
if (val.startsWith('/')) {
pathname = val;
} else {
function isValidUrl(string:string) {
try {
let url = new URL(string);
return url;
} catch (err) {
return undefined;
}
}
let locationUrl = isValidUrl( val );
if (isUndefined(locationUrl)) { // i.e. it's a relative path (no slashes)
pathname = `${referrerUrlPathname}${val}`;
} else {
if (isEqual(locationUrl.hostname, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host)) {
pathname = locationUrl.pathname + locationUrl.search;
} else {
let serviceConnectAppData = await this._zitiBrowzerServiceWorkerGlobalScope._zitiContext.getConnectAppDataByServiceName(this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.service, 'https');
if (!isUndefined(serviceConnectAppData)) {
if (isEqual(locationUrl.hostname, serviceConnectAppData.dst_hostname)) {
pathname = locationUrl.pathname + locationUrl.search;
}
else if (isEqual(locationUrl.hostname, serviceConnectAppData.dst_ip)) {
pathname = locationUrl.pathname + locationUrl.search;
}
} else {
skipTransform = true;
}
}
}
}
if (!isUndefined(pathname) && !skipTransform) {
let serviceScheme = shouldRoute.serviceScheme;
if (serviceScheme && !serviceScheme.endsWith(':')) {
serviceScheme = serviceScheme + ":";
}
let serviceName = shouldRoute.serviceName;
if (isEqual(serviceName, this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.target.service)) {
serviceName = this._zitiBrowzerServiceWorkerGlobalScope._zitiConfig.browzer.bootstrapper.self.host;
}
let newLocationUrl = new URL(`${serviceScheme}//${serviceName}${pathname}`);
val = newLocationUrl.toString();
this.logger.trace( `location header transformed to: ${val}`);
} else {
this.logger.trace( `location header transform bypassed for: ${val}`);
}
}
else if (key.toLowerCase() === 'content-security-policy') {
val = this.generateNewCSP(val);
}
headers.append( key, val);
}
headers.append( 'x-ziti-browzer-sw-workbox-strategies-version', pjson.version );
if ( (!isEqual(zitiResponse.status, 204)) && (zitiResponse.status < 300 || zitiResponse.status > 399) ) {
if (isTextHtml) {
var responseBlob = await zitiResponse.blob();
var responseBlobStream = responseBlob.stream();
const responseStream = new ReadableStream({
start(controller) {
function push() {
var chunk = responseBlobStream.read();
if (chunk) {
controller.enqueue(chunk);
push();
} else {
controller.close();
return;
}
};
push();
}
});
response = new Response( responseStream, { "status": zitiResponse.status, "headers": headers } );
} else if ( isTextXml ) {
var responseBlob = await zitiResponse.blob();
var responseBlobStream = responseBlob.stream();
const responseStream = new ReadableStream({
start(controller) {
function push() {
var chunk = responseBlobStream.read();
if (chunk) {
controller.enqueue(chunk);
push();
} else {
controller.close();
return;
}
};
push();
}
});
response = new Response( responseStream, { "status": zitiResponse.status, "headers": headers } );
} else {
const responseStream = new ReadableStream({
start(controller) {
function push(chunk: any) {
try {
if (chunk) {
controller.enqueue(chunk);
} else {
// if (!isClosed) {
// isClosed = true;
controller.close();
// }
return;
}
} catch (e) {}
};
zitiResponse.body.on('data', (chunk: any) => {
push(chunk);
});
zitiResponse.body.on('end', () => {
push(null);
});
}
});
response = new Response( responseStream, { "status": zitiResponse.status, "headers": headers } );
}
} else {
response = new Response( null, { "status": zitiResponse.status, "headers": headers } );
}
this.logger.trace(`ZitiFirstStrategy: formed native response for [${zitiResponse.url}]`);
} catch (fetchError) {
this.logger.error(`Got error: `, fetchError);
if (fetchError instanceof Error) {
error = fetchError;
}
}
if (timeoutId) {
clearTimeout(timeoutId);
}
if (response) {
this.logger.debug(`Got response from Ziti network.`);
} else {
if (useCache) {
this.logger.warn(
`Unable to get a response from Ziti network. Will respond ` +
`with a cached response.`,
);
}
}
if ((error || !response) && useCache) {
response = await handler.cacheMatch(request);
if (response) {
this.logger.debug(`Found a cached response`);
}
}
return response;
}
/**
* @param {Object} options
* @param {number|undefined} options.timeoutId
* @param {Request} options.request
* @param {Event} options.event
* @return {Promise<Response>}
*
* @private
*/
async _getNetworkPromise({
timeoutId,
request,
handler,
useCache,
}: {
request: Request;
timeoutId?: number;
handler: StrategyHandler;
useCache: boolean;
}): Promise<Response | undefined> {
let error;
let response;
try {
// if (useCache) {
// this.logger.debug(`doing raw internet fetchAndCachePut for: `, request.url);
// response = await handler.fetchAndCachePut(request);
// } else {
this.logger.debug(`doing raw internet fetch for [${request.url}]`);
response = await handler.fetch(request);
// }
this.logger.debug(`Got raw internet response from [${request.url}]`);
} catch (fetchError) {
this.logger.error(`Got error: [${fetchError}]`);
if (fetchError instanceof Error) {
error = fetchError;
}
}
if (timeoutId) {
clearTimeout(timeoutId);
}
if (response) {
this.logger.debug(`Got response from network.`);
/**
* If we get a failed respose that is also an 'opaqueredirect', it is most likely
* because the attempt to fetch something served by the HTTP Agent is being canceled,
* then redirected to the IdP (re)authentication URL.
*
* HACK ALERT:
* For reaons currently unknown, if we do NOT unregister the SW, before letting
* the OIDC middleware continue, it will fail...
* So, we force an unregister of the SW here to keep the (re)authentication flow working.
*/
if (!response.ok && ( response.type === 'opaqueredirect' ) ) {
this.logger.debug(`Got 'opaqueredirect' response from network; doing SW unregister now`);
await this._zitiBrowzerServiceWorkerGlobalScope._unregister();
}
} else {
if (useCache) {
this.logger.warn(
`Unable to get a response from the network. Will respond ` +
`with a cached response.`,
);
}
}
if ((error || !response) && useCache) {
response = await handler.cacheMatch(request);
if (response) {
this.logger.debug(`Found a cached response in the '${this.cacheName}'` + ` cache.`);
}
}
return response;
}
}
export {ZitiFirstStrategy};