Skip to content

Commit

Permalink
Merge pull request Jigsaw-Code#1090 from Jigsaw-Code/bemasc-custom-fetch
Browse files Browse the repository at this point in the history
fix(manager): Proxy Shadowbox HTTP requests through Node
  • Loading branch information
Benjamin M. Schwartz authored May 13, 2022
2 parents 4664384 + 263974e commit 1acb7dc
Show file tree
Hide file tree
Showing 22 changed files with 2,964 additions and 3,483 deletions.
6 changes: 5 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
;enforces that the user is `npm install`ing with the correct node version
; Enforces that the user is `npm install`ing with the correct node version.
engine-strict=true

; Workaround for conflict between the default location(s) of node-forge and the
; location expected by Typescript, Jasmine, and Electron.
prefer-dedupe=true
5,768 changes: 2,474 additions & 3,294 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/metrics_server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
"@google-cloud/storage here only to help Typescript code using @google-cloud/bigquery compile"
],
"dependencies": {
"@google-cloud/bigquery": "^2.0.3",
"@google-cloud/bigquery": "^5.12.0",
"express": "^4.17.1"
},
"devDependencies": {
"@google-cloud/storage": "^2.3.1",
"@google-cloud/storage": "^5.19.4",
"@types/express": "^4.17.12"
},
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src/server_manager/electron_app/build.action.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ tsc -p src/server_manager/electron_app/tsconfig.json --outDir build/server_manag
readonly STATIC_DIR="${OUT_DIR}/static"
mkdir -p "${STATIC_DIR}"
mkdir -p "${STATIC_DIR}/server_manager"
cp -r "${OUT_DIR}/js/"* "${STATIC_DIR}"
cp -r "${OUT_DIR}/js/electron_app/"* "${STATIC_DIR}"
cp -r "${BUILD_DIR}/server_manager/web_app/static" "${STATIC_DIR}/server_manager/web_app/"

# Electron requires a package.json file for the app's name, etc.
Expand Down
94 changes: 94 additions & 0 deletions src/server_manager/electron_app/fetch.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as crypto from 'crypto';
import * as tls from 'tls';
import * as https from 'https';
import * as forge from 'node-forge';

import {fetchWithPin} from './fetch';
import {AddressInfo} from 'net';

describe('fetchWithPin', () => {
it('throws on pin mismatch (remote)', async () => {
const result = fetchWithPin(
{url: 'https://www.gstatic.com/', method: 'GET'},
'incorrect fingerprint'
);
await expectAsync(result).toBeRejectedWithError(Error, /Fingerprint mismatch/);
});

// Make a certificate.
const {privateKey, publicKey} = forge.pki.rsa.generateKeyPair(1024);
const cert = forge.pki.createCertificate();
cert.publicKey = publicKey;
cert.sign(privateKey); // Self-signed cert

// Serialize the certificate for `tls.createServer()`.
const keyPem = forge.pki.privateKeyToPem(privateKey);
const certPem = forge.pki.certificateToPem(cert);

// Compute the certificate fingerprint.
const certDer = forge.pki.pemToDer(certPem);
const sha256 = crypto.createHash('sha256');
const certSha256 = sha256.update(certDer.data, 'binary').digest().toString('binary');

it('throws on pin mismatch (local)', async () => {
const server = tls.createServer({key: keyPem, cert: certPem});
await new Promise<void>((fulfill) => server.listen(0, fulfill));

const address = server.address() as AddressInfo;
const req = {
url: `https://localhost:${address.port}/foo`,
method: 'GET',
};

// Fail if the TLS handshake completes.
server.on('secureConnection', fail);

const clientClosed = new Promise((fulfill) =>
server.on('connection', (socket) => socket.on('close', fulfill))
);

const result = fetchWithPin(req, 'incorrect fingerprint');
await expectAsync(result).toBeRejectedWithError(Error, /Fingerprint mismatch/);

// Don't stop the test until the client has closed the TCP socket.
await clientClosed;
});

it('succeeds on pin match', async () => {
const server = https.createServer({key: keyPem, cert: certPem});
await new Promise<void>((fulfill) => server.listen(0, fulfill));

const address = server.address() as AddressInfo;
const req = {
url: `https://localhost:${address.port}/foo`,
method: 'GET',
};
server.on('request', (incoming, response) => {
expect(incoming.url).toBe('/foo');
expect(incoming.method).toBe('GET');
response.writeHead(200);
response.write('test test');
response.end();
});

const result = fetchWithPin(req, certSha256);
await expectAsync(result).toBeResolvedTo({
status: 200,
body: 'test test',
});
});
});
70 changes: 70 additions & 0 deletions src/server_manager/electron_app/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as https from 'https';
import {TLSSocket} from 'tls';
import {urlToHttpOptions} from 'url';

