UNPKG

remoteview

Version:

Remote isolated browser platform by Dosyago

481 lines (430 loc) 16.1 kB
import express from 'express'; import http from 'http'; import https from 'https'; import fetch from 'node-fetch'; import multer from 'multer'; import WebSocket from 'ws'; import fs from 'fs'; import os from 'os'; import path from 'path'; import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import csrf from 'csurf'; import {pluginsDemoPage} from './public/plugins/demo/page.js'; import zl from './zombie-lord/api.js'; import {start_mode} from './args.js'; import {version, APP_ROOT, BRANCH, COOKIENAME, GO_SECURE, DEBUG} from './common.js'; import {timedSend, eventSendLoop} from './server.js'; export const fileChoosers = new Map(); const protocol = GO_SECURE ? https : http; const COOKIE_OPTS = { secure: GO_SECURE, httpOnly: true, maxAge: 345600000, sameSite: 'Strict' }; const uploadPath = path.resolve(os.homedir(), 'uploads'); if ( ! fs.existsSync(uploadPath) ) { fs.mkdirSync(uploadPath, {recursive:true}); } const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadPath), filename: (req, file, cb) => { return cb(null, nextFileName(path.extname(file.originalname))) } }); const upload = multer({storage}); const Queue = { funcs: [] }; // rate limiter const RateLimiter = rateLimit({ windowMs: 1000 * 60 * 3, max: DEBUG.mode == 'dev' ? 1000 : 50 }); export let LatestCSRFToken = ''; let messageQueueRunning = false; let browserTargetId; let requestId = 0; //let lastTS = Date.now(); export async function start_ws_server( port, zombie_port, allowed_user_cookie, session_token, ) { DEBUG.val && console.log(`Starting websocket server on ${port}`); const app = express(); const server_port = port; // determine if we use secure let latestMessageId = 0; const sslBranch = BRANCH == 'master' ? 'master' : 'staging'; const secure_options = {}; try { const sec = { cert: fs.readFileSync(`./sslcert/${sslBranch}/fullchain.pem`), key: fs.readFileSync(`./sslcert/${sslBranch}/privkey.pem`), ca: fs.readFileSync(`./sslcert/${sslBranch}/chain.pem`), }; Object.assign(secure_options, sec); } catch(e) { console.warn(`No certs found so will use insecure no SSL.`); } const secure = secure_options.cert && secure_options.ca && secure_options.key || false; // set up express app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], imgSrc: [ "'self'", "data:" ], mediaSrc: [ "'self'", "http://*:*", "https://*:*" ], connectSrc: [ "'self'", "wss://isolation.site:*", "wss://demo.browsergap.dosyago.com:*" ], styleSrc: [ "'self'", "'unsafe-inline'" ], scriptSrc: [ "'self'", "'unsafe-eval'", "'sha256-ktnwD9kIpbxpOmbTg7NUsKRlpicCv8bryYhIbiRDFaQ='", ], objectSrc: ["'none'"], ...(secure ? {updgradeInsecureRequests: []} : {}) }, reportOnly: false, }, })); app.use(RateLimiter); app.use(bodyParser.urlencoded({extended:true})); app.use(bodyParser.json()); app.use(cookieParser()); app.use(upload.array("files", 10)); app.use(csrf({cookie:true})); app.use((req, res, next) => { LatestCSRFToken = req.csrfToken(); next(); }); if ( start_mode == "signup" ) { app.get("/", (req,res) => res.sendFile(path.join(APP_ROOT, 'public', 'index.html'))); } else { if ( DEBUG.mode == 'dev' ) { app.get("/", (req,res) => res.sendFile(path.join(APP_ROOT, 'public', 'image.html'))); } else { app.get("/", (req,res) => res.sendFile(path.join(APP_ROOT, 'public', 'bundle.html'))); } app.get("/login", (req,res) => { const {token,ran,url:Url} = req.query; if ( token == session_token ) { res.cookie(COOKIENAME+port, allowed_user_cookie, COOKIE_OPTS); let url; url = `/?ran=${ran||Math.random()}#${session_token}`; if ( !! Url ) { url = `/?url=${encodeURIComponent(Url)}&ran=${ran||Math.random()}#${session_token}`; } res.redirect(url); } else { res.type("html"); if ( session_token == 'token2' ) { res.end(`Incorrect token, not token2. <a href=/login?token=token2>Try again.</a>`); } else { res.end(`Incorrect token. <a href=${secure ? 'https' : 'http'}://${req.hostname}/>Try again.</a>`); } } }); } app.use(express.static(path.resolve(APP_ROOT,'public'))); app.post('/current/:current/event/:event', wrap(async (req, res) => { const actualUri = 'https://' + req.headers.host + ':8001' + req.url; const resp = await fetch(actualUri, {method: 'POST', body: JSON.stringify(req.body), headers: { 'Content-Type': 'application/json' }}).then(r => r.text()); res.end(pluginsDemoPage({body:resp})); })); const server = protocol.createServer.apply(protocol, GO_SECURE && secure ? [secure_options, app] : [app]); const wss = new WebSocket.Server({server}); wss.on('connection', (ws, req) => { const qp = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) .split('&') .filter(p => p.includes("session_token"))[0] .split('=')[1] : undefined; const cookie = req.headers.cookie; const IP = req.connection.remoteAddress; let closed = false; DEBUG.val && console.log({wsConnected:{cookie, qp, IP}}); zl.act.saveIP(IP); const validAuth = DEBUG.dev || allowed_user_cookie == 'cookie' || (cookie && cookie.includes(`${COOKIENAME+port}=${allowed_user_cookie}`)) || (qp && qp == session_token); if( validAuth ) { zl.life.onDeath(zombie_port, () => { console.info("Zombie/chrome closed or crashed."); //console.log("Closing as zombie crashed."); //ws.close(); }); ws.on('close', () => { closed = true; }); ws.on('message', message => { const func = async () => { const Data = [], Frames = [], Meta = [], State = {}; message = JSON.parse(message); const {zombie, tabs, messageId} = message; latestMessageId = messageId; try { if ( zombie ) { const {events} = zombie; let {receivesFrames} = zombie; if ( receivesFrames ) { // switch it on in DEBUG and save it on the websocket for all future events DEBUG.noShot = false; ws.receivesFrames = receivesFrames; } else { receivesFrames = ws.receivesFrames; } DEBUG.val && console.log(`Starting ${events.length} events for message ${messageId}`); await eventSendLoop(events, {Data, Frames, Meta, State, receivesFrames}); DEBUG.val && console.log(`Ending ${events.length} events for message ${messageId}\n`); const {totalBandwidth} = State; so(ws,{messageId, data:Data, frameBuffer:Frames, meta:Meta, totalBandwidth}); } else if ( tabs ) { let {data:{targetInfos:targets}} = await timedSend({ name: "Target.getTargets", params: {}, }, zombie_port); browserTargetId = browserTargetId ||zl.act.getBrowserTargetId(zombie_port); targets = targets.filter(({targetId,type}) => type == 'page' && zl.act.hasSession(targetId, zombie_port)); const activeTarget = zl.act.getActiveTarget(zombie_port); zl.act.addTargets(targets, zombie_port); so(ws,{messageId, activeTarget, tabs:targets}); } else { console.warn(JSON.stringify({unknownMessage:message})); } } catch(e) { // errors are not always pushed up this far // but when they are we can alert the client so(ws,{messageId, data:[ { error: "Failed to communicate with cloud browser", resetRequired: true } ], frameBuffer:[], meta:[], totalBandwidth: 0}); } }; Queue.funcs.push(func); if ( ! messageQueueRunning ) { runMessageQueue(); } }); // why the below? // for cases where we still want to alert the client // to an error we don't push all the way // (because say, the send() method that results in the error // has no corresponding result that it is expected to tell the client about // e.g. // await send("Command.name", params); // errors from here will not go to client zl.act.setClientErrorSender(err => { so(ws,{messageId:latestMessageId, data:[ { error: err, resetRequired: true } ], frameBuffer:[], meta:[], totalBandwidth: 0}); }, zombie_port); } else { const hadSession = !! cookie && cookie.includes(COOKIENAME+port); const msg = hadSession ? `Your session has expired. Please log back in.` : `No session detected, have you signed up yet?`; const error = {msg, willCloseSocket: true, hadSession}; so(ws, {messageId:1,data:[{error}]}); console.log("Closing as not authorized.", cookie, hadSession, msg); ws.close(); } function so(socket, message) { if ( closed ) return; if ( !message ) return; message.timestamp = Date.now(); //message.tgap = message.timestamp - lastTS; //lastTS = message.timestamp; if ( typeof message == "string" || Array.isArray(message) ){ message; } else { message = JSON.stringify(message); } try { socket.send(message); } catch(e) { console.warn(`Websocket error with sending message`, e, message); } } }); server.listen(server_port, async err => { if ( err ) { console.error('err', err); process.exit(1); } else { addHandlers(); DEBUG.val && console.log({uptime:new Date, message:'websocket server up', server_port}); } }); function addHandlers() { app.get(`/api/${version}/tabs`, wrap(async (req, res) => { const cookie = req.cookies[COOKIENAME+port]; if ( !DEBUG.dev && allowed_user_cookie !== 'cookie' && (cookie !== allowed_user_cookie) ) { return res.status(401).send('{"err":"forbidden"}'); } requestId++; res.type('json'); let targets; try { ({data:{targetInfos:targets}} = await timedSend({ name: "Target.getTargets", params: {}, }, zombie_port)); browserTargetId = browserTargetId ||zl.act.getBrowserTargetId(zombie_port); targets = targets.filter(({targetId,type}) => type == 'page' && zl.act.hasSession(targetId, zombie_port)); const activeTarget = zl.act.getActiveTarget(zombie_port); zl.act.addTargets(targets, zombie_port); res.end(JSON.stringify({tabs:targets,activeTarget,requestId})); } catch(e) { console.warn('No target data from chrome. Normally means chrome is not opened.'); throw e; } })); app.post("/file", async (req,res) => { const cookie = req.cookies[COOKIENAME+port]; if ( !DEBUG.dev && allowed_user_cookie !== 'cookie' && (cookie !== allowed_user_cookie) ) { return res.status(401).send('{"err":"forbidden"}'); } const {files} = req; const {sessionid:sessionId} = req.body; const backendNodeId = fileChoosers.get(sessionId); const action = ! files || files.length == 0 ? 'cancel' : 'accept'; /** const fileInputResult = await zl.act.send({ name:"Runtime.evaluate", params: { expression: "self.zombieDosyLastClicked.fileInput" }, definitelyWait: true, sessionId }, zombie_port); console.log({fileInputResult, s:JSON.stringify(fileInputResult)}); const objectId = fileInputResult.data.result.objectId; **/ const command = { name: "DOM.setFileInputFiles", params: { files: files && files.map(({path}) => path), backendNodeId }, sessionId }; DEBUG.val > DEBUG.med && console.log("We need to send the right command to the browser session", files, sessionId, action, command); let result; try { result = await zl.act.send(command, zombie_port); } catch(e) { console.log("Error sending file input command", e); } DEBUG.val > DEBUG.med && console.log({fileResult:result}); if ( !result || result.error ) { res.status(500).send(JSON.stringify({error:'there was an error attaching the files'})); } else { result = { success: true, files: files.map(({originalname,size}) => ({name:originalname,size})) }; DEBUG.val > DEBUG.med && console.log("Sent files to file input", result, files); res.json(result); } }); // error handling middleware app.use('*', (err, req, res, next) => { try { res.type('json'); } finally { let message = ''; if ( DEBUG.dev && DEBUG.val ) { message = s({error: { msg: err.message, stack: err.stack.split(/\n/g) }}); } else { message = s({error: err.message || err+'', resetRequired:true}); } res.write(message); res.end(); if ( DEBUG.val ) { console.warn(err); } next(); } }); } async function runMessageQueue() { if ( messageQueueRunning ) return; messageQueueRunning = true; while( Queue.funcs.length ) { const func = Queue.funcs.shift(); try { await func(); } catch(e) { console.warn("error while running message queue", e); } // await sleep(TIME_BETWEEN_MESSAGES); } messageQueueRunning = false; } // helpers function wrap(fn) { return async function handler(req, res, next) { try { await fn(req, res, next); } catch(e) { if ( DEBUG.val ) { console.info(`caught error in ${fn}`, e); } next(e); } } } function s(o) { let r; if ( typeof o == "string" ) r = 0; else try { r = JSON.stringify(o, null, 2); } catch(e) { DEBUG.val > DEBUG.hi && console.warn(e); } try { r = r + ''; } catch(e) { DEBUG.val > DEBUG.hi && console.warn(e); } return r; } } function nextFileName(ext = '') { //console.log({nextFileName:{ext}}); if ( ! ext.startsWith('.') ) { ext = '.' + ext; } const name = `file${(Math.random()*1000000).toString(36)}${ext}`; //console.log({nextFileName:{name}}); return name; }