Mandate auth token to connect over TCP
Summary: Until now, launching flipper-server with TCP would accept any incoming connection as long as it comes from the same origin (localhost) using web socket host origin verification. This is not entirely secure as origin can be spoofed with tools like curl. Our team created a security review and a proposal was written: https://docs.google.com/document/d/16iXypCQibPiner061SoaQUFUY9tLVAEpkKfV_hUXI7c/ Effectively, Flipper can generate a token which is then used by the client to authenticate. This diff contains the changes required to generate, obtain, and validate authentication tokens from clients connecting to flipper over TCP connections. The token itself is a JWT token. JWT was chosen because it is a simple industry standard which offers three features which can immediately benefit us: - Expiration handling. No need for Flipper to store this information anywhere. - Payload. Payload can be used to push any data we deem relevant i.e. unix username. - Signing. Signed and verified using the same server key pair which is already in place for certificate exchange. Additionally, the token is stored in the Flipper static folder. This ensures that the browser and PWA clients have access to it. Reviewed By: mweststrate Differential Revision: D45179654 fbshipit-source-id: 6761bcb24f4ba30b67d1511cde8fe875158d78af
This commit is contained in:
committed by
Facebook GitHub Bot
parent
70cdc9bedc
commit
238f40f55d
@@ -30,9 +30,10 @@ export type {FlipperServer, FlipperServerCommands, FlipperServerExecOptions};
|
||||
export function createFlipperServer(
|
||||
host: string,
|
||||
port: number,
|
||||
args: string,
|
||||
onStateChange: (state: FlipperServerState) => void,
|
||||
): Promise<FlipperServer> {
|
||||
const socket = new ReconnectingWebSocket(`ws://${host}:${port}`);
|
||||
const socket = new ReconnectingWebSocket(`ws://${host}:${port}${args}`);
|
||||
return createFlipperServerWithSocket(socket as WebSocket, onStateChange);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"http-proxy": "^1.18.1",
|
||||
"invariant": "^2.2.4",
|
||||
"js-base64": "^3.7.5",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"memorystream": "^0.3.1",
|
||||
"node-fetch": "2",
|
||||
@@ -53,6 +54,7 @@
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/http-proxy": "^1.17.8",
|
||||
"@types/invariant": "^2.2.35",
|
||||
"@types/jsonwebtoken": "^9.0.1",
|
||||
"@types/memorystream": "^0.3.0",
|
||||
"@types/node": "^17.0.31",
|
||||
"@types/node-fetch": "2",
|
||||
|
||||
@@ -21,3 +21,5 @@ export * from './server/utilities';
|
||||
export {isFBBuild} from './fb-stubs/constants';
|
||||
|
||||
export {WEBSOCKET_MAX_MESSAGE_SIZE} from './comms/ServerWebSocket';
|
||||
|
||||
export {getAuthToken} from './utils/certificateUtils';
|
||||
|
||||
@@ -23,6 +23,7 @@ import exitHook from 'exit-hook';
|
||||
import {attachSocketServer} from './attachSocketServer';
|
||||
import {FlipperServerImpl} from '../FlipperServerImpl';
|
||||
import {FlipperServerCompanionEnv} from 'flipper-server-companion';
|
||||
import {validateAuthToken} from '../utils/certificateUtils';
|
||||
|
||||
type Config = {
|
||||
port: number;
|
||||
@@ -36,10 +37,36 @@ type ReadyForConnections = (
|
||||
companionEnv: FlipperServerCompanionEnv,
|
||||
) => Promise<void>;
|
||||
|
||||
const verifyAuthToken = (req: http.IncomingMessage): boolean => {
|
||||
let token: string | null = null;
|
||||
if (req.url) {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
token = url.searchParams.get('token');
|
||||
}
|
||||
|
||||
if (!token && req.headers['x-access-token']) {
|
||||
token = req.headers['x-access-token'] as string;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.warn('[conn] A token is required for authentication');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
validateAuthToken(token);
|
||||
console.info('[conn] Token was successfully validated');
|
||||
} catch (err) {
|
||||
console.warn('[conn] An invalid token was supplied for authentication');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Orchestrates the creation of the HTTP server, proxy, and web socket.
|
||||
* Orchestrates the creation of the HTTP server, proxy, and WS server.
|
||||
* @param config Server configuration.
|
||||
* @returns Returns a promise to the created server, proxy and web socket.
|
||||
* @returns Returns a promise to the created server, proxy and WS server.
|
||||
*/
|
||||
export async function startServer(config: Config): Promise<{
|
||||
app: Express;
|
||||
@@ -217,8 +244,9 @@ function addWebsocket(server: http.Server, config: Config) {
|
||||
possibleHosts.includes(req.headers.host)
|
||||
) {
|
||||
// no origin header? The request is not originating from a browser, so should be OK to pass through
|
||||
// If origin matches our own address, it means we are serving the page
|
||||
return true;
|
||||
// If origin matches our own address, it means we are serving the page.
|
||||
|
||||
return verifyAuthToken(req);
|
||||
} else {
|
||||
// for now we don't allow cross origin request, so that an arbitrary website cannot try to
|
||||
// connect a socket to localhost:serverport, and try to use the all powerful Flipper APIs to read
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import {promisify} from 'util';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import {
|
||||
openssl,
|
||||
isInstalled as opensslInstalled,
|
||||
@@ -18,13 +19,15 @@ import tmp, {FileOptions} from 'tmp';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
import {isTest} from 'flipper-common';
|
||||
import {flipperDataFolder} from './paths';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import {getFlipperServerConfig} from '../FlipperServerConfig';
|
||||
|
||||
const tmpFile = promisify(tmp.file) as (
|
||||
options?: FileOptions,
|
||||
) => Promise<string>;
|
||||
|
||||
const getFilePath = (fileName: string): string => {
|
||||
return path.resolve(flipperDataFolder, 'certs', fileName);
|
||||
const getFilePath = (filename: string): string => {
|
||||
return path.resolve(flipperDataFolder, 'certs', filename);
|
||||
};
|
||||
|
||||
// Desktop file paths
|
||||
@@ -69,16 +72,23 @@ export const ensureOpenSSLIsAvailable = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
let serverConfig: SecureServerConfig | undefined;
|
||||
export const loadSecureServerConfig = async (): Promise<SecureServerConfig> => {
|
||||
if (serverConfig) {
|
||||
return serverConfig;
|
||||
}
|
||||
|
||||
await ensureOpenSSLIsAvailable();
|
||||
await certificateSetup();
|
||||
return {
|
||||
await generateAuthToken();
|
||||
serverConfig = {
|
||||
key: await fs.readFile(serverKey),
|
||||
cert: await fs.readFile(serverCert),
|
||||
ca: await fs.readFile(caCert),
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
|
||||
};
|
||||
return serverConfig;
|
||||
};
|
||||
|
||||
export const extractAppNameFromCSR = async (csr: string): Promise<string> => {
|
||||
@@ -254,3 +264,44 @@ const writeToTempFile = async (content: string): Promise<string> => {
|
||||
await fs.writeFile(path, content);
|
||||
return path;
|
||||
};
|
||||
|
||||
const getStaticFilePath = (filename: string): string => {
|
||||
return path.resolve(getFlipperServerConfig().paths.staticPath, filename);
|
||||
};
|
||||
|
||||
const tokenFilename = 'auth.token';
|
||||
const getTokenPath = (): string => {
|
||||
const path = getStaticFilePath(tokenFilename);
|
||||
return path;
|
||||
};
|
||||
|
||||
export const generateAuthToken = async () => {
|
||||
const privateKey = await fs.readFile(serverKey);
|
||||
const token = jwt.sign({unixname: os.userInfo().username}, privateKey, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '21 days',
|
||||
});
|
||||
|
||||
await fs.writeFile(getTokenPath(), token);
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
export const getAuthToken = async () => {
|
||||
if (!(await fs.pathExists(getTokenPath()))) {
|
||||
return generateAuthToken();
|
||||
}
|
||||
|
||||
const token = await fs.readFile(getTokenPath());
|
||||
return token.toString();
|
||||
};
|
||||
|
||||
export const validateAuthToken = (token: string) => {
|
||||
if (!serverConfig) {
|
||||
throw new Error(
|
||||
'Unable to validate auth token as no server configuration is available',
|
||||
);
|
||||
}
|
||||
|
||||
jwt.verify(token, serverConfig.cert);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {initCompanionEnv} from 'flipper-server-companion';
|
||||
import {startFlipperServer, startServer} from 'flipper-server-core';
|
||||
import {isTest} from 'flipper-common';
|
||||
import exitHook from 'exit-hook';
|
||||
import {getAuthToken} from 'flipper-server-core';
|
||||
|
||||
const argv = yargs
|
||||
.usage('yarn flipper-server [args]')
|
||||
@@ -163,7 +164,7 @@ process.on('unhandledRejection', (reason, promise) => {
|
||||
});
|
||||
|
||||
start()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
if (!argv.tcp) {
|
||||
console.log('Flipper server started and listening');
|
||||
return;
|
||||
@@ -171,7 +172,8 @@ start()
|
||||
console.log(
|
||||
'Flipper server started and listening at port ' + chalk.green(argv.port),
|
||||
);
|
||||
const url = `http://localhost:${argv.port}`;
|
||||
const token = await getAuthToken();
|
||||
const url = `http://localhost:${argv.port}?token=${token}`;
|
||||
console.log('Go to: ' + chalk.green(chalk.bold(url)));
|
||||
if (argv.open) {
|
||||
open(url);
|
||||
|
||||
@@ -30,6 +30,7 @@ async function start() {
|
||||
const flipperServer = await createFlipperServer(
|
||||
location.hostname,
|
||||
parseInt(location.port, 10),
|
||||
location.search,
|
||||
(state: FlipperServerState) => {
|
||||
switch (state) {
|
||||
case FlipperServerState.CONNECTING:
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
left: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -64,7 +65,7 @@
|
||||
window.global = window;
|
||||
let suppressErrors = false;
|
||||
|
||||
const socket = new WebSocket(`ws://${location.host}`);
|
||||
const socket = new WebSocket(`ws://${location.host}${location.search}`);
|
||||
window.devSocket = socket;
|
||||
|
||||
socket.addEventListener('message', ({ data: dataRaw }) => {
|
||||
|
||||
@@ -3789,6 +3789,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
||||
|
||||
"@types/jsonwebtoken@^9.0.1":
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.1.tgz#29b1369c4774200d6d6f63135bf3d1ba3ef997a4"
|
||||
integrity sha512-c5ltxazpWabia/4UzhIoaDcIza4KViOQhdbjRlfcIGVnsE3c3brkz9Z+F/EeJIECOQP7W7US2hNE930cWWkPiw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/lockfile@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/lockfile/-/lockfile-1.0.2.tgz#3f77e84171a2b7e3198bd5717c7547a54393baf8"
|
||||
@@ -5504,6 +5511,11 @@ buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
|
||||
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
|
||||
integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
|
||||
integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
|
||||
|
||||
buffer-equal@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe"
|
||||
@@ -6800,6 +6812,13 @@ ecc-jsbn@~0.1.1:
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.1.0"
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
|
||||
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
|
||||
dependencies:
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
ee-first@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
|
||||
@@ -10361,6 +10380,16 @@ jsonparse@^1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
|
||||
integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
|
||||
|
||||
jsonwebtoken@^9.0.0:
|
||||
version "9.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz#d0faf9ba1cc3a56255fe49c0961a67e520c1926d"
|
||||
integrity sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==
|
||||
dependencies:
|
||||
jws "^3.2.2"
|
||||
lodash "^4.17.21"
|
||||
ms "^2.1.1"
|
||||
semver "^7.3.8"
|
||||
|
||||
jsprim@^1.2.2:
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb"
|
||||
@@ -10399,6 +10428,23 @@ jszip@^3.10.1:
|
||||
readable-stream "~2.3.6"
|
||||
setimmediate "^1.0.5"
|
||||
|
||||
jwa@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
|
||||
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
|
||||
dependencies:
|
||||
buffer-equal-constant-time "1.0.1"
|
||||
ecdsa-sig-formatter "1.0.11"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jws@^3.2.2:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
|
||||
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
|
||||
dependencies:
|
||||
jwa "^1.4.1"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
keyv@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
|
||||
|
||||
Reference in New Issue
Block a user