import type {IncomingMessage} from 'http';

import type {HttpRequest, HttpResponse} from '../infrastructure/path_api';

export const fetchWithPin = async (
req: HttpRequest,
fingerprint: string
): Promise<HttpResponse> => {
const response = await new Promise<IncomingMessage>((resolve, reject) => {
const options: https.RequestOptions = {
...urlToHttpOptions(new URL(req.url)),
method: req.method,
headers: req.headers,
rejectUnauthorized: false, // Disable certificate chain validation.
};
const request = https.request(options, resolve).on('error', reject);

// Enforce certificate fingerprint match.
request.on('socket', (socket: TLSSocket) =>
socket.on('secureConnect', () => {
const certificate = socket.getPeerCertificate();
// Parse fingerprint in "AB:CD:EF" form.
const sha2hex = certificate.fingerprint256.replace(/:/g, '');
const sha2binary = Buffer.from(sha2hex, 'hex').toString('binary');
if (sha2binary !== fingerprint) {
request.emit(
'error',
new Error(`Fingerprint mismatch: expected ${fingerprint}, not ${sha2binary}`)
);
request.destroy();
return;
}
})
);

if (req.body) {
request.write(req.body);
}

request.end();
});

const chunks: Buffer[] = [];
for await (const chunk of response) {
chunks.push(chunk);
}

return {
status: response.statusCode,
body: Buffer.concat(chunks).toString(),
};
};
28 changes: 11 additions & 17 deletions src/server_manager/electron_app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {autoUpdater} from 'electron-updater';
import * as path from 'path';
import {URL, URLSearchParams} from 'url';

import type {HttpRequest, HttpResponse} from '../infrastructure/path_api';
import {fetchWithPin} from './fetch';
import * as menu from './menu';

const app = electron.app;
Expand Down Expand Up @@ -237,23 +239,15 @@ function main() {
}
});

// Handle request to trust the certificate from the renderer process.
const trustedFingerprints = new Set<string>();
const makeKey = (host: string, fingerprint: string) => `${host};${fingerprint}`;
ipcMain.on('trust-certificate', (event: IpcEvent, host: string, fingerprint: string) => {
trustedFingerprints.add(makeKey(host, `sha256/${fingerprint}`));
event.returnValue = true;
});
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
event.preventDefault();
try {
const parsed = new URL(url);
callback(trustedFingerprints.has(makeKey(parsed.host, certificate.fingerprint)));
} catch (e) {
console.error(e);
callback(false);
}
});
// Proxy for fetch calls that require fingerprint pinning.
ipcMain.handle(
'fetch-with-pin',
(
event: Electron.IpcMainInvokeEvent,
req: HttpRequest,
fingerprint: string
): Promise<HttpResponse> => fetchWithPin(req, fingerprint)
);

