diff --git a/AGENTS.md b/AGENTS.md index 350a04f..6fbf7e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,8 @@ Prettier + ESLint run on commit via `lint-staged` (Husky pre-commit hook). - `LoadNetworkEngine/` — parallel fetch load generator - `ReachabilityEngine/` — simple fetch with timeout - `src/Results/` — aggregation, stats (percentile, jitter), and AIM scoring. -- `src/utils/` — small math helpers (`sum`, `avg`, `percentile`, `scaleThreshold`). +- `src/utils/` — small helpers: math (`sum`, `avg`, `percentile`, `scaleThreshold`) + and `authorization` (attaches the `authorizationToken` to the API URLs). - `example/turn-worker/` — separate Cloudflare Worker sub-project with its own `package.json` and Prettier config; not part of the library build. diff --git a/README.md b/README.md index c6b5da3..4d78a8a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ new SpeedTest({ configOptions }) | **turnServerCredsApiUrl**: *string* | A URI that returns TURN server credentials. Expects a JSON response with `username` and `credential` keys. | - | | **turnServerUser**: *string* | The username for the TURN server credentials. | - | | **turnServerPass**: *string* | The password for the TURN server credentials. | - | +| **authorizationToken**: *string* | An opaque token attributing the test to a registered customer, sent as a `jwt` query-string parameter on the measurement, TURN credential and results-logging requests. Obtain it from your own backend — the engine never requests one itself. Never sent over plain HTTP. | `null` | | **measurements**: *array* | The sequence of measurements to perform by the speedtest engine. See [below](#measurement-config) for the specific syntax of this option. || | **measureDownloadLoadedLatency**: *boolean* | Whether to perform additional latency measurements simultaneously with download requests, to measure loaded latency (during download). | `true` | | **measureUploadLoadedLatency**: *boolean* | Whether to perform additional latency measurements simultaneously with upload requests, to measure loaded latency (during upload). | `true` | diff --git a/src/config/defaultConfig.ts b/src/config/defaultConfig.ts index af3b6b5..4def431 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -67,6 +67,12 @@ export interface Config { includeCredentials: boolean; /** Optional session ID attached to measurement logs. */ sessionId: string | undefined; + /** + * Opaque token attributing this test to a registered customer, sent as a + * `jwt` query-string param on the measurement, TURN credential and + * results-logging requests. Not sent over plain HTTP. Default: `null`. + */ + authorizationToken: string | null; /** * Ordered list of measurement phases to execute. @@ -140,6 +146,7 @@ const defaultConfig: Config = { rpkiInvalidHost: 'invalid.rpki.cloudflare.com', includeCredentials: false, sessionId: undefined, + authorizationToken: null, // Measurements measurements: [ diff --git a/src/engines/BandwidthEngine/BandwidthEngine.ts b/src/engines/BandwidthEngine/BandwidthEngine.ts index 6a51739..997003f 100644 --- a/src/engines/BandwidthEngine/BandwidthEngine.ts +++ b/src/engines/BandwidthEngine/BandwidthEngine.ts @@ -1,4 +1,5 @@ import type { Engine } from '../Engine'; +import { redactAuthorizationToken } from '../../utils/authorization'; const MAX_RETRIES = 20; @@ -553,7 +554,8 @@ class BandwidthMeasurementEngine implements Engine { if (this.#currentAbortController!.signal.aborted) { return; } - console.warn(`Error fetching ${url}: ${error}`); + const safeUrl = redactAuthorizationToken(url); + console.warn(`Error fetching ${safeUrl}: ${error}`); if (this.#retries++ < MAX_RETRIES) { this.#nextMeasurement(); // keep trying @@ -561,7 +563,7 @@ class BandwidthMeasurementEngine implements Engine { this.#retries = 0; this.#setRunning(false); this.#onConnectionError( - `Connection failed to ${url}. Gave up after ${MAX_RETRIES} retries.` + `Connection failed to ${safeUrl}. Gave up after ${MAX_RETRIES} retries.` ); } }); diff --git a/src/engines/BandwidthEngine/LoggingBandwidthEngine.ts b/src/engines/BandwidthEngine/LoggingBandwidthEngine.ts index 02b0baa..c9ce646 100644 --- a/src/engines/BandwidthEngine/LoggingBandwidthEngine.ts +++ b/src/engines/BandwidthEngine/LoggingBandwidthEngine.ts @@ -110,11 +110,13 @@ class LoggingBandwidthEngine extends BandwidthEngine { this.#token = null; this.#requestTime = null; + // Swallowed: logging is best-effort, and an unhandled rejection would print + // the URL, which carries the authorization token. fetch(this.#logApiUrl, { method: 'POST', body: JSON.stringify(logData), ...this.fetchOptions - }); + }).catch(() => {}); } } diff --git a/src/index.ts b/src/index.ts index 87bc8c5..e6f677a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import Results from './Results'; import logFinalResults, { type AimLogResponse } from './logging/logFinalResults'; +import { applyAuthorizationToken } from './utils/authorization'; const DEFAULT_OPTIMAL_DOWNLOAD_SIZE = 1e6; const DEFAULT_OPTIMAL_UPLOAD_SIZE = 1e6; @@ -122,12 +123,14 @@ const genMeasId = (): string => `${Math.round(Math.random() * 1e16)}`; */ class MeasurementEngine { constructor(userConfig: ConfigOptions = {}) { - this.#config = Object.assign( - {}, - defaultConfig, - userConfig, - internalConfig - ) as SpeedTestConfig; + this.#config = applyAuthorizationToken( + Object.assign( + {}, + defaultConfig, + userConfig, + internalConfig + ) as SpeedTestConfig + ); this.#results = new Results(this.#config); this.#config.autoStart && this.play(); } @@ -136,6 +139,11 @@ class MeasurementEngine { return this.#results; } + /** The merged config, so subclasses need not rebuild it. */ + protected get config(): SpeedTestConfig { + return this.#config; + } + /** Not paused and not finished. */ get isRunning(): boolean { return this.#running; @@ -735,16 +743,6 @@ class SpeedTestEngine extends MeasurementEngine { constructor(userConfig: ConfigOptions = {}) { super(userConfig); super.onFinish = this.#logFinalResults; - - const config = Object.assign( - {}, - defaultConfig, - userConfig, - internalConfig - ) as SpeedTestConfig; - - this.#logAimApiUrl = config.logAimApiUrl; - this.#sessionId = config.sessionId; } // Public attributes @@ -773,18 +771,15 @@ class SpeedTestEngine extends MeasurementEngine { */ onResultsLogged: (response: AimLogResponse) => void = () => {}; - // Internal state - readonly #logAimApiUrl: string | null; - readonly #sessionId: string | undefined; - // Internal methods #logFinalResults = (results: Results): void => { - if (!this.#logAimApiUrl) { + const apiUrl = this.config.logAimApiUrl; + if (!apiUrl) { return; } logFinalResults(results, { - apiUrl: this.#logAimApiUrl, - sessionId: this.#sessionId + apiUrl, + sessionId: this.config.sessionId }).then(response => { this.onResultsLogged(response); }); diff --git a/src/utils/authorization.ts b/src/utils/authorization.ts new file mode 100644 index 0000000..686486d --- /dev/null +++ b/src/utils/authorization.ts @@ -0,0 +1,103 @@ +/** + * Query-string parameter carrying the authorization token. + * + * Named `jwt`, not `token`: the measurement log POST body already has a + * `token` field holding a server-issued per-measurement value, and both are + * sent to `logMeasurementApiUrl`. + */ +export const AUTHORIZATION_TOKEN_PARAM = 'jwt'; + +/** Placeholder substituted for the token in error messages. */ +const REDACTED = 'REDACTED'; + +/** + * Appends the authorization token to `apiUrl`, preserving existing params. + * + * Query string rather than a header, which would trigger a CORS preflight and + * suppress BandwidthEngine's server-time calibration. Never over plain HTTP. + */ +export const withAuthorizationToken = ( + apiUrl: string, + token: string | null +): string => { + if (!token) return apiUrl; + + // Only relative URLs need the page origin, so absolute ones work without a DOM. + let urlObj: URL; + try { + urlObj = new URL(apiUrl); + } catch { + urlObj = new URL(apiUrl, window.location.origin); + } + + if (urlObj.protocol !== 'https:') return apiUrl; + + urlObj.searchParams.set(AUTHORIZATION_TOKEN_PARAM, token); + return urlObj.href; +}; + +/** + * Masks the authorization token in a URL bound for a log or an error callback. + * + * Consumers routinely forward `onError` payloads to third-party log sinks, so + * the credential must not travel with them. Returns `apiUrl` untouched when + * there is no token to mask. + */ +export const redactAuthorizationToken = (apiUrl: string): string => { + let urlObj: URL; + try { + urlObj = new URL(apiUrl); + } catch { + try { + urlObj = new URL(apiUrl, window.location.origin); + } catch { + return apiUrl; + } + } + + if (!urlObj.searchParams.has(AUTHORIZATION_TOKEN_PARAM)) return apiUrl; + + urlObj.searchParams.set(AUTHORIZATION_TOKEN_PARAM, REDACTED); + return urlObj.href; +}; + +/** + * Config URLs that carry the authorization token. Single source of truth: a new + * measurement endpoint must be added here to be attributed. + * + * `turnServerUri` is excluded (not HTTP), as are the reachability, RPKI and + * NXDOMAIN probe hosts, which are unrelated to the measurement endpoints. + */ +export const AUTHORIZABLE_URLS = [ + 'downloadApiUrl', + 'uploadApiUrl', + 'turnServerCredsApiUrl', + 'logAimApiUrl', + 'logMeasurementApiUrl' +] as const; + +/** The config fields {@link applyAuthorizationToken} rewrites. */ +type AuthorizableUrls = { authorizationToken: string | null } & { + [K in (typeof AUTHORIZABLE_URLS)[number]]: string | null; +}; + +/** + * Attaches the token to every URL in {@link AUTHORIZABLE_URLS}, so the engines + * inherit it through the URLs they already receive. Mutates `config`, which is + * always a freshly merged object. + */ +export const applyAuthorizationToken = ( + config: T +): T => { + const token = config.authorizationToken; + if (!token) return config; + + // Widened to write through the union of keys; T only narrows the field types. + const urls = config as AuthorizableUrls; + for (const key of AUTHORIZABLE_URLS) { + const url = urls[key]; + if (url) urls[key] = withAuthorizationToken(url, token); + } + + return config; +}; diff --git a/tests/unit/config/authorizationToken.test.ts b/tests/unit/config/authorizationToken.test.ts new file mode 100644 index 0000000..2a6fdd9 --- /dev/null +++ b/tests/unit/config/authorizationToken.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import defaultConfig from '../../../src/config/defaultConfig.ts'; +import { + applyAuthorizationToken, + AUTHORIZABLE_URLS +} from '../../../src/utils/authorization.ts'; + +const TOKEN = 'test-token-123'; + +/** Merges user config over the defaults the way the engine constructors do. */ +const resolveConfig = (userConfig: Partial) => + applyAuthorizationToken(Object.assign({}, defaultConfig, userConfig)); + +describe('applyAuthorizationToken', () => { + beforeEach(() => { + vi.stubGlobal('window', { + location: { origin: 'https://speed.cloudflare.com' } + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('attaches the token to every URL in AUTHORIZABLE_URLS', () => { + const config = resolveConfig({ + authorizationToken: TOKEN, + // Null by default, so set it to cover every key in the list. + logMeasurementApiUrl: 'https://speed.cloudflare.com/__log' + }); + + for (const key of AUTHORIZABLE_URLS) { + expect(new URL(config[key]!).searchParams.get('jwt'), key).toBe(TOKEN); + } + }); + + it('leaves disabled logging endpoints null', () => { + const config = resolveConfig({ + authorizationToken: TOKEN, + logAimApiUrl: null, + logMeasurementApiUrl: null + }); + + expect(config.logAimApiUrl).toBeNull(); + expect(config.logMeasurementApiUrl).toBeNull(); + }); + + it('does not attach the token to excluded hosts', () => { + const config = resolveConfig({ authorizationToken: TOKEN }); + + expect(config.rpkiInvalidHost).toBe('invalid.rpki.cloudflare.com'); + expect(config.turnServerUri).toBe('turn.speed.cloudflare.com:50000'); + }); + + it('leaves every URL untouched when no token is configured', () => { + const config = resolveConfig({}); + + for (const key of AUTHORIZABLE_URLS) { + expect(config[key], key).toBe(defaultConfig[key]); + } + }); + + it('does not mutate the shared defaultConfig object', () => { + // applyAuthorizationToken mutates in place, so it must only ever be handed + // a freshly merged object — otherwise the token leaks across instances. + resolveConfig({ authorizationToken: TOKEN }); + + expect(defaultConfig.downloadApiUrl).toBe( + 'https://speed.cloudflare.com/__down' + ); + expect(defaultConfig.logAimApiUrl).toBe( + 'https://speed.cloudflare.com/__results' + ); + expect(defaultConfig.authorizationToken).toBeNull(); + }); + + it('does not attach the token over plain HTTP', () => { + const config = resolveConfig({ + authorizationToken: TOKEN, + downloadApiUrl: 'http://speed.cloudflare.com/__down', + uploadApiUrl: 'http://speed.cloudflare.com/__up' + }); + + expect(config.downloadApiUrl).toBe('http://speed.cloudflare.com/__down'); + expect(config.uploadApiUrl).toBe('http://speed.cloudflare.com/__up'); + // HTTPS endpoints in the same config are still attributed. + expect(new URL(config.logAimApiUrl!).searchParams.get('jwt')).toBe(TOKEN); + }); +}); diff --git a/tests/unit/config/defaultConfig.test.ts b/tests/unit/config/defaultConfig.test.ts index 8ea410e..d94c68c 100644 --- a/tests/unit/config/defaultConfig.test.ts +++ b/tests/unit/config/defaultConfig.test.ts @@ -56,4 +56,8 @@ describe('defaultConfig', () => { expect(defaultConfig.turnServerUser).toBeNull(); expect(defaultConfig.turnServerPass).toBeNull(); }); + + it('has no authorization token by default', () => { + expect(defaultConfig.authorizationToken).toBeNull(); + }); }); diff --git a/tests/unit/utils/authorization.test.ts b/tests/unit/utils/authorization.test.ts new file mode 100644 index 0000000..5181a3d --- /dev/null +++ b/tests/unit/utils/authorization.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + AUTHORIZATION_TOKEN_PARAM, + redactAuthorizationToken, + withAuthorizationToken +} from '../../../src/utils/authorization.ts'; + +/** The helper resolves relative URLs against the page origin, like the engines do. */ +const stubOrigin = (origin: string): void => { + vi.stubGlobal('window', { location: { origin } }); +}; + +const TOKEN = 'eyJhbGciOiJFUzI1NiJ9.payload.signature'; + +describe('withAuthorizationToken', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('appends the token as a query-string param', () => { + stubOrigin('https://speed.example.com'); + + const url = withAuthorizationToken( + 'https://speed.example.com/__down', + TOKEN + ); + + expect(new URL(url).searchParams.get(AUTHORIZATION_TOKEN_PARAM)).toBe( + TOKEN + ); + }); + + it('uses `jwt` as the param name', () => { + stubOrigin('https://speed.example.com'); + + expect( + withAuthorizationToken('https://speed.example.com/__up', 'abc') + ).toBe('https://speed.example.com/__up?jwt=abc'); + }); + + it('preserves query params already present on the URL', () => { + stubOrigin('https://speed.example.com'); + + const url = new URL( + withAuthorizationToken( + 'https://speed.example.com/__down?foo=bar&baz=1', + TOKEN + ) + ); + + expect(url.searchParams.get('foo')).toBe('bar'); + expect(url.searchParams.get('baz')).toBe('1'); + expect(url.searchParams.get(AUTHORIZATION_TOKEN_PARAM)).toBe(TOKEN); + }); + + it('url-encodes tokens containing reserved characters', () => { + stubOrigin('https://speed.example.com'); + + const url = withAuthorizationToken( + 'https://speed.example.com/__down', + 'a+b/c=d&e' + ); + + expect(url).not.toContain('a+b/c=d&e'); + expect(new URL(url).searchParams.get(AUTHORIZATION_TOKEN_PARAM)).toBe( + 'a+b/c=d&e' + ); + }); + + it('resolves relative URLs against the page origin', () => { + stubOrigin('https://speed.example.com'); + + expect(withAuthorizationToken('/__down', TOKEN)).toBe( + `https://speed.example.com/__down?jwt=${encodeURIComponent(TOKEN)}` + ); + }); + + it('returns the URL untouched when there is no token', () => { + stubOrigin('https://speed.example.com'); + + expect( + withAuthorizationToken('https://speed.example.com/__down', null) + ).toBe('https://speed.example.com/__down'); + expect(withAuthorizationToken('https://speed.example.com/__down', '')).toBe( + 'https://speed.example.com/__down' + ); + }); + + it('never attaches the token over plain HTTP', () => { + stubOrigin('https://speed.example.com'); + + expect( + withAuthorizationToken('http://speed.example.com/__down', TOKEN) + ).toBe('http://speed.example.com/__down'); + }); + + it('never attaches the token to a relative URL on an HTTP page', () => { + stubOrigin('http://speed.example.com'); + + expect(withAuthorizationToken('/__down', TOKEN)).toBe('/__down'); + }); + + it('tokenizes absolute URLs without a DOM', () => { + // Every default API URL is absolute, so constructing an engine with a token + // must not require a DOM. + expect(typeof window).toBe('undefined'); + expect( + withAuthorizationToken('https://speed.example.com/__down', 'abc') + ).toBe('https://speed.example.com/__down?jwt=abc'); + expect(() => withAuthorizationToken('/__down', null)).not.toThrow(); + }); +}); + +describe('redactAuthorizationToken', () => { + const tokenized = withAuthorizationToken( + 'https://speed.example.com/__down?bytes=100000', + TOKEN + ); + + it('masks the token', () => { + const redacted = redactAuthorizationToken(tokenized); + + expect(redacted).not.toContain(TOKEN); + expect(new URL(redacted).searchParams.get(AUTHORIZATION_TOKEN_PARAM)).toBe( + 'REDACTED' + ); + }); + + it('leaves the rest of the URL intact', () => { + const redacted = new URL(redactAuthorizationToken(tokenized)); + + expect(redacted.origin + redacted.pathname).toBe( + 'https://speed.example.com/__down' + ); + expect(redacted.searchParams.get('bytes')).toBe('100000'); + }); + + it('returns the URL untouched when it carries no token', () => { + const url = 'https://speed.example.com/__down?bytes=100000'; + + expect(redactAuthorizationToken(url)).toBe(url); + }); + + it('never throws on a URL it cannot parse', () => { + // Runs on the error path, so it must not mask the original failure. + expect(typeof window).toBe('undefined'); + expect(redactAuthorizationToken('/__down?jwt=abc')).toBe( + '/__down?jwt=abc' + ); + expect(redactAuthorizationToken('not a url')).toBe('not a url'); + }); +});