diff --git a/__tests__/cli.test.ts b/__tests__/cli.test.ts index 5875ecd4..bfa5c5e4 100644 --- a/__tests__/cli.test.ts +++ b/__tests__/cli.test.ts @@ -421,6 +421,26 @@ describe('CLI', () => { expect.objectContaining({ status: 'succeeded' }) ); }); + + it('succeeds with --certificate-authorities', async () => { + const cli = new CLIMock() + .args( + cliArgs.concat( + '--certificate-authorities', + join(FIXTURES_DIR, 'ca', 'selfsigned.cert') + ) + ) + .run(); + expect(await cli.exitCode).toBe(0); + + const journeyEnd = safeNDJSONParse(cli.buffer()).find( + ({ type }) => type === 'journey/end' + ); + + expect(journeyEnd.journey).toEqual( + expect.objectContaining({ status: 'succeeded' }) + ); + }); }); describe('Throttling', () => { diff --git a/__tests__/core/certs.test.ts b/__tests__/core/certs.test.ts new file mode 100644 index 00000000..f8f24132 --- /dev/null +++ b/__tests__/core/certs.test.ts @@ -0,0 +1,126 @@ +/** + * MIT License + * + * Copyright (c) 2020-present, Elastic NV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { + getSpkiFingerprint, + getSpkiFingerprints, + normalizeCertificateAuthorities, + splitPemCertificates, +} from '../../src/core/certs'; + +const CA_DIR = join(__dirname, '..', 'fixtures', 'ca'); +const localhostCA = readFileSync(join(CA_DIR, 'localhost-ca.crt'), 'utf-8'); +const selfSigned = readFileSync(join(CA_DIR, 'selfsigned.cert'), 'utf-8'); + +// Pre-computed via: +// openssl x509 -in -pubkey -noout | openssl pkey -pubin -outform der \ +// | openssl dgst -sha256 -binary | openssl enc -base64 +const LOCALHOST_CA_SPKI = 'i5ldWK8mZc2VpuB/HbP4QqNvC9izca4MRl+tWlgevP4='; +const SELF_SIGNED_SPKI = 'lKbtU5NxDdWZVzUHjMAVxT3j71kHJmv04kPyf3D0Khc='; + +describe('certs', () => { + describe('normalizeCertificateAuthorities', () => { + it('returns an empty list when nothing is provided', () => { + expect(normalizeCertificateAuthorities(undefined)).toEqual([]); + expect(normalizeCertificateAuthorities('')).toEqual([]); + expect(normalizeCertificateAuthorities(' ')).toEqual([]); + }); + + it('wraps a single string entry', () => { + expect(normalizeCertificateAuthorities(localhostCA)).toEqual([ + localhostCA, + ]); + }); + + it('converts Buffers to strings', () => { + expect(normalizeCertificateAuthorities(Buffer.from(localhostCA))).toEqual( + [localhostCA] + ); + }); + + it('flattens arrays of strings and Buffers', () => { + const result = normalizeCertificateAuthorities([ + localhostCA, + Buffer.from(selfSigned), + ]); + expect(result).toEqual([localhostCA, selfSigned]); + }); + }); + + describe('splitPemCertificates', () => { + it('splits a bundle into individual certificates', () => { + const bundle = `${localhostCA}\n${selfSigned}`; + expect(splitPemCertificates(bundle)).toHaveLength(2); + }); + + it('returns an empty list when there are no PEM markers', () => { + expect(splitPemCertificates('not a certificate')).toEqual([]); + }); + }); + + describe('getSpkiFingerprint', () => { + it('computes the base64 SHA-256 SPKI fingerprint', () => { + expect(getSpkiFingerprint(localhostCA)).toBe(LOCALHOST_CA_SPKI); + expect(getSpkiFingerprint(selfSigned)).toBe(SELF_SIGNED_SPKI); + }); + }); + + describe('getSpkiFingerprints', () => { + it('returns an empty list when no CA is provided', () => { + expect(getSpkiFingerprints(undefined)).toEqual([]); + }); + + it('computes fingerprints for every certificate in the input', () => { + expect(getSpkiFingerprints([localhostCA, selfSigned])).toEqual([ + LOCALHOST_CA_SPKI, + SELF_SIGNED_SPKI, + ]); + }); + + it('handles a bundle that contains multiple certificates', () => { + const bundle = `${localhostCA}\n${selfSigned}`; + expect(getSpkiFingerprints(bundle)).toEqual([ + LOCALHOST_CA_SPKI, + SELF_SIGNED_SPKI, + ]); + }); + + it('de-duplicates repeated certificates', () => { + expect(getSpkiFingerprints([localhostCA, localhostCA])).toEqual([ + LOCALHOST_CA_SPKI, + ]); + }); + + it('skips invalid entries instead of throwing', () => { + expect( + getSpkiFingerprints( + '-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----' + ) + ).toEqual([]); + }); + }); +}); diff --git a/__tests__/fixtures/ca/localhost-ca.crt b/__tests__/fixtures/ca/localhost-ca.crt new file mode 100644 index 00000000..cccb4ee3 --- /dev/null +++ b/__tests__/fixtures/ca/localhost-ca.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDjzCCAnegAwIBAgIUFbSGf6touCKtVVVBAzdp5nZhmLcwDQYJKoZIhvcNAQEL +BQAwSDELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB0VsYXN0aWMxEzARBgNVBAsMClN5 +bnRoZXRpY3MxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yNjA2MTIxMTM0MjFaGA8y +MTI2MDUxOTExMzQyMVowSDELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB0VsYXN0aWMx +EzARBgNVBAsMClN5bnRoZXRpY3MxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAJqSzSNQmA7a9i7o/29Pm07lhFIzhLIz +SZx8k2n+jlH4fR4znGCjhoOyXSPkIxTYPm88Yy58VqehHKLppN1oAG06SDBDFJqw +yy4oo4ZMXNZ6cGzkxm2i7gCY2N8yFdBh5VNZjVan/4dz3+mC+nbDhEDl5m19XwbI +oYEma61C0vrHOGtmSwS8y1Xeo8RTOkiW8PosWlpv+c68in4i8PyEN7LVG4m7hWR3 +99Tuc1QTKMr5kGdYrV+ddMOODLlrLWeI6BhYBXkwZ5g3pPVyuTQ+FQfXqFRaclnP +Ss9R7Nq492cjeNu/Np+5y56Bn4FdYRTIplMbN009nCMR19yO74H8bd0CAwEAAaNv +MG0wHQYDVR0OBBYEFGBiO2kdnAMlwKQ79Dce8KhE7ZFdMB8GA1UdIwQYMBaAFGBi +O2kdnAMlwKQ79Dce8KhE7ZFdMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAmt9Mhb5BBASxnMrYA +DsZQsUMTeKeg3K53ySUNELL0i06lZeRhMNDyNkaN0Z/EBcHXLoJG21uO8Jjx75N/ +f3rzEFjjuXPy7uPOvRfHT2jiRqgBxcGOdsimw6Ttzy8jUOSMjjr9/4reSMLZ3hve +otjq/ba/3XcQYla25ErqYnJN+K2ww6kEQh9eDmCL6ThET+38COyNs/O1kAQURLeS +MLq/KKg6dB9r8P/9X9ODwnzNntmnadlsphHwA+UUE/iH/trrH+LO/zSpQLUkjDVm +2/U1AdM71HqSPpiaxl08i0yJb48hOar56RPwpH4BRbnNBKvvPYn6A08+HbQrGIlJ +Zc8m +-----END CERTIFICATE----- diff --git a/__tests__/fixtures/ca/localhost-ca.key b/__tests__/fixtures/ca/localhost-ca.key new file mode 100644 index 00000000..79792b46 --- /dev/null +++ b/__tests__/fixtures/ca/localhost-ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCaks0jUJgO2vYu +6P9vT5tO5YRSM4SyM0mcfJNp/o5R+H0eM5xgo4aDsl0j5CMU2D5vPGMufFanoRyi +6aTdaABtOkgwQxSasMsuKKOGTFzWenBs5MZtou4AmNjfMhXQYeVTWY1Wp/+Hc9/p +gvp2w4RA5eZtfV8GyKGBJmutQtL6xzhrZksEvMtV3qPEUzpIlvD6LFpab/nOvIp+ +IvD8hDey1RuJu4Vkd/fU7nNUEyjK+ZBnWK1fnXTDjgy5ay1niOgYWAV5MGeYN6T1 +crk0PhUH16hUWnJZz0rPUezauPdnI3jbvzafucuegZ+BXWEUyKZTGzdNPZwjEdfc +ju+B/G3dAgMBAAECggEAA5+T0/NYcSa0VSKslC8lSJrsOGIRRv0ZSWQJL5eiPe0p +f6r9jC1hIpashMIdCWjNQ93CRG1xOZI3AeGgtfjxY54VYZ487ihdRup4dNY26ecq +Wf8d4KWMVzK+CLNbnAwjbEoB3GlpPcNbWRWzKdQI9l19Qo1JDDdpQ8YDCadymyLF +hApxBCMUOwnn76/q8ui1EmPLZbL55lGdOTAHCVn+xhD6XWPEeun3S5XT1IKMHjL2 +uk/g462QZTksTokfSFEygYlJC8OJkVcq1WtwFKblyT712RVp947TzxdkggHN2GEn +ltpMV7krc0/KaJCooyteik1V1KXbNtCcQfnEhXKLeQKBgQDaA91DXnnIMpZrFnJX +s4rfDXcBzXxn0ih79TUy8eh856mnEH4iAxP9UxLtZ7dKGKSGCmoHXxdHy6GAZfaq +cGE1V5R0flPLcKE4GiUM8tPD+1d1qE2l+nKg1h+Hm5SYUTvdXHpS+wdZu2I8fU9g +ukkXfsOgyW0DSrGdDnslrnYhhQKBgQC1gT8dof8g8lnDrscTL7719GVk918z8+kp +MEa7b+DSkjhsNI/wKR30f+uzhrqqn1JYQ0CEqivC8BSetHOAGdxyxCGpjRL2aQmr +SlLMBu2uN9Pggu4A07Xr7z+LIdhIjwKRCg6S5yR7DjNOIGnz4lmczHKPuiZbKPmx +UCKerCUeeQKBgQCpIXpTu0VK1EPxC9bkxrqjU/TRBzLN8DHMCGye+yBbVfU9UAQq +Kq0lR39VgbKl3vwzqHyc/142Knk9/NwhWVFwamMXBLHJD7ViqpW3t+IlFiXBdzHV +47dtou+O6lma2NpSXl3fZjNLn50URDqCVcJCYesuH1VcymOD7ioHD5NBaQKBgQCc +mfw79AathYyfgpAu5LYtfgVx3OFecOdOa2HL3RaseA6Ihb1fKAq5ZxmVVvx9tGMW +1ke3bx+83OndmLJC541F1CV7JcYjprL6AHF5qcyu0WpUvoLdYc0H2PAJelUjHYjI +XVX/t8DJD5KCqQLhsv1AhUGe8qyfYLY3H34PS1XneQKBgQC/y2QVx/8PkWpBVLyI +SF8V7i0bi/JSwXGYz6ixWBueK8sV00aWUDmhPSBNBunHNRJL763GYbwrqckiNRgz +A2ThGVc4e/dC2m3XUaqKK65xEEgzmXTnYnY9c1PBRfqqZJv3mtDIxaz762rBvj8y +OAivmF593o3TFeSAOpRFZLyQMA== +-----END PRIVATE KEY----- diff --git a/__tests__/options.test.ts b/__tests__/options.test.ts index 9ed57560..c0ca512b 100644 --- a/__tests__/options.test.ts +++ b/__tests__/options.test.ts @@ -31,6 +31,7 @@ import { parsePlaywrightOptions, } from '../src/options'; import { join } from 'path'; +import { readFileSync } from 'fs'; describe('options', () => { it('normalize', async () => { @@ -193,6 +194,33 @@ describe('options', () => { }); }); + describe('certificateAuthorities', () => { + const caPath = join(__dirname, 'fixtures', 'ca', 'localhost-ca.crt'); + + it('is undefined when not provided', async () => { + const options = await normalizeOptions({}); + expect(options.certificateAuthorities).toBeUndefined(); + }); + + it('keeps inline PEM content as-is', async () => { + const pem = readFileSync(caPath, 'utf-8'); + const options = await normalizeOptions({ + certificateAuthorities: pem, + } as CliArgs); + expect(options.certificateAuthorities).toEqual([pem]); + }); + + it('resolves file paths to PEM content', async () => { + const options = await normalizeOptions({ + // CLI variadic option yields an array of paths/strings + certificateAuthorities: [caPath], + } as CliArgs); + expect(options.certificateAuthorities).toEqual([ + readFileSync(caPath, 'utf-8'), + ]); + }); + }); + describe('parseFileOption', () => { it('parses file', () => { expect( diff --git a/examples/internal-ca/README.md b/examples/internal-ca/README.md new file mode 100644 index 00000000..5c5599d4 --- /dev/null +++ b/examples/internal-ca/README.md @@ -0,0 +1,135 @@ +# Internal / private CA example + +This example shows how to run an Elastic Synthetics **browser monitor** against +an internal HTTPS site whose certificate is signed by a **private / internal +Certificate Authority (CA)** — without rebuilding the agent image and without +disabling TLS validation. + +## The problem + +Chromium on Linux validates certificates against its own **NSS trust store**, +not the operating system store. So even if your internal root CA is installed on +the host (`/etc/ssl/certs`, `update-ca-certificates`, …), Chromium still fails +with: + +``` +net::ERR_CERT_AUTHORITY_INVALID +``` + +Historically the only options were to bake the CA into a custom agent image, or +set `ignoreHTTPSErrors: true` — which turns off validation for **every** request. + +## The fix: `certificateAuthorities` + +Configure the CA(s) you trust and the Synthetics runner computes each CA's +SHA-256 **SPKI fingerprint** and passes them to Chromium via +`--ignore-certificate-errors-spki-list`. Chromium then trusts certificates +chaining to those public keys and nothing else: + +```ts +// synthetics.config.ts +import type { SyntheticsConfig } from '@elastic/synthetics'; + +export default (): SyntheticsConfig => ({ + // Path to a PEM file, inline PEM, a Buffer, or an array of any of these. + certificateAuthorities: ['./certs/internal-ca.crt'], +}); +``` + +Or per-run from the CLI (variadic — pass more than one): + +```sh +npx @elastic/synthetics . --certificate-authorities ./certs/internal-ca.crt +``` + +Unlike the Kerberos example, this works from **both** Elastic's managed global +locations and Private Locations, because it is a launch-time Chromium flag and +does not touch the host trust store. + +## Files + +| File | Purpose | +|---|---| +| `synthetics.config.ts` | Declares the trusted internal CA. | +| `internal-site.journey.ts` | Navigates to the internal HTTPS URL and asserts a successful response. | + +## Running + +```sh +npm install +npx @elastic/synthetics . \ + --certificate-authorities ./certs/internal-ca.crt \ + --params '{"url":"https://internal.corp.local/"}' +``` + +## Testing it locally end-to-end + +You can prove the behaviour with a throwaway CA and a tiny HTTPS server — no +internal infrastructure required. + +1. **Generate a private CA and a server cert signed by it:** + +```sh +mkdir -p certs && cd certs + +# Root CA +openssl req -x509 -newkey rsa:2048 -nodes -keyout internal-ca.key \ + -out internal-ca.crt -days 3650 -subj "/CN=Example Internal CA" + +# Server key + CSR for localhost +openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr \ + -subj "/CN=localhost" + +# Sign the server cert with the CA (incl. SAN so Chromium is happy) +openssl x509 -req -in server.csr -CA internal-ca.crt -CAkey internal-ca.key \ + -CAcreateserial -out server.crt -days 825 \ + -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1") +cd .. +``` + +2. **Serve a page over HTTPS with the signed cert** (in a separate terminal): + +```sh +node -e "require('https').createServer({key:require('fs').readFileSync('certs/server.key'),cert:require('fs').readFileSync('certs/server.crt')},(_,res)=>res.end('

internal ok

')).listen(8443,()=>console.log('https://localhost:8443'))" +``` + +3. **Run the journey against it.** First WITHOUT the CA to see it fail: + +```sh +npx @elastic/synthetics . --params '{"url":"https://localhost:8443/"}' +# -> step fails with net::ERR_CERT_AUTHORITY_INVALID +``` + + Now WITH the CA — it passes: + +```sh +npx @elastic/synthetics . \ + --certificate-authorities ./certs/internal-ca.crt \ + --params '{"url":"https://localhost:8443/"}' +# -> journey succeeds +``` + +### Confirming the flag reached Chromium + +Run a journey in non-headless mode and inspect the browser process: + +```sh +ps -ef | grep -E 'chrome|headless_shell' | grep -- '--ignore-certificate-errors-spki-list' +``` + +You should see `--ignore-certificate-errors-spki-list=` on the main +browser process command line. + +## Security notes & limitations + +- **Targeted, not blanket.** Only certificates chaining to the SPKI hashes you + provide are trusted; every other endpoint is validated normally. This is much + safer than `ignoreHTTPSErrors: true`. +- **SPKI pinning bypasses *all* cert errors for the pinned keys** — including + expiry and hostname mismatch — because it matches on the public key. Trust + only CAs you control. +- **Rotate carefully.** If the CA's key pair changes, update + `certificateAuthorities` with the new CA so the new SPKI hash is pinned. +- **Lightweight (HTTP/TCP/ICMP) monitors** are unaffected by this setting; it + applies to browser monitors. The CLI side (e.g. `push` talking to a + Kibana fronted by an internal CA) is covered separately. diff --git a/examples/internal-ca/internal-site.journey.ts b/examples/internal-ca/internal-site.journey.ts new file mode 100644 index 00000000..671e2eb0 --- /dev/null +++ b/examples/internal-ca/internal-site.journey.ts @@ -0,0 +1,32 @@ +import { journey, step, expect } from '@elastic/synthetics'; + +/** + * Journey that loads an internal HTTPS site served with a certificate issued + * by a private / internal CA. + * + * With `certificateAuthorities` configured (see synthetics.config.ts), the + * Synthetics runner pins the CA's SPKI fingerprint via + * `--ignore-certificate-errors-spki-list`, so Chromium trusts the certificate + * and `page.goto` resolves instead of failing with + * `net::ERR_CERT_AUTHORITY_INVALID`. + * + * Remove the `certificateAuthorities` setting (or point it at the wrong CA) and + * this journey fails — proof that validation is still happening, just scoped to + * the keys you trust. + */ +journey('internal site (private CA)', ({ page, params }) => { + step('navigate to the internal HTTPS URL', async () => { + const response = await page.goto(params.url, { + waitUntil: 'domcontentloaded', + }); + expect( + response?.status(), + 'expected the internal CA to be trusted' + ).toBeLessThan(400); + }); + + step('assert page content rendered', async () => { + // Replace with a selector that is unique to your internal app. + await expect(page.locator('body')).toBeVisible(); + }); +}); diff --git a/examples/internal-ca/package.json b/examples/internal-ca/package.json new file mode 100644 index 00000000..610e650d --- /dev/null +++ b/examples/internal-ca/package.json @@ -0,0 +1,12 @@ +{ + "name": "synthetics-internal-ca-example", + "version": "1.0.0", + "private": true, + "description": "Example: Elastic Synthetics browser monitor against an internal HTTPS site signed by a private CA", + "scripts": { + "test": "npx @elastic/synthetics ." + }, + "dependencies": { + "@elastic/synthetics": "*" + } +} diff --git a/examples/internal-ca/synthetics.config.ts b/examples/internal-ca/synthetics.config.ts new file mode 100644 index 00000000..031d05ae --- /dev/null +++ b/examples/internal-ca/synthetics.config.ts @@ -0,0 +1,53 @@ +import type { SyntheticsConfig } from '@elastic/synthetics'; + +/** + * Example configuration that lets a browser monitor reach an internal HTTPS + * site whose certificate is issued by a private / internal Certificate + * Authority (CA). + * + * Why this is needed: + * + * Chromium on Linux validates server certificates against its own NSS trust + * store rather than the operating system store. That means dropping your + * internal root CA into `/etc/ssl/certs` (or installing it on the host) is + * NOT enough — Chromium still reports `ERR_CERT_AUTHORITY_INVALID`. Until now + * the only workarounds were rebuilding the agent image with the CA baked in + * or turning off validation entirely with `ignoreHTTPSErrors`. + * + * What `certificateAuthorities` does: + * + * The Synthetics runner computes the SHA-256 SPKI fingerprint of each CA you + * pass here and forwards them to Chromium via + * `--ignore-certificate-errors-spki-list`. Chromium then trusts certificates + * chaining to those public keys WITHOUT disabling validation for any other + * endpoint (unlike `ignoreHTTPSErrors`, which blindly accepts every cert). + * + * Each entry can be either: + * - a path to a PEM file (resolved relative to where you run the CLI), or + * - inline PEM content (handy for params / secrets), or + * - a Buffer / array of the above. + * + * Unlike the Kerberos example, this works from BOTH Elastic's managed global + * locations and Private Locations, because it is a launch-time Chromium flag + * and does not touch the host trust store. + */ +export default () => { + const config: SyntheticsConfig = { + params: { + // Override with the real internal URL signed by your private CA. + url: 'https://internal.corp.local/', + }, + // Trust an internal CA. Each entry is a path to a PEM file or inline PEM. + // To inline the content instead of a path: + // certificateAuthorities: [readFileSync('./certs/internal-ca.crt', 'utf-8')] + certificateAuthorities: ['./certs/internal-ca.crt'], + + monitor: { + schedule: 10, + // Works on managed global locations too — swap/add as needed. + locations: ['us_east'], + // privateLocations: ['my-private-location'], + }, + }; + return config; +}; diff --git a/src/cli.ts b/src/cli.ts index 1a7e0743..35a9a1af 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -74,6 +74,7 @@ const { match, fields, maintenanceWindows, + certificateAuthorities, } = getCommonCommandOpts(); program @@ -128,6 +129,7 @@ program '--ignore-https-errors', 'ignores any HTTPS errors in sites being tested, including ones related to unrecognized certs or signatures. This can be insecure!' ) + .addOption(certificateAuthorities) .option( '--quiet-exit-code', 'always return 0 as an exit code status, regardless of test pass / fail. Only return > 0 exit codes on internal errors where the suite could not be run' @@ -232,6 +234,7 @@ program .addOption(match) .addOption(params) .addOption(playwrightOpts) + .addOption(certificateAuthorities) .addOption(configOpt) .addOption(maintenanceWindows) .action(async cmdOpts => { diff --git a/src/common_types.ts b/src/common_types.ts index 4702e73e..daecf0e9 100644 --- a/src/common_types.ts +++ b/src/common_types.ts @@ -208,6 +208,14 @@ type GrepOptions = { match?: string; }; +/** + * Custom certificate authorities (PEM content or Buffer, optionally an array) + * that browser monitors and the CLI should trust in addition to the public + * roots. Used to support internal / private CAs without rebuilding the agent + * image. + */ +export type CertificateAuthorities = string | Buffer | Array; + type BaseArgs = { params?: Params; screenshots?: ScreenshotOptions; @@ -217,6 +225,7 @@ type BaseArgs = { outfd?: number; wsEndpoint?: string; pauseOnError?: boolean; + certificateAuthorities?: CertificateAuthorities; playwrightOptions?: PlaywrightOptions; quietExitCode?: boolean; throttling?: MonitorConfig['throttling']; @@ -289,6 +298,7 @@ export type SyntheticsConfig = { monitor?: MonitorConfig; project?: ProjectSettings; proxy?: ProxySettings; + certificateAuthorities?: CertificateAuthorities; }; /** Runner Payload types */ diff --git a/src/core/certs.ts b/src/core/certs.ts new file mode 100644 index 00000000..c06f710e --- /dev/null +++ b/src/core/certs.ts @@ -0,0 +1,92 @@ +/** + * MIT License + * + * Copyright (c) 2020-present, Elastic NV + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +import { X509Certificate, createHash } from 'crypto'; +import { CertificateAuthorities } from '../common_types'; + +const PEM_CERTIFICATE_RE = + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + +/** + * Normalize the user provided certificate authorities (string, Buffer or an + * array of either) into a flat list of PEM strings. Each entry may itself be a + * bundle that contains more than one certificate. + */ +export function normalizeCertificateAuthorities( + ca?: CertificateAuthorities +): string[] { + if (ca == null) { + return []; + } + const entries = Array.isArray(ca) ? ca : [ca]; + return entries + .map(entry => (Buffer.isBuffer(entry) ? entry.toString('utf-8') : entry)) + .filter((entry): entry is string => Boolean(entry && entry.trim())); +} + +/** + * Split a PEM bundle into the individual certificates it contains. Node's + * `X509Certificate` only parses the first certificate of a bundle, so we have + * to slice them apart ourselves to support full chains / multiple CAs. + */ +export function splitPemCertificates(pem: string): string[] { + return pem.match(PEM_CERTIFICATE_RE) ?? []; +} + +/** + * Compute the base64 encoded SHA-256 fingerprint of a certificate's + * SubjectPublicKeyInfo (SPKI). This is the exact value Chromium expects in the + * `--ignore-certificate-errors-spki-list` switch. + */ +export function getSpkiFingerprint(pem: string): string { + const der = new X509Certificate(pem).publicKey.export({ + type: 'spki', + format: 'der', + }); + return createHash('sha256').update(der).digest('base64'); +} + +/** + * Build the list of SPKI fingerprints for all certificates contained in the + * provided certificate authorities. Invalid certificates are skipped with a + * warning so a single bad entry never aborts the whole run. + */ +export function getSpkiFingerprints(ca?: CertificateAuthorities): string[] { + const fingerprints = new Set(); + for (const entry of normalizeCertificateAuthorities(ca)) { + const certificates = splitPemCertificates(entry); + // Fall back to treating the whole entry as a single certificate when no + // PEM boundary markers are present. + const candidates = certificates.length > 0 ? certificates : [entry]; + for (const certificate of candidates) { + try { + fingerprints.add(getSpkiFingerprint(certificate)); + } catch { + // Skip invalid entries so a single bad certificate never aborts a run. + } + } + } + return [...fingerprints]; +} diff --git a/src/core/gatherer.ts b/src/core/gatherer.ts index 1b12f96f..4a6f7112 100644 --- a/src/core/gatherer.ts +++ b/src/core/gatherer.ts @@ -32,6 +32,7 @@ import { } from 'playwright-core'; import { PluginManager } from '../plugins'; import { log } from './logger'; +import { getSpkiFingerprints } from './certs'; import { Driver, NetworkConditions, RunOptions } from '../common_types'; // Default timeout for Playwright actions and Navigations @@ -56,10 +57,32 @@ export class Gatherer { log(`Gatherer: connecting to WS endpoint: ${wsEndpoint}`); Gatherer.browser = await chromium.connect(wsEndpoint); } else { + /** + * Chromium on Linux trusts its own NSS store rather than the system CA + * store, so internal/private CAs are rejected even when present on the + * host. Pin the SPKI fingerprints of the user provided CAs so Chromium + * trusts certificates issued by them without disabling validation for + * every other endpoint (unlike `ignoreHTTPSErrors`). + */ + const spkiFingerprints = getSpkiFingerprints( + options.certificateAuthorities + ); + if (spkiFingerprints.length > 0) { + log( + `Gatherer: trusting ${spkiFingerprints.length} custom certificate authority public key(s)` + ); + } Gatherer.browser = await chromium.launch({ ...playwrightOptions, args: [ ...(playwrightOptions?.headless ? ['--disable-gpu'] : []), + ...(spkiFingerprints.length > 0 + ? [ + `--ignore-certificate-errors-spki-list=${spkiFingerprints.join( + ',' + )}`, + ] + : []), ...(playwrightOptions?.args ?? []), ], }); diff --git a/src/options.ts b/src/options.ts index 6be22f1b..b8028076 100644 --- a/src/options.ts +++ b/src/options.ts @@ -28,6 +28,7 @@ import { createOption } from 'commander'; import { readConfig } from './config'; import type { CliArgs, RunOptions } from './common_types'; import { isFile, THROTTLING_WARNING_MSG, warn } from './helpers'; +import { normalizeCertificateAuthorities } from './core/certs'; import { readFileSync } from 'fs'; type Mode = 'run' | 'push'; @@ -103,6 +104,19 @@ export async function normalizeOptions( merge(config?.proxy ?? {}, cliArgs?.proxy || {}) ); + /** + * Merge custom certificate authorities from the Synthetics config and the + * CLI. Each entry can be inline PEM content or a path to a PEM file that we + * resolve here so downstream consumers only ever deal with PEM strings. + */ + const certificateAuthorities = [ + ...normalizeCertificateAuthorities(config.certificateAuthorities), + ...normalizeCertificateAuthorities(cliArgs.certificateAuthorities), + ].map(entry => (isFile(entry) ? readFileSync(entry, 'utf-8') : entry)); + options.certificateAuthorities = certificateAuthorities.length + ? certificateAuthorities + : undefined; + /** * Merge default options based on the mode of operation whether we are running tests locally * or pushing the project monitors @@ -265,6 +279,11 @@ export function getCommonCommandOpts() { "List of Kibana's Maintenance Windows IDs assigned by default. More information on https://www.elastic.co/docs/explore-analyze/alerts-cases/alerts/maintenance-windows." ); + const certificateAuthorities = createOption( + '--certificate-authorities ', + 'One or more trusted CA certificates, each provided as inline PEM content or a path to a PEM file. Lets browser monitors and the CLI trust internal/private CAs without rebuilding the agent image.' + ); + return { auth, authMandatory, @@ -276,6 +295,7 @@ export function getCommonCommandOpts() { match, fields, maintenanceWindows, + certificateAuthorities, }; }