tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

index.ts (9084B)


      1 /**
      2 * @license
      3 * Copyright 2017 Google Inc.
      4 * SPDX-License-Identifier: Apache-2.0
      5 */
      6 
      7 import assert from 'node:assert';
      8 import {readFile, readFileSync} from 'node:fs';
      9 import {
     10  createServer as createHttpServer,
     11  type IncomingMessage,
     12  type RequestListener,
     13  type Server as HttpServer,
     14  type ServerResponse,
     15 } from 'node:http';
     16 import {
     17  createServer as createHttpsServer,
     18  type Server as HttpsServer,
     19  type ServerOptions as HttpsServerOptions,
     20 } from 'node:https';
     21 import type {AddressInfo} from 'node:net';
     22 import {join} from 'node:path';
     23 import type {Duplex} from 'node:stream';
     24 import {gzip} from 'zlib';
     25 
     26 import {getType as getMimeType} from 'mime';
     27 import {Server as WebSocketServer, type WebSocket} from 'ws';
     28 
     29 interface Subscriber {
     30  resolve: (msg: IncomingMessage) => void;
     31  reject: (err?: Error) => void;
     32  promise: Promise<IncomingMessage>;
     33 }
     34 
     35 type TestIncomingMessage = IncomingMessage & {postBody?: Promise<string>};
     36 
     37 export class TestServer {
     38  PORT!: number;
     39  PREFIX!: string;
     40  CROSS_PROCESS_PREFIX!: string;
     41  EMPTY_PAGE!: string;
     42 
     43  #dirPath: string;
     44  #server: HttpsServer | HttpServer;
     45  #wsServer: WebSocketServer;
     46 
     47  #startTime = new Date();
     48  #cachedPathPrefix?: string;
     49 
     50  #connections = new Set<Duplex>();
     51  #routes = new Map<
     52    string,
     53    (msg: IncomingMessage, res: ServerResponse) => void
     54  >();
     55  #auths = new Map<string, {username: string; password: string}>();
     56  #csp = new Map<string, string>();
     57  #gzipRoutes = new Set<string>();
     58  #requestSubscribers = new Map<string, Subscriber>();
     59  #requests = new Set<ServerResponse>();
     60 
     61  static async create(dirPath: string): Promise<TestServer> {
     62    let res!: (value: unknown) => void;
     63    const promise = new Promise(resolve => {
     64      res = resolve;
     65    });
     66    const server = new TestServer(dirPath);
     67    server.#server.once('listening', res);
     68    server.#server.listen(0);
     69    await promise;
     70    return server;
     71  }
     72 
     73  static async createHTTPS(dirPath: string): Promise<TestServer> {
     74    let res!: (value: unknown) => void;
     75    const promise = new Promise(resolve => {
     76      res = resolve;
     77    });
     78    const server = new TestServer(dirPath, {
     79      key: readFileSync(join(__dirname, '..', 'key.pem')),
     80      cert: readFileSync(join(__dirname, '..', 'cert.pem')),
     81      passphrase: 'aaaa',
     82    });
     83    server.#server.once('listening', res);
     84    server.#server.listen(0);
     85    await promise;
     86    return server;
     87  }
     88 
     89  constructor(dirPath: string, sslOptions?: HttpsServerOptions) {
     90    this.#dirPath = dirPath;
     91 
     92    if (sslOptions) {
     93      this.#server = createHttpsServer(sslOptions, this.#onRequest);
     94    } else {
     95      this.#server = createHttpServer(this.#onRequest);
     96    }
     97    this.#server.on('connection', this.#onServerConnection);
     98    // Disable this as sometimes the socket will timeout
     99    // We rely on the fact that we will close the server at the end
    100    this.#server.keepAliveTimeout = 0;
    101    this.#server.on('clientError', err => {
    102      if (
    103        'code' in err &&
    104        err.code === 'ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN'
    105      ) {
    106        return;
    107      }
    108      console.error('test-server client error', err);
    109    });
    110    this.#wsServer = new WebSocketServer({server: this.#server});
    111    this.#wsServer.on('connection', this.#onWebSocketConnection);
    112  }
    113 
    114  #onServerConnection = (connection: Duplex): void => {
    115    this.#connections.add(connection);
    116    // ECONNRESET is a legit error given
    117    // that tab closing simply kills process.
    118    connection.on('error', error => {
    119      if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') {
    120        throw error;
    121      }
    122    });
    123    connection.once('close', () => {
    124      return this.#connections.delete(connection);
    125    });
    126  };
    127 
    128  get port(): number {
    129    return (this.#server.address() as AddressInfo).port;
    130  }
    131 
    132  enableHTTPCache(pathPrefix: string): void {
    133    this.#cachedPathPrefix = pathPrefix;
    134  }
    135 
    136  setAuth(path: string, username: string, password: string): void {
    137    this.#auths.set(path, {username, password});
    138  }
    139 
    140  enableGzip(path: string): void {
    141    this.#gzipRoutes.add(path);
    142  }
    143 
    144  setCSP(path: string, csp: string): void {
    145    this.#csp.set(path, csp);
    146  }
    147 
    148  async stop(): Promise<void> {
    149    this.reset();
    150    for (const socket of this.#connections) {
    151      socket.destroy();
    152    }
    153    this.#connections.clear();
    154    await new Promise(x => {
    155      return this.#server.close(x);
    156    });
    157  }
    158 
    159  setRoute(
    160    path: string,
    161    handler: (req: IncomingMessage, res: ServerResponse) => void,
    162  ): void {
    163    this.#routes.set(path, handler);
    164  }
    165 
    166  setRedirect(from: string, to: string): void {
    167    this.setRoute(from, (_, res) => {
    168      res.writeHead(302, {location: to});
    169      res.end();
    170    });
    171  }
    172 
    173  waitForRequest(path: string): Promise<TestIncomingMessage> {
    174    const subscriber = this.#requestSubscribers.get(path);
    175    if (subscriber) {
    176      return subscriber.promise;
    177    }
    178    let resolve!: (value: IncomingMessage) => void;
    179    let reject!: (reason?: Error) => void;
    180    const promise = new Promise<IncomingMessage>((res, rej) => {
    181      resolve = res;
    182      reject = rej;
    183    });
    184    this.#requestSubscribers.set(path, {resolve, reject, promise});
    185    return promise;
    186  }
    187 
    188  reset(): void {
    189    this.#routes.clear();
    190    this.#auths.clear();
    191    this.#csp.clear();
    192    this.#gzipRoutes.clear();
    193    const error = new Error('Static Server has been reset');
    194    for (const subscriber of this.#requestSubscribers.values()) {
    195      subscriber.reject.call(undefined, error);
    196    }
    197    this.#requestSubscribers.clear();
    198    for (const request of this.#requests.values()) {
    199      if (!request.writableEnded) {
    200        request.destroy();
    201      }
    202    }
    203    this.#requests.clear();
    204  }
    205 
    206  #onRequest: RequestListener = (
    207    request: TestIncomingMessage,
    208    response,
    209  ): void => {
    210    this.#requests.add(response);
    211 
    212    request.on('error', (error: {code: string}) => {
    213      if (error.code === 'ECONNRESET') {
    214        response.end();
    215      } else {
    216        throw error;
    217      }
    218    });
    219    request.postBody = new Promise(resolve => {
    220      let body = '';
    221      request.on('data', (chunk: string) => {
    222        return (body += chunk);
    223      });
    224      request.on('end', () => {
    225        return resolve(body);
    226      });
    227    });
    228    assert(request.url);
    229    const url = new URL(request.url, `https://${request.headers.host}`);
    230    const path = url.pathname;
    231    const auth = this.#auths.get(path);
    232    if (auth) {
    233      const credentials = Buffer.from(
    234        (request.headers.authorization || '').split(' ')[1] || '',
    235        'base64',
    236      ).toString();
    237      if (credentials !== `${auth.username}:${auth.password}`) {
    238        response.writeHead(401, {
    239          'WWW-Authenticate': 'Basic realm="Secure Area"',
    240        });
    241        response.end('HTTP Error 401 Unauthorized: Access is denied');
    242        return;
    243      }
    244    }
    245    const subscriber = this.#requestSubscribers.get(path);
    246    if (subscriber) {
    247      subscriber.resolve.call(undefined, request);
    248      this.#requestSubscribers.delete(path);
    249    }
    250    const handler = this.#routes.get(path);
    251    if (handler) {
    252      handler.call(undefined, request, response);
    253    } else {
    254      this.serveFile(request, response, path);
    255    }
    256  };
    257 
    258  serveFile(
    259    request: IncomingMessage,
    260    response: ServerResponse,
    261    pathName: string,
    262  ): void {
    263    pathName = decodeURIComponent(pathName);
    264    if (pathName === '/') {
    265      pathName = '/index.html';
    266    }
    267    const filePath = join(this.#dirPath, pathName.substring(1));
    268 
    269    if (this.#cachedPathPrefix && filePath.startsWith(this.#cachedPathPrefix)) {
    270      if (request.headers['if-modified-since']) {
    271        response.statusCode = 304; // not modified
    272        response.end();
    273        return;
    274      }
    275      response.setHeader('Cache-Control', 'public, max-age=31536000');
    276      response.setHeader('Last-Modified', this.#startTime.toISOString());
    277    } else {
    278      response.setHeader('Cache-Control', 'no-cache, no-store');
    279    }
    280    const csp = this.#csp.get(pathName);
    281    if (csp) {
    282      response.setHeader('Content-Security-Policy', csp);
    283    }
    284 
    285    readFile(filePath, (err, data) => {
    286      // This can happen if the request is not awaited but started
    287      // in the test and get clean via `reset()`
    288      if (response.writableEnded) {
    289        return;
    290      }
    291 
    292      if (err) {
    293        response.statusCode = 404;
    294        response.end(`File not found: ${filePath}`);
    295        return;
    296      }
    297      const mimeType = getMimeType(filePath);
    298      if (mimeType) {
    299        const isTextEncoding = /^text\/|^application\/(javascript|json)/.test(
    300          mimeType,
    301        );
    302        const contentType = isTextEncoding
    303          ? `${mimeType}; charset=utf-8`
    304          : mimeType;
    305        response.setHeader('Content-Type', contentType);
    306      }
    307      if (this.#gzipRoutes.has(pathName)) {
    308        response.setHeader('Content-Encoding', 'gzip');
    309        gzip(data, (_, result) => {
    310          response.end(result);
    311        });
    312      } else {
    313        response.end(data);
    314      }
    315    });
    316  }
    317 
    318  #onWebSocketConnection = (socket: WebSocket): void => {
    319    socket.send('opened');
    320  };
    321 }