Skip to content
Draft
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
20 changes: 20 additions & 0 deletions __tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
126 changes: 126 additions & 0 deletions __tests__/core/certs.test.ts
Original file line number Diff line number Diff line change
@@ -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 <cert> -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([]);
});
});
});
22 changes: 22 additions & 0 deletions __tests__/fixtures/ca/localhost-ca.crt
Original file line number Diff line number Diff line change
@@ -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-----
28 changes: 28 additions & 0 deletions __tests__/fixtures/ca/localhost-ca.key
Original file line number Diff line number Diff line change
@@ -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-----
28 changes: 28 additions & 0 deletions __tests__/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
parsePlaywrightOptions,
} from '../src/options';
import { join } from 'path';
import { readFileSync } from 'fs';

describe('options', () => {
it('normalize', async () => {
Expand Down Expand Up @@ -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(
Expand Down
135 changes: 135 additions & 0 deletions examples/internal-ca/README.md
Original file line number Diff line number Diff line change
@@ -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('<h1>internal ok</h1>')).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=<base64-hash>` 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.
Loading
Loading