Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions packages/cli/src/commands/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,32 @@ const mockCommand: CommandModule = {
}),
handler: async parsedArgs => {
parsedArgs.jsonSchemaFakerFillProperties = parsedArgs['json-schema-faker-fillProperties'];
const { multiprocess, dynamic, port, host, cors, document, errors, verboseLevel, ignoreExamples, seed, jsonSchemaFakerFillProperties } =
parsedArgs as unknown as CreateMockServerOptions;
parsedArgs.tlsKey = parsedArgs['tls-key'];
parsedArgs.tlsCert = parsedArgs['tls-cert'];
parsedArgs.tlsPassphrase = parsedArgs['tls-passphrase'];
parsedArgs.tlsCa = parsedArgs['tls-ca'];
parsedArgs.tlsForwardClientCert = parsedArgs['tls-forward-client-cert'];
parsedArgs.tlsHttp2 = parsedArgs['tls-http2'];
const {
multiprocess,
dynamic,
port,
host,
cors,
document,
errors,
verboseLevel,
ignoreExamples,
seed,
jsonSchemaFakerFillProperties,
tlsKey,
tlsCert,
tlsPassphrase,
tlsCa,
mtls,
tlsForwardClientCert,
tlsHttp2,
} = parsedArgs as unknown as CreateMockServerOptions;

const createPrism = multiprocess ? createMultiProcessPrism : createSingleProcessPrism;
const options = {
Expand All @@ -59,6 +83,13 @@ const mockCommand: CommandModule = {
ignoreExamples,
seed,
jsonSchemaFakerFillProperties,
tlsKey,
tlsCert,
tlsPassphrase,
tlsCa,
mtls,
tlsForwardClientCert,
tlsHttp2,
};

await runPrismAndSetupWatcher(createPrism, options);
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/commands/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ const proxyCommand: CommandModule = {
}),
handler: async parsedArgs => {
parsedArgs.validateRequest = parsedArgs['validate-request'];
parsedArgs.tlsKey = parsedArgs['tls-key'];
parsedArgs.tlsCert = parsedArgs['tls-cert'];
parsedArgs.tlsPassphrase = parsedArgs['tls-passphrase'];
parsedArgs.tlsCa = parsedArgs['tls-ca'];
parsedArgs.tlsForwardClientCert = parsedArgs['tls-forward-client-cert'];
parsedArgs.tlsHttp2 = parsedArgs['tls-http2'];
const p: CreateProxyServerOptions = pick(
parsedArgs as unknown as CreateProxyServerOptions,
'dynamic',
Expand All @@ -54,7 +60,14 @@ const proxyCommand: CommandModule = {
'ignoreExamples',
'seed',
'upstreamProxy',
'jsonSchemaFakerFillProperties'
'jsonSchemaFakerFillProperties',
'tlsKey',
'tlsCert',
'tlsPassphrase',
'tlsCa',
'mtls',
'tlsForwardClientCert',
'tlsHttp2'
);

const createPrism = p.multiprocess ? createMultiProcessPrism : createSingleProcessPrism;
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/commands/sharedOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,46 @@ const sharedOptions: Dictionary<Options> = {
// custom levels like "success" and "start" are set to the same severity value as "info"
choices: Object.keys(pino.levels.values).concat('silent'),
},

'tls-key': {
description: 'Path to a PEM private key. Enables HTTPS (TLS termination) when set with --tls-cert.',
string: true,
},

'tls-cert': {
description: 'Path to a PEM server certificate (may include the chain). Required with --tls-key.',
string: true,
},

'tls-passphrase': {
description: 'Passphrase for an encrypted --tls-key.',
string: true,
},

'tls-ca': {
description: 'Path to a PEM CA bundle used to verify client certificates. Enables mTLS.',
string: true,
},

mtls: {
description:
'Require and verify a client certificate (mTLS); connections without a valid client cert are rejected. Requires --tls-ca.',
boolean: true,
default: false,
},

'tls-forward-client-cert': {
description:
'Inject the verified client certificate identity (subject, SAN, fingerprint) as x-client-cert-* request headers.',
boolean: true,
default: false,
},

'tls-http2': {
description: 'Serve over HTTP/2 (with HTTPS/1.1 fallback) instead of HTTPS/1.1. Requires --tls-key/--tls-cert.',
boolean: true,
default: false,
},
};

export default sharedOptions;
46 changes: 45 additions & 1 deletion packages/cli/src/util/createServer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createLogger } from '@stoplight/prism-core';
import { IHttpConfig, IHttpRequest } from '@stoplight/prism-http';
import { createServer as createHttpServer } from '@stoplight/prism-http-server';
import { createServer as createHttpServer, ITlsOptions } from '@stoplight/prism-http-server';
import * as fs from 'fs';
import * as chalk from 'chalk';
import cluster from 'node:cluster';
import * as E from 'fp-ts/Either';
Expand Down Expand Up @@ -104,6 +105,7 @@ async function createPrismServerWithLogger(options: CreateBaseServerOptions, log
cors: options.cors,
config,
components: { logger: logInstance.child({ name: 'HTTP SERVER' }) },
tls: buildTlsOptions(options),
});