// Restores the mainWindow if minimized and brings it into focus.
ipcMain.on('bring-to-front', (_event: IpcEvent) => {
Expand Down
9 changes: 6 additions & 3 deletions src/server_manager/electron_app/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {URL} from 'url';

import * as digitalocean_oauth from './digitalocean_oauth';
import * as gcp_oauth from './gcp_oauth';
import {HttpRequest, HttpResponse} from '../infrastructure/path_api';
import {redactManagerUrl} from './util';

// This file is run in the renderer process *before* nodeIntegration is disabled.
Expand Down Expand Up @@ -47,9 +48,11 @@ if (sentryDsn) {
});
}

contextBridge.exposeInMainWorld('trustCertificate', (host: string, fingerprint: string) => {
return ipcRenderer.sendSync('trust-certificate', host, fingerprint);
});
contextBridge.exposeInMainWorld(
'fetchWithPin',
(request: HttpRequest, fingerprint: string): Promise<HttpResponse> =>
ipcRenderer.invoke('fetch-with-pin', request, fingerprint)
);

contextBridge.exposeInMainWorld('openImage', (basename: string) => {
ipcRenderer.send('open-image', basename);
Expand Down
4 changes: 2 additions & 2 deletions src/server_manager/electron_app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
"removeComments": false,
"noImplicitAny": true,
"module": "commonjs",
"rootDir": ".",
"rootDir": "..",
"lib": ["dom", "es2021"]
},
"include": ["*.ts", "../types/*.d.ts"],
"include": ["*.ts", "../infrastructure/path_api.ts", "../types/*.d.ts"],
"exclude": ["node_modules"],
"compileOnSave": true
}
4 changes: 3 additions & 1 deletion src/server_manager/infrastructure/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import type {HttpResponse} from './path_api';

export class OutlineError extends Error {
constructor(message?: string) {
// ref:
Expand Down Expand Up @@ -45,7 +47,7 @@ export class ServerInstallFailedError extends OutlineError {

// Thrown when a Shadowbox API request fails.
export class ServerApiError extends OutlineError {
constructor(message: string, public readonly response?: Response) {
constructor(message: string, public readonly response?: HttpResponse) {
super(message);
}

Expand Down
15 changes: 0 additions & 15 deletions src/server_manager/infrastructure/hex_encoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.

export function asciiToHex(text: string) {
// Assumes that text is no more than 8 bits per char, i.e. no unicode.
const hexBytes: string[] = [];
for (let i = 0; i < text.length; ++i) {
const charCode = text.charCodeAt(i);
if (charCode > 0xff) {
// Consider supporting non-ascii characters:
// http://monsur.hossa.in/2012/07/20/utf-8-in-javascript.html
throw new Error(`Cannot encode wide character with value ${charCode}`);
}
hexBytes.push(('0' + charCode.toString(16)).slice(-2));
}
return hexBytes.join('');
}

export function hexToString(hexString: string) {
const bytes: string[] = [];
if (hexString.length % 2 !== 0) {
Expand Down
67 changes: 67 additions & 0 deletions src/server_manager/infrastructure/path_api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2022 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {PathApiClient} from './path_api';

describe('PathApi', () => {
// Mock fetcher
let lastRequest: HttpRequest;
let nextResponse: Promise<HttpResponse>;

const fetcher = (request: HttpRequest) => {
lastRequest = request;
return nextResponse;
};

beforeEach(() => {
lastRequest = undefined;
nextResponse = undefined;
});

const api = new PathApiClient('https://asdf.test/foo', fetcher);

it('GET', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.request('bar')).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'GET',
});
});

it('PUT form data', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.requestForm('bar', 'PUT', {name: 'value'})).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'PUT',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'name=value',
});
});

it('POST JSON data', async () => {
const response = {status: 200, body: '{"asdf": true}'};
nextResponse = Promise.resolve(response);
expect(await api.requestJson('bar', 'POST', {key: 'value'})).toEqual({asdf: true});
expect(lastRequest).toEqual({
url: 'https://asdf.test/foo/bar',
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '{"key":"value"}',
});
});
});
Loading

0 comments on commit 1acb7dc

Please sign in to comment.