Path: blob/master/src/packages/hub/proxy/handle-request.ts
1496 views
/* Handle a proxy request */12import { createProxyServer, type ProxyServer } from "http-proxy-3";3import LRU from "lru-cache";4import stripRememberMeCookie from "./strip-remember-me-cookie";5import { versionCheckFails } from "./version";6import { getTarget } from "./target";7import getLogger from "../logger";8import { stripBasePath } from "./util";9import { ProjectControlFunction } from "@cocalc/server/projects/control";10import siteUrl from "@cocalc/database/settings/site-url";11import { parseReq } from "./parse";12import { readFile as readProjectFile } from "@cocalc/conat/files/read";13import { path_split } from "@cocalc/util/misc";14import { once } from "@cocalc/util/async-utils";15import hasAccess from "./check-for-access-to-project";16import mime from "mime-types";1718const DANGEROUS_CONTENT_TYPE = new Set(["image/svg+xml" /*, "text/html"*/]);1920const logger = getLogger("proxy:handle-request");2122interface Options {23projectControl: ProjectControlFunction;24isPersonal: boolean;25}2627export default function init({ projectControl, isPersonal }: Options) {28/* Cache at most 5000 proxies, each for up to 3 minutes.29Throwing away proxies at any time from the cache is fine since30the proxy is just used to handle *individual* http requests,31and the cache is entirely for speed. Also, invalidating cache entries32works around weird cases, where maybe error/close don't get33properly called, but the proxy is not working due to network34issues. Invalidating cache entries quickly is also good from35a permissions and security point of view.36*/3738const cache = new LRU<string, ProxyServer>({39max: 5000,40ttl: 1000 * 60 * 3,41});4243async function handleProxyRequest(req, res): Promise<void> {44const dbg = (...args) => {45// for low level debugging -- silly isn't logged by default46logger.silly(req.url, ...args);47};48dbg("got request");49// dangerous/verbose to log...?50// dbg("headers = ", req.headers);5152if (!isPersonal && versionCheckFails(req, res)) {53dbg("version check failed");54// note that the versionCheckFails function already sent back an error response.55throw Error("version check failed");56}5758// Before doing anything further with the request on to the proxy, we remove **all** cookies whose59// name contains "remember_me", to prevent the project backend from getting at60// the user's session cookie, since one project shouldn't be able to get61// access to any user's account.62let remember_me, api_key;63if (req.headers["cookie"] != null) {64let cookie;65({ cookie, remember_me, api_key } = stripRememberMeCookie(66req.headers["cookie"],67));68req.headers["cookie"] = cookie;69}7071if (!isPersonal && !remember_me && !api_key) {72dbg("no rememember me set, so blocking");73// Not in personal mode and there is no remember_me or api_key set all, so74// definitely block access. 4xx since this is a *client* problem.75const url = await siteUrl();76throw Error(77`Please login to <a target='_blank' href='${url}'>${url}</a> with cookies enabled, then refresh this page.`,78);79}8081const url = stripBasePath(req.url);82const parsed = parseReq(url, remember_me, api_key);83// TODO: parseReq is called again in getTarget so need to refactor...84const { type, project_id } = parsed;85if (type == "files") {86dbg("handling the request via conat file streaming");87if (88!(await hasAccess({89project_id,90remember_me,91api_key,92type: "read",93isPersonal,94}))95) {96throw Error(`user does not have read access to project`);97}98const i = url.indexOf("files/");99const compute_server_id = req.query.id ?? 0;100let j = url.lastIndexOf("?");101if (j == -1) {102j = url.length;103}104const path = decodeURIComponent(url.slice(i + "files/".length, j));105dbg("conat: get file", { project_id, path, compute_server_id, url });106const fileName = path_split(path).tail;107const contentType = mime.lookup(fileName);108if (109req.query.download != null ||110DANGEROUS_CONTENT_TYPE.has(contentType)111) {112const fileNameEncoded = encodeURIComponent(fileName)113.replace(/['()]/g, escape)114.replace(/\*/g, "%2A");115res.setHeader(116"Content-disposition",117`attachment; filename*=UTF-8''${fileNameEncoded}`,118);119}120res.setHeader("Content-type", contentType);121for await (const chunk of await readProjectFile({122project_id,123compute_server_id,124path,125// allow a long download time (1 hour), since files can be large and126// networks can be slow.127maxWait: 1000 * 60 * 60,128})) {129if (!res.write(chunk)) {130// backpressure -- wait for it to resolve131await once(res, "drain");132}133}134res.end();135return;136}137138const { host, port, internal_url } = await getTarget({139remember_me,140api_key,141url,142isPersonal,143projectControl,144parsed,145});146147// It's http here because we've already got past the ssl layer. This is all internal.148const target = `http://${host}:${port}`;149dbg("target resolves to", target);150151let proxy;152if (cache.has(target)) {153// we already have the proxy for this target in the cache154dbg("using cached proxy");155proxy = cache.get(target);156} else {157logger.debug("make a new proxy server to", target);158proxy = createProxyServer({159ws: false,160target,161});162// and cache it.163cache.set(target, proxy);164logger.debug("created new proxy");165166proxy.on("error", (err) => {167logger.debug(`http proxy error -- ${err}`);168});169}170171if (internal_url != null) {172dbg("changing req url from ", req.url, " to ", internal_url);173req.url = internal_url;174}175dbg("handling the request using the proxy");176proxy.web(req, res);177}178179return async (req, res) => {180try {181await handleProxyRequest(req, res);182} catch (err) {183const msg = `WARNING: error proxying request ${req.url} -- ${err}`;184res.writeHead(426, { "Content-Type": "text/html" });185res.end(msg);186// Not something to log as an error -- just debug; it's normal for it to happen, e.g., when187// a project isn't running.188logger.debug(msg);189}190};191}192193194