const address = await server.listen(options.port, options.host);
Expand Down Expand Up @@ -153,6 +155,41 @@ function isProxyServerOptions(options: CreateBaseServerOptions): options is Crea
return 'upstream' in options;
}

function readPemFile(label: string, filePath: string): Buffer {
try {
return fs.readFileSync(filePath);
} catch (e) {
throw new Error(`Unable to read ${label} from "${filePath}": ${(e as Error).message}`);
}
}

function buildTlsOptions(options: CreateBaseServerOptions): ITlsOptions | undefined {
if (!options.tlsKey && !options.tlsCert && !options.tlsCa && !options.mtls && !options.tlsHttp2) {
return undefined;
}

if (!options.tlsKey || !options.tlsCert) {
throw new Error('TLS requires both --tls-key and --tls-cert.');
}

if (options.mtls && !options.tlsCa) {
throw new Error('--mtls requires --tls-ca to verify client certificates.');
}

const ca = options.tlsCa ? readPemFile('TLS CA bundle', options.tlsCa) : undefined;

return {
key: readPemFile('TLS key', options.tlsKey),
cert: readPemFile('TLS certificate', options.tlsCert),
passphrase: options.tlsPassphrase,
ca,
requestCert: options.mtls || !!ca,
rejectUnauthorized: options.mtls,
forwardClientCertHeaders: options.tlsForwardClientCert,
http2: options.tlsHttp2,
};
}

/**
* @property {boolean} jsonSchemaFakerFillProperties - Used to override the default json-schema-faker extension value
*/
Expand All @@ -168,6 +205,13 @@ type CreateBaseServerOptions = {
ignoreExamples: boolean;
seed: string;
jsonSchemaFakerFillProperties: boolean;
tlsKey?: string;
tlsCert?: string;
tlsPassphrase?: string;
tlsCa?: string;
mtls?: boolean;
tlsForwardClientCert?: boolean;
tlsHttp2?: boolean;
};

export interface CreateProxyServerOptions extends CreateBaseServerOptions {
Expand Down
106 changes: 106 additions & 0 deletions packages/http-server/src/__tests__/tls.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createLogger } from '@stoplight/prism-core';
import { getHttpOperationsFromSpec } from '@stoplight/prism-http';
import { execFileSync } from 'child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolve } from 'path';
import fetch from 'node-fetch';
import { Agent } from 'https';
import { createServer } from '../';
import { ITlsOptions, ThenArg } from '../types';

const logger = createLogger('TEST', { enabled: false });
const specPath = resolve(__dirname, 'fixtures', 'petstore.no-auth.oas3.yaml');

