diff --git a/agent/main/lib/Agent.ts b/agent/main/lib/Agent.ts index aea163375..f5a9484de 100644 --- a/agent/main/lib/Agent.ts +++ b/agent/main/lib/Agent.ts @@ -10,7 +10,10 @@ import { IHooksProvider } from '@ulixee/unblocked-specification/agent/hooks/IHoo import IEmulationProfile, { IEmulationOptions, } from '@ulixee/unblocked-specification/plugin/IEmulationProfile'; -import { IUnblockedPluginClass, PluginConfigs } from '@ulixee/unblocked-specification/plugin/IUnblockedPlugin'; +import { + IUnblockedPluginClass, + PluginConfigs, +} from '@ulixee/unblocked-specification/plugin/IUnblockedPlugin'; import { nanoid } from 'nanoid'; import env from '../env'; import ICommandMarker from '../interfaces/ICommandMarker'; @@ -52,6 +55,10 @@ export default class Agent extends TypedEventEmitter<{ close: void }> { private readonly closeBrowserOnClose: boolean = false; private isolatedMitm: MitmProxy; + // We use secretKey all through Agent components to make sure websites can't test if hero is present. + // Without this secretKey if would be pretty easy to detect hero. + private secretKey = nanoid(); + private get proxyConnectionInfo(): IProxyConnectionOptions { if (!this.enableMitm) { if (!this.emulationProfile.upstreamProxyUrl) return null; @@ -209,6 +216,7 @@ export default class Agent extends TypedEventEmitter<{ close: void }> { hooks: this.plugins, isIncognito: this.isIncognito, commandMarker: this.options.commandMarker, + secretKey: this.secretKey, }); this.events.once(this.browserContext, 'close', () => this.close()); diff --git a/agent/main/lib/Browser.ts b/agent/main/lib/Browser.ts index 19f0fb516..8036ca3d0 100755 --- a/agent/main/lib/Browser.ts +++ b/agent/main/lib/Browser.ts @@ -368,7 +368,7 @@ export default class Browser extends TypedEventEmitter implement if (options.proxyPort !== undefined && !launchArgs.some(x => x.startsWith('--proxy-server'))) { launchArgs.push( // Use proxy for localhost URLs - '--proxy-bypass-list=<-loopback>;websocket.localhost', + '--proxy-bypass-list=<-loopback>', `--proxy-server=localhost:${options.proxyPort}`, ); } @@ -461,7 +461,6 @@ export default class Browser extends TypedEventEmitter implement }); } const context = new BrowserContext(this, false); - void context.initialize(); context.hooks = this.browserContextCreationHooks ?? {}; context.id = targetInfo.browserContextId; context.targetsById.set(targetInfo.targetId, targetInfo); @@ -573,7 +572,6 @@ export default class Browser extends TypedEventEmitter implement private async onNewContext(context: BrowserContext): Promise { const id = context.id; this.browserContextsById.set(id, context); - await context.initialize(); context.once('close', () => this.browserContextsById.delete(id)); this.emit('new-context', { context }); await this.hooks?.onNewBrowserContext?.(context); diff --git a/agent/main/lib/BrowserContext.ts b/agent/main/lib/BrowserContext.ts index 74571fcf1..c139383e2 100644 --- a/agent/main/lib/BrowserContext.ts +++ b/agent/main/lib/BrowserContext.ts @@ -28,7 +28,6 @@ import WebsocketMessages from './WebsocketMessages'; import { DefaultCommandMarker } from './DefaultCommandMarker'; import DevtoolsSessionLogger from './DevtoolsSessionLogger'; import FrameOutOfProcess from './FrameOutOfProcess'; -import { WebsocketSession } from './WebsocketSession'; import CookieParam = Protocol.Network.CookieParam; import TargetInfo = Protocol.Target.TargetInfo; import CreateBrowserContextRequest = Protocol.Target.CreateBrowserContextRequest; @@ -41,6 +40,7 @@ export interface IBrowserContextCreateOptions { hooks?: IBrowserContextHooks & IInteractHooks; isIncognito?: boolean; commandMarker?: ICommandMarker; + secretKey?: string, } export default class BrowserContext @@ -69,7 +69,6 @@ export default class BrowserContext } public isIncognito = true; - public readonly websocketSession: WebsocketSession; public readonly idTracker = { navigationId: 0, @@ -77,6 +76,7 @@ export default class BrowserContext frameId: 0, }; + public secretKey?: string public commandMarker: ICommandMarker; private attachedTargetIds = new Set(); @@ -96,6 +96,7 @@ export default class BrowserContext this.isIncognito = isIncognito; this.logger = options?.logger ?? log; this.hooks = options?.hooks ?? {}; + this.secretKey = options?.secretKey; this.commandMarker = options?.commandMarker ?? new DefaultCommandMarker(this); this.resources = new Resources(this); this.websocketMessages = new WebsocketMessages(this.logger); @@ -104,11 +105,6 @@ export default class BrowserContext this.devtoolsSessionLogger.subscribeToDevtoolsMessages(this.browser.devtoolsSession, { sessionType: 'browser', }); - this.websocketSession = new WebsocketSession(); - } - - public async initialize(): Promise { - await this.websocketSession.initialize(); } public async open(): Promise { @@ -118,7 +114,7 @@ export default class BrowserContext disposeOnDetach: true, }; if (this.proxy?.address) { - createContextOptions.proxyBypassList = '<-loopback>;websocket.localhost'; + createContextOptions.proxyBypassList = '<-loopback>'; createContextOptions.proxyServer = this.proxy.address; } @@ -351,7 +347,6 @@ export default class BrowserContext this.resources.cleanup(); this.events.close(); this.emit('close'); - this.websocketSession.close(); this.devtoolsSessionLogger.close(); this.removeAllListeners(); this.cleanup(); diff --git a/agent/main/lib/Console.ts b/agent/main/lib/Console.ts new file mode 100644 index 000000000..717473c93 --- /dev/null +++ b/agent/main/lib/Console.ts @@ -0,0 +1,133 @@ +// Currently this only used to support communication from chrome (injected scripts) to unblocked agent +import Resolvable from '@ulixee/commons/lib/Resolvable'; +import TypedEventEmitter from '@ulixee/commons/lib/TypedEventEmitter'; +import DevtoolsSession from './DevtoolsSession'; +import EventSubscriber from '@ulixee/commons/lib/EventSubscriber'; +import Protocol from 'devtools-protocol'; +import { IConsoleEvents } from '@ulixee/unblocked-specification/agent/browser/IConsole'; + +const SCRIPT_PLACEHOLDER = ''; + +export class Console extends TypedEventEmitter { + isReady: Resolvable; + + private readonly events = new EventSubscriber(); + // We store resolvable when we received websocket message before, receiving + // targetId, this way we can await this, and still trigger to get proper ids. + private clientIdToTargetId = new Map | string>(); + + constructor( + public devtoolsSession: DevtoolsSession, + public secretKey: string, + ) { + super(); + } + + async initialize(): Promise { + if (this.isReady) return this.isReady.promise; + this.isReady = new Resolvable(); + + await this.devtoolsSession.send('Console.enable'); + this.events.on( + this.devtoolsSession, + 'Console.messageAdded', + this.handleConsoleMessage.bind(this), + ); + + this.isReady.resolve(); + return this.isReady.promise; + } + + isConsoleRegisterUrl(url: string): boolean { + return url.includes( + `hero.localhost/?secretKey=${this.secretKey}&action=registerConsoleClientId&clientId=`, + ); + } + + registerFrameId(url: string, frameId: string): void { + const parsed = new URL(url); + const clientId = parsed.searchParams.get('clientId'); + if (!clientId) return; + + const targetId = this.clientIdToTargetId.get(clientId); + if (targetId instanceof Resolvable) { + targetId.resolve(frameId); + } + this.clientIdToTargetId.set(clientId, frameId); + } + + injectCallbackIntoScript(script: string): string { + // We could do this as a simple template script but this logic might get + // complex over time and we want typescript to be able to check proxyScript(); + const scriptFn = injectedScript + .toString() + // eslint-disable-next-line no-template-curly-in-string + .replaceAll('${this.secretKey}', this.secretKey) + // Use function otherwise replace will try todo some magic + .replace('SCRIPT_PLACEHOLDER', () => script); + + const wsScript = `(${scriptFn})();`; + return wsScript; + } + + private async handleConsoleMessage(msgAdded: Protocol.Console.MessageAddedEvent): Promise { + if (msgAdded.message.source !== 'console-api' || msgAdded.message.level !== 'debug') return; + + let clientId: string; + let name: string; + let payload: any; + + try { + // Doing this is much much cheaper than json parse on everything logged in console debug + const text = msgAdded.message.text; + const [secret, maybeClientId, serializedData] = [ + text.slice(6, 27), + text.slice(29, 39), + text.slice(41), + ]; + if (secret !== this.secretKey) return; + + const data = JSON.parse(serializedData); + name = data.name; + payload = data.payload; + clientId = maybeClientId; + } catch { + return; + } + + let frameId = this.clientIdToTargetId.get(clientId); + if (!frameId) { + const resolvable = new Resolvable(); + this.clientIdToTargetId.set(clientId, resolvable); + frameId = await resolvable.promise; + } else if (frameId instanceof Resolvable) { + frameId = await frameId.promise; + } + + this.emit('callback-received', { id: frameId, name, payload }); + } +} + +/** This function will be stringified and inserted as a wrapper script so all injected + * scripts have access to a callback function (over a websocket). This function takes + * care of setting up that websocket and all other logic it needs as glue to make it all work. + * */ +function injectedScript(): void { + const clientId = Math.random().toString().slice(2, 12); + + const url = `http://hero.localhost/?secretKey=${this.secretKey}&action=registerConsoleClientId&clientId=${clientId}`; + + // This will signal to network manager we are trying to make websocket connection + // This is needed later to map clientId to frameId + void fetch(url, { mode: 'no-cors' }).catch(() => undefined); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const callback = (name, payload): void => { + const serializedData = JSON.stringify({ name, payload }); + // eslint-disable-next-line no-console + console.debug(`hero: ${this.secretKey}, ${clientId}, ${serializedData}`); + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + SCRIPT_PLACEHOLDER; +} diff --git a/agent/main/lib/DevtoolsSessionLogger.ts b/agent/main/lib/DevtoolsSessionLogger.ts index 123ddd7c0..bdd247831 100644 --- a/agent/main/lib/DevtoolsSessionLogger.ts +++ b/agent/main/lib/DevtoolsSessionLogger.ts @@ -141,6 +141,12 @@ export default class DevtoolsSessionLogger extends TypedEventEmitter(); private readonly events = new EventSubscriber(); private readonly networkManager: NetworkManager; private readonly domStorageTracker: DomStorageTracker; private pageCallbacks = new Map(); + private console: Console; + private isReady: Promise; constructor(page: Page, devtoolsSession: DevtoolsSession) { @@ -85,16 +84,21 @@ export default class FramesManager extends TypedEventEmitter { + const url = event.request.request.url; + if (this.console.isConsoleRegisterUrl(url)) { + this.console.registerFrameId(url, event.request.frameId); + } + }); } public initialize(devtoolsSession: DevtoolsSession): Promise { @@ -153,6 +157,7 @@ export default class FramesManager extends TypedEventEmitter { devtoolsSession ??= this.devtoolsSession; if (callbacks) { - script = this.websocketSession.injectWebsocketCallbackIntoScript(script); + script = this.console.injectCallbackIntoScript(script); for (const [name, onCallbackFn] of Object.entries(callbacks)) { if (onCallbackFn) { if (this.pageCallbacks.has(name) && this.pageCallbacks.get(name) !== onCallbackFn) @@ -691,8 +696,8 @@ export default class FramesManager extends TypedEventEmitter { const callback = this.pageCallbacks.get(event.name); let frame = this.framesById.get(event.id); diff --git a/agent/main/lib/NetworkManager.ts b/agent/main/lib/NetworkManager.ts index ad2ccd123..034e98387 100755 --- a/agent/main/lib/NetworkManager.ts +++ b/agent/main/lib/NetworkManager.ts @@ -25,7 +25,6 @@ import LoadingFailedEvent = Protocol.Network.LoadingFailedEvent; import RequestServedFromCacheEvent = Protocol.Network.RequestServedFromCacheEvent; import RequestWillBeSentExtraInfoEvent = Protocol.Network.RequestWillBeSentExtraInfoEvent; import IProxyConnectionOptions from '../interfaces/IProxyConnectionOptions'; -import { WebsocketSession } from './WebsocketSession'; interface IResourcePublishing { hasRequestWillBeSentEvent: boolean; @@ -37,7 +36,6 @@ interface IResourcePublishing { const mbBytes = 1028 * 1028; export default class NetworkManager extends TypedEventEmitter { - public readonly websocketSession: WebsocketSession; protected readonly logger: IBoundLog; private readonly devtools: DevtoolsSession; private readonly attemptedAuthentications = new Set(); @@ -62,13 +60,12 @@ export default class NetworkManager extends TypedEventEmitter 0) this.isChromeRetainingResources = true; + const patternsToIntercepts: Fetch.RequestPattern[] = [ + { urlPattern: 'http://hero.localhost/*' }, + ]; + if (this.proxyConnectionOptions?.password) { + // Pattern needs to match website url (not proxy url), so wildcard is only option we really have here + patternsToIntercepts.push({ urlPattern: '*' }); + } + const errors = await Promise.all([ this.devtools .send('Network.enable', { @@ -125,13 +130,12 @@ export default class NetworkManager extends TypedEventEmitter err), - this.proxyConnectionOptions?.password - ? this.devtools - .send('Fetch.enable', { - handleAuthRequests: true, - }) - .catch(err => err) - : Promise.resolve(), + this.devtools + .send('Fetch.enable', { + handleAuthRequests: !!this.proxyConnectionOptions?.password, + patterns: patternsToIntercepts, + }) + .catch(err => err), // this.devtools.send('Security.setIgnoreCertificateErrors', { ignore: true }), ]); for (const error of errors) { @@ -192,11 +196,11 @@ export default class NetworkManager extends TypedEventEmitter implements this.mouse = new Mouse(devtoolsSession, this.keyboard); this.networkManager = new NetworkManager( devtoolsSession, - this.browserContext.websocketSession, this.logger, this.browserContext.proxy, + this.browserContext.secretKey, ); this.domStorageTracker = new DomStorageTracker( this, diff --git a/agent/main/lib/WebsocketSession.ts b/agent/main/lib/WebsocketSession.ts deleted file mode 100644 index 20966bb30..000000000 --- a/agent/main/lib/WebsocketSession.ts +++ /dev/null @@ -1,213 +0,0 @@ -import Log from '@ulixee/commons/lib/Logger'; -import Resolvable from '@ulixee/commons/lib/Resolvable'; -import TypedEventEmitter from '@ulixee/commons/lib/TypedEventEmitter'; -import { - IWebsocketEvents, - WebsocketCallback, -} from '@ulixee/unblocked-specification/agent/browser/IWebsocketSession'; -import { IncomingMessage, createServer } from 'http'; -import { Socket, Server } from 'net'; -import { Server as WebsocketServer, type createWebSocketStream } from 'ws'; - -// Not sure where to import this from -type Websocket = Parameters[0]; - -const SCRIPT_PLACEHOLDER = ''; -const { log } = Log(module); - -export class WebsocketSession extends TypedEventEmitter { - isReady: Resolvable; - - private readonly host = 'websocket.localhost'; - private port: number; - private readonly secret = Math.random().toString(); - - // We store resolvable when we received websocket message before, receiving - // targetId, this way we can await this, and still trigger to get proper ids. - private clientIdToTargetId = new Map | string>(); - - private server: Server; - private wss: WebsocketServer; - private intervals = new Set(); - - constructor() { - super(); - this.server = createServer(); - this.wss = new WebsocketServer({ noServer: true }); - } - - async initialize(): Promise { - if (this.isReady) return this.isReady.promise; - this.isReady = new Resolvable(); - - this.server.on('error', this.isReady.reject); - this.server.listen(0, () => { - const address = this.server.address(); - if (typeof address === 'string') { - throw new Error('Unexpected server address format (string)'); - } - this.port = address.port; - this.isReady.resolve(); - }); - - this.server.on('upgrade', this.handleUpgrade.bind(this)); - this.wss.on('connection', this.handleConnection.bind(this)); - - return this.isReady.promise; - } - - close(): void { - this.wss.close(); - this.server.unref().close(); - this.intervals.forEach(interval => clearInterval(interval)); - } - - isWebsocketUrl(url: string): boolean { - try { - const parsed = new URL(url); - return ( - parsed.hostname === this.host && - parsed.port === this.port.toString() && - parsed.searchParams.get('secret') === this.secret - ); - } catch { - return false; - } - } - - registerWebsocketFrameId(url: string, frameId: string): void { - const parsed = new URL(url); - if (parsed.searchParams.get('secret') !== this.secret) return; - const clientId = parsed.searchParams.get('clientId'); - if (!clientId) return; - - const targetId = this.clientIdToTargetId.get(clientId); - if (targetId instanceof Resolvable) { - targetId.resolve(frameId); - } - this.clientIdToTargetId.set(clientId, frameId); - } - - injectWebsocketCallbackIntoScript(script: string): string { - // We could do this as a simple template script but this logic might get - // complex over time and we want typescript to be able to check proxyScript(); - const scriptFn = injectedScript - .toString() - // eslint-disable-next-line no-template-curly-in-string - .replaceAll('${this.host}', this.host) - // eslint-disable-next-line no-template-curly-in-string - .replaceAll('${this.port}', this.port.toString()) - // eslint-disable-next-line no-template-curly-in-string - .replaceAll('${this.secret}', this.secret) - // Use function otherwise replace will try todo some magic - .replace('SCRIPT_PLACEHOLDER', () => script); - - const wsScript = `(${scriptFn})();`; - return wsScript; - } - - private handleUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void { - const url = new URL(request.url, `ws://${this.host}`); - // Close and dont send 403 so this acts as an invisible websocket server - if (url.searchParams.get('secret') !== this.secret) { - socket.destroy(); - } - - const clientId = url.searchParams.get('clientId'); - this.wss.handleUpgrade(request, socket as Socket, head, ws => { - this.wss.emit('connection', ws, request, clientId); - }); - } - - private handleConnection(ws: Websocket, request: IncomingMessage, clientId: string): void { - ws.on('error', error => log.error('WebsocketSession.ConnectionError', { error })); - ws.on('message', this.handleMessage.bind(this, clientId)); - - let isAlive = true; - ws.on('pong', () => { - isAlive = true; - }); - - const interval = setInterval(() => { - if (isAlive) { - isAlive = false; - return ws.ping(); - } - - this.clientIdToTargetId.delete(clientId); - ws.terminate(); - clearInterval(interval); - this.intervals.delete(interval); - }, 30e3).unref(); - - this.intervals.add(interval); - } - - private async handleMessage(clientId: string, data: Buffer): Promise { - const { name, payload } = JSON.parse(data.toString()); - let frameId = this.clientIdToTargetId.get(clientId); - if (!frameId) { - const resolvable = new Resolvable(); - this.clientIdToTargetId.set(clientId, resolvable); - frameId = await resolvable.promise; - } else if (frameId instanceof Resolvable) { - frameId = await frameId.promise; - } - - this.emit('message-received', { id: frameId, name, payload }); - } -} - -/** This function will be stringified and inserted as a wrapper script so all injected - * scripts have access to a callback function (over a websocket). This function takes - * care of setting up that websocket and all other logic it needs as glue to make it all work. - * */ -function injectedScript(): void { - const clientId = Math.random(); - const url = `${this.host}:${this.port}?secret=${this.secret}&clientId=${clientId}`; - // This will signal to network manager we are trying to make websocket connection - // This is needed later to map clientId to frameId - fetch(`http://${url}`).catch(() => {}); - - let callback: WebsocketCallback; - try { - const socket = new WebSocket(`ws://${url}`); - let isReady = false; - const queuedCallbacks: { name: string; payload: string }[] = []; - - const sendOverSocket = (name: string, payload: string): void => { - try { - socket.send(JSON.stringify({ name, payload })); - } catch (error) { - // eslint-disable-next-line no-console - console.log(`failed to send over websocket: ${error}`); - } - }; - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - callback = (name, payload): void => { - if (!isReady) { - queuedCallbacks.push({ name, payload }); - return; - } - sendOverSocket(name, payload); - }; - - socket.addEventListener('open', _event => { - let queuedCallback = queuedCallbacks.shift(); - while (queuedCallback) { - sendOverSocket(queuedCallback.name, queuedCallback.payload); - queuedCallback = queuedCallbacks.shift(); - } - // Only ready when all older messages have been send so we - // keep original order of messages. - isReady = true; - }); - } catch (error) { - // eslint-disable-next-line no-console - console.log(`failed to use websocket: ${error}`); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - SCRIPT_PLACEHOLDER; -} diff --git a/agent/main/lib/Worker.ts b/agent/main/lib/Worker.ts index 814450faf..82f469006 100644 --- a/agent/main/lib/Worker.ts +++ b/agent/main/lib/Worker.ts @@ -59,9 +59,9 @@ export class Worker extends TypedEventEmitter implements IWorker }); this.networkManager = new NetworkManager( devtoolsSession, - this.browserContext.websocketSession, this.logger, browserContext.proxy, + browserContext.secretKey, ); const session = this.devtoolsSession; this.events.on(session, 'Inspector.targetReloadedAfterCrash', () => { diff --git a/agent/main/test/mitm.test.ts b/agent/main/test/mitm.test.ts index 6b0e51847..6d992655a 100644 --- a/agent/main/test/mitm.test.ts +++ b/agent/main/test/mitm.test.ts @@ -37,7 +37,9 @@ function createCallResultsWithoutNonApplicableCalls() { return mocks.MitmRequestContext.create.mock.results.filter( result => // Favicon might or might not be called depending on OS, version and url - !result.value.url.href.includes('favicon'), + !result.value.url.href.includes('favicon') && + // Not interesting in internal traffic here + !result.value.url.href.includes('heroInternalUrl'), ); } diff --git a/specification/agent/browser/IBrowserNetworkEvents.ts b/specification/agent/browser/IBrowserNetworkEvents.ts index adb3c551e..91c61bdb5 100644 --- a/specification/agent/browser/IBrowserNetworkEvents.ts +++ b/specification/agent/browser/IBrowserNetworkEvents.ts @@ -1,4 +1,6 @@ import IHttpResourceLoadDetails from '../net/IHttpResourceLoadDetails'; +import Protocol from 'devtools-protocol'; +import RequestWillBeSentEvent = Protocol.Network.RequestWillBeSentEvent; export declare type IBrowserResourceRequest = Omit< IHttpResourceLoadDetails, @@ -51,4 +53,8 @@ export interface IBrowserNetworkEvents { 'resource-failed': { resource: IBrowserResourceRequest; }; + // Special internal network request, used to communicate internal data + 'internal-request': { + request: RequestWillBeSentEvent; + }; } diff --git a/specification/agent/browser/IConsole.ts b/specification/agent/browser/IConsole.ts new file mode 100644 index 000000000..152bf776a --- /dev/null +++ b/specification/agent/browser/IConsole.ts @@ -0,0 +1,5 @@ +export interface IConsoleEvents { + 'callback-received': { id: string; name: string; payload: string }; +} + +export type ConsoleCallback = (name: string, payload: string) => void; diff --git a/specification/agent/browser/IWebsocketSession.ts b/specification/agent/browser/IWebsocketSession.ts deleted file mode 100644 index bac1b46a7..000000000 --- a/specification/agent/browser/IWebsocketSession.ts +++ /dev/null @@ -1,6 +0,0 @@ -// TODO rename/extend this for future use cases -export interface IWebsocketEvents { - 'message-received': { id: string; name: string; payload: string }; -} - -export type WebsocketCallback = (name: string, payload: string) => void; diff --git a/timetravel/lib/MirrorNetwork.ts b/timetravel/lib/MirrorNetwork.ts index d2001c7cf..a78319242 100644 --- a/timetravel/lib/MirrorNetwork.ts +++ b/timetravel/lib/MirrorNetwork.ts @@ -67,7 +67,7 @@ export default class MirrorNetwork { responseCode: 200, responseHeaders: [ { name: 'Content-Type', value: 'text/html; charset=utf-8' }, - { name: 'Content-Security-Policy', value: "script-src 'nonce-hero-timetravel'; connect-src 'self' ws://websocket.localhost:* http://websocket.localhost:*" }, + { name: 'Content-Security-Policy', value: "script-src 'nonce-hero-timetravel'; connect-src 'self'" }, { name: 'Access-Control-Allow-Origin', value: '*' }, ], body: Buffer.from(`${doctype}`).toString('base64'),