let certDir: string;
let caCert: Buffer;

// Generate a CA, a server cert (SAN localhost/127.0.0.1) and a client cert, all via openssl.
beforeAll(() => {
certDir = mkdtempSync(join(tmpdir(), 'prism-tls-'));
const f = (name: string) => join(certDir, name);
const run = (args: string[]) => execFileSync('openssl', args, { stdio: 'ignore' });

writeFileSync(f('san.ext'), 'subjectAltName=DNS:localhost,IP:127.0.0.1');

run(['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', f('ca.key'), '-out', f('ca.crt'), '-days', '1', '-subj', '/CN=Test CA']);
run(['req', '-newkey', 'rsa:2048', '-nodes', '-keyout', f('server.key'), '-out', f('server.csr'), '-subj', '/CN=localhost']);
run(['x509', '-req', '-in', f('server.csr'), '-CA', f('ca.crt'), '-CAkey', f('ca.key'), '-CAcreateserial', '-out', f('server.crt'), '-days', '1', '-extfile', f('san.ext')]);
run(['req', '-newkey', 'rsa:2048', '-nodes', '-keyout', f('client.key'), '-out', f('client.csr'), '-subj', '/CN=test-client']);
run(['x509', '-req', '-in', f('client.csr'), '-CA', f('ca.crt'), '-CAkey', f('ca.key'), '-CAcreateserial', '-out', f('client.crt'), '-days', '1']);

caCert = readFileSync(f('ca.crt'));
}, 30000);

afterAll(() => {
if (certDir) rmSync(certDir, { recursive: true, force: true });
});

async function instantiate(tls: ITlsOptions, port: number) {
const operations = await getHttpOperationsFromSpec(specPath);
const server = createServer(operations, {
components: { logger },
config: {
checkSecurity: true,
validateRequest: true,
validateResponse: true,
errors: false,
mock: { dynamic: false },
upstreamProxy: undefined,
isProxy: false,
},
cors: true,
tls,
});
const address = await server.listen(port, '127.0.0.1');
return { close: server.close.bind(server), address };
}

const f = (name: string) => join(certDir, name);

describe('TLS termination', () => {
let server: ThenArg<ReturnType<typeof instantiate>>;

afterEach(() => server && server.close());

it('serves over HTTPS and reports an https:// address', async () => {
server = await instantiate({ key: readFileSync(f('server.key')), cert: readFileSync(f('server.crt')) }, 30441);

expect(server.address).toMatch(/^https:\/\//);

const res = await fetch(`${server.address}/no_auth/pets?name=fido`, {
agent: new Agent({ ca: caCert }),
});
expect(res.status).toBe(200);
});
});

describe('mTLS termination', () => {
let server: ThenArg<ReturnType<typeof instantiate>>;

afterEach(() => server && server.close());

it('rejects a request without a client certificate', async () => {
server = await instantiate(
{ key: readFileSync(f('server.key')), cert: readFileSync(f('server.crt')), ca: caCert, requestCert: true, rejectUnauthorized: true },
30442
);

await expect(
fetch(`${server.address}/no_auth/pets?name=fido`, { agent: new Agent({ ca: caCert }) })
).rejects.toThrow();
});

it('accepts a request with a valid client certificate', async () => {
server = await instantiate(
{ key: readFileSync(f('server.key')), cert: readFileSync(f('server.crt')), ca: caCert, requestCert: true, rejectUnauthorized: true },
30443
);

const res = await fetch(`${server.address}/no_auth/pets?name=fido`, {
agent: new Agent({ ca: caCert, cert: readFileSync(f('client.crt')), key: readFileSync(f('client.key')) }),
});
expect(res.status).toBe(200);
});
});
1 change: 1 addition & 0 deletions packages/http-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { createServer } from './server';
export { ITlsOptions, IPrismHttpServerOpts } from './types';
Loading