From 8fe66c3bb2189a1e5a031a77660e6d3085ed83bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Wed, 29 Jul 2026 12:49:18 +0100 Subject: [PATCH 1/6] feat: add authorizationToken config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `authorizationToken` that attributes a test to a registered customer, so usage can be accounted for rather than being anonymous. The token is opaque to the engine: consumers obtain it from their own backend and pass it in. It is attached as a `token` query-string parameter to the endpoints that bill against a customer — the download, upload and load-generator requests, the TURN credentials request, and the results-logging requests. The reachability, RPKI and NXDOMAIN probes are excluded, since they target unrelated hosts. Sent in the query string rather than a header to avoid a CORS preflight on the measurement endpoints. Beyond the extra round trip, a preflight reuses the TCP connection for the real request, which would suppress the server-time calibration in BandwidthEngine that depends on observing a fresh handshake. The token is never attached over plain HTTP, so that a token cannot be exposed in cleartext. Defaults to null, leaving existing behaviour unchanged. --- AGENTS.md | 3 +- README.md | 1 + src/config/defaultConfig.ts | 12 +++ src/index.ts | 29 ++--- src/utils/authorization.ts | 69 ++++++++++++ tests/unit/config/authorizationToken.test.ts | 101 +++++++++++++++++ tests/unit/config/defaultConfig.test.ts | 4 + tests/unit/utils/authorization.test.ts | 107 +++++++++++++++++++ 8 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 src/utils/authorization.ts create mode 100644 tests/unit/config/authorizationToken.test.ts create mode 100644 tests/unit/utils/authorization.test.ts diff --git a/AGENTS.md b/AGENTS.md index 350a04f..36c3f34 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 billed 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..41e3c38 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 `token` 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..60a5f32 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -67,6 +67,17 @@ export interface Config { includeCredentials: boolean; /** Optional session ID attached to measurement logs. */ sessionId: string | undefined; + /** + * Opaque authorization token attributing this test to a registered customer. + * + * Sent as a `token` 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 attached over plain HTTP, since + * a token seen in cleartext must be treated as compromised. + * + * Default: `null` (requests are unattributed). + */ + authorizationToken: string | null; /** * Ordered list of measurement phases to execute. @@ -140,6 +151,7 @@ const defaultConfig: Config = { rpkiInvalidHost: 'invalid.rpki.cloudflare.com', includeCredentials: false, sessionId: undefined, + authorizationToken: null, // Measurements measurements: [ diff --git a/src/index.ts b/src/index.ts index 87bc8c5..5495364 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(); } @@ -736,12 +739,14 @@ class SpeedTestEngine extends MeasurementEngine { super(userConfig); super.onFinish = this.#logFinalResults; - const config = Object.assign( - {}, - defaultConfig, - userConfig, - internalConfig - ) as SpeedTestConfig; + const config = applyAuthorizationToken( + Object.assign( + {}, + defaultConfig, + userConfig, + internalConfig + ) as SpeedTestConfig + ); this.#logAimApiUrl = config.logAimApiUrl; this.#sessionId = config.sessionId; diff --git a/src/utils/authorization.ts b/src/utils/authorization.ts new file mode 100644 index 0000000..7bd3aa9 --- /dev/null +++ b/src/utils/authorization.ts @@ -0,0 +1,69 @@ +/** Query-string parameter carrying the authorization token. */ +export const AUTHORIZATION_TOKEN_PARAM = 'token'; + +/** + * Returns `apiUrl` with the authorization token appended as a query-string + * parameter, preserving any params already present. + * + * The token is sent in the query string rather than a header to avoid a CORS + * preflight on the measurement endpoints: an extra round trip there would cost + * test time and, by reusing the TCP connection, suppress the server-time + * calibration in `BandwidthEngine` that relies on seeing a fresh handshake. + * + * Returns `apiUrl` untouched when there is no token, or when the resolved URL + * is not HTTPS — a token observed in cleartext must be treated as compromised, + * so it must never leave the client over plain HTTP. + */ +export const withAuthorizationToken = ( + apiUrl: string, + token: string | null +): string => { + if (!token) return apiUrl; + + const urlObj = new URL(apiUrl, window.location.origin); + if (urlObj.protocol !== 'https:') return apiUrl; + + urlObj.searchParams.set(AUTHORIZATION_TOKEN_PARAM, token); + return urlObj.href; +}; + +/** The config fields {@link applyAuthorizationToken} rewrites. */ +interface AuthorizableUrls { + authorizationToken: string | null; + downloadApiUrl: string; + uploadApiUrl: string; + turnServerCredsApiUrl: string; + logAimApiUrl: string | null; + logMeasurementApiUrl: string | null; +} + +/** + * Bakes the authorization token into every API URL billed to a customer, so + * that the engines inherit it through the URLs they already receive. + * + * The reachability, RPKI and NXDOMAIN probes are deliberately excluded: they + * target unrelated hosts rather than the measurement endpoints. Mutates and + * returns `config`, which is always a freshly merged object. + */ +export const applyAuthorizationToken = ( + config: T +): T => { + const token = config.authorizationToken; + if (!token) return config; + + config.downloadApiUrl = withAuthorizationToken(config.downloadApiUrl, token); + config.uploadApiUrl = withAuthorizationToken(config.uploadApiUrl, token); + config.turnServerCredsApiUrl = withAuthorizationToken( + config.turnServerCredsApiUrl, + token + ); + if (config.logAimApiUrl) + config.logAimApiUrl = withAuthorizationToken(config.logAimApiUrl, token); + if (config.logMeasurementApiUrl) + config.logMeasurementApiUrl = withAuthorizationToken( + config.logMeasurementApiUrl, + 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..18cbfb2 --- /dev/null +++ b/tests/unit/config/authorizationToken.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import defaultConfig from '../../../src/config/defaultConfig.ts'; +import { applyAuthorizationToken } 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)); + +/** Endpoints that bill against a customer and must carry the token. */ +const BILLED_URL_KEYS = [ + 'downloadApiUrl', + 'uploadApiUrl', + 'turnServerCredsApiUrl', + 'logAimApiUrl' +] as const; + +describe('applyAuthorizationToken', () => { + beforeEach(() => { + vi.stubGlobal('window', { + location: { origin: 'https://speed.cloudflare.com' } + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('attaches the token to every billed endpoint', () => { + const config = resolveConfig({ authorizationToken: TOKEN }); + + for (const key of BILLED_URL_KEYS) { + expect(new URL(config[key]!).searchParams.get('token'), key).toBe(TOKEN); + } + }); + + it('attaches the token to per-measurement logging when configured', () => { + const config = resolveConfig({ + authorizationToken: TOKEN, + logMeasurementApiUrl: 'https://speed.cloudflare.com/__log' + }); + + expect( + new URL(config.logMeasurementApiUrl!).searchParams.get('token') + ).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 the RPKI probe host', () => { + // Reachability/RPKI probes target unrelated hosts, not billed endpoints. + const config = resolveConfig({ authorizationToken: TOKEN }); + + expect(config.rpkiInvalidHost).toBe('invalid.rpki.cloudflare.com'); + }); + + it('leaves every URL untouched when no token is configured', () => { + const config = resolveConfig({}); + + for (const key of BILLED_URL_KEYS) { + 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('token')).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..ac42580 --- /dev/null +++ b/tests/unit/utils/authorization.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + AUTHORIZATION_TOKEN_PARAM, + 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 `token` as the param name', () => { + stubOrigin('https://speed.example.com'); + + expect( + withAuthorizationToken('https://speed.example.com/__up', 'abc') + ).toBe('https://speed.example.com/__up?token=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?token=${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('does not touch `window` when there is no token', () => { + // Guards SSR/`autoStart: false` construction: the default config must not + // require a DOM just to build the engine. + expect(() => withAuthorizationToken('/__down', null)).not.toThrow(); + }); +}); From ce4c00beecd90ee0b4d1779261ca925f1eac3fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Wed, 29 Jul 2026 14:27:26 +0100 Subject: [PATCH 2/6] fix: resolve absolute API URLs without requiring a DOM Absolute URLs are now parsed without a base, so only genuinely relative ones consult window.location.origin. Every default API URL is absolute, so constructing an engine with a token no longer needs a DOM. Also trims the comments added in the previous commit. --- src/config/defaultConfig.ts | 11 +++------ src/utils/authorization.ts | 31 ++++++++++++-------------- tests/unit/utils/authorization.test.ts | 10 ++++++--- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/src/config/defaultConfig.ts b/src/config/defaultConfig.ts index 60a5f32..e606eb5 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -68,14 +68,9 @@ export interface Config { /** Optional session ID attached to measurement logs. */ sessionId: string | undefined; /** - * Opaque authorization token attributing this test to a registered customer. - * - * Sent as a `token` 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 attached over plain HTTP, since - * a token seen in cleartext must be treated as compromised. - * - * Default: `null` (requests are unattributed). + * Opaque token attributing this test to a registered customer, sent as a + * `token` query-string param on the measurement, TURN credential and + * results-logging requests. Not sent over plain HTTP. Default: `null`. */ authorizationToken: string | null; diff --git a/src/utils/authorization.ts b/src/utils/authorization.ts index 7bd3aa9..4961e4d 100644 --- a/src/utils/authorization.ts +++ b/src/utils/authorization.ts @@ -2,17 +2,10 @@ export const AUTHORIZATION_TOKEN_PARAM = 'token'; /** - * Returns `apiUrl` with the authorization token appended as a query-string - * parameter, preserving any params already present. + * Appends the authorization token to `apiUrl`, preserving existing params. * - * The token is sent in the query string rather than a header to avoid a CORS - * preflight on the measurement endpoints: an extra round trip there would cost - * test time and, by reusing the TCP connection, suppress the server-time - * calibration in `BandwidthEngine` that relies on seeing a fresh handshake. - * - * Returns `apiUrl` untouched when there is no token, or when the resolved URL - * is not HTTPS — a token observed in cleartext must be treated as compromised, - * so it must never leave the client over plain HTTP. + * 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, @@ -20,7 +13,14 @@ export const withAuthorizationToken = ( ): string => { if (!token) return apiUrl; - const urlObj = new URL(apiUrl, window.location.origin); + // 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); @@ -38,12 +38,9 @@ interface AuthorizableUrls { } /** - * Bakes the authorization token into every API URL billed to a customer, so - * that the engines inherit it through the URLs they already receive. - * - * The reachability, RPKI and NXDOMAIN probes are deliberately excluded: they - * target unrelated hosts rather than the measurement endpoints. Mutates and - * returns `config`, which is always a freshly merged object. + * Bakes the token into every billed API URL so the engines inherit it. + * Excludes the reachability/RPKI/NXDOMAIN probes, which hit unrelated hosts. + * Mutates `config`, which is always a freshly merged object. */ export const applyAuthorizationToken = ( config: T diff --git a/tests/unit/utils/authorization.test.ts b/tests/unit/utils/authorization.test.ts index ac42580..8528955 100644 --- a/tests/unit/utils/authorization.test.ts +++ b/tests/unit/utils/authorization.test.ts @@ -99,9 +99,13 @@ describe('withAuthorizationToken', () => { expect(withAuthorizationToken('/__down', TOKEN)).toBe('/__down'); }); - it('does not touch `window` when there is no token', () => { - // Guards SSR/`autoStart: false` construction: the default config must not - // require a DOM just to build the engine. + 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?token=abc'); expect(() => withAuthorizationToken('/__down', null)).not.toThrow(); }); }); From 08c8616eadfa89d088750410a10e344eb575a3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Wed, 29 Jul 2026 14:54:02 +0100 Subject: [PATCH 3/6] refactor: derive tokenized API URLs from a single list The URLs carrying the token were enumerated in three places, so a new measurement endpoint could silently ship unattributed. TOKEN_URL_KEYS is now the only list, iterated by the helper and asserted exhaustively by the tests, which import it rather than keeping their own copy. --- AGENTS.md | 2 +- src/utils/authorization.ts | 53 +++++++++++--------- tests/unit/config/authorizationToken.test.ts | 36 +++++-------- 3 files changed, 41 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 36c3f34..6fbf7e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Prettier + ESLint run on commit via `lint-staged` (Husky pre-commit hook). - `ReachabilityEngine/` — simple fetch with timeout - `src/Results/` — aggregation, stats (percentile, jitter), and AIM scoring. - `src/utils/` — small helpers: math (`sum`, `avg`, `percentile`, `scaleThreshold`) - and `authorization` (attaches the `authorizationToken` to billed API URLs). + 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/src/utils/authorization.ts b/src/utils/authorization.ts index 4961e4d..6791e63 100644 --- a/src/utils/authorization.ts +++ b/src/utils/authorization.ts @@ -27,40 +27,43 @@ export const withAuthorizationToken = ( 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 TOKEN_URL_KEYS = [ + 'downloadApiUrl', + 'uploadApiUrl', + 'turnServerCredsApiUrl', + 'logAimApiUrl', + 'logMeasurementApiUrl' +] as const; + /** The config fields {@link applyAuthorizationToken} rewrites. */ -interface AuthorizableUrls { - authorizationToken: string | null; - downloadApiUrl: string; - uploadApiUrl: string; - turnServerCredsApiUrl: string; - logAimApiUrl: string | null; - logMeasurementApiUrl: string | null; -} +type TokenizableUrls = { authorizationToken: string | null } & { + [K in (typeof TOKEN_URL_KEYS)[number]]: string | null; +}; /** - * Bakes the token into every billed API URL so the engines inherit it. - * Excludes the reachability/RPKI/NXDOMAIN probes, which hit unrelated hosts. - * Mutates `config`, which is always a freshly merged object. + * Attaches the token to every URL in {@link TOKEN_URL_KEYS}, so the engines + * inherit it through the URLs they already receive. Mutates `config`, which is + * always a freshly merged object. */ -export const applyAuthorizationToken = ( +export const applyAuthorizationToken = ( config: T ): T => { const token = config.authorizationToken; if (!token) return config; - config.downloadApiUrl = withAuthorizationToken(config.downloadApiUrl, token); - config.uploadApiUrl = withAuthorizationToken(config.uploadApiUrl, token); - config.turnServerCredsApiUrl = withAuthorizationToken( - config.turnServerCredsApiUrl, - token - ); - if (config.logAimApiUrl) - config.logAimApiUrl = withAuthorizationToken(config.logAimApiUrl, token); - if (config.logMeasurementApiUrl) - config.logMeasurementApiUrl = withAuthorizationToken( - config.logMeasurementApiUrl, - token - ); + // Widened to write through the union of keys; T only narrows the field types. + const urls = config as TokenizableUrls; + for (const key of TOKEN_URL_KEYS) { + 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 index 18cbfb2..9123b0a 100644 --- a/tests/unit/config/authorizationToken.test.ts +++ b/tests/unit/config/authorizationToken.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import defaultConfig from '../../../src/config/defaultConfig.ts'; -import { applyAuthorizationToken } from '../../../src/utils/authorization.ts'; +import { + applyAuthorizationToken, + TOKEN_URL_KEYS +} from '../../../src/utils/authorization.ts'; const TOKEN = 'test-token-123'; @@ -8,14 +11,6 @@ const TOKEN = 'test-token-123'; const resolveConfig = (userConfig: Partial) => applyAuthorizationToken(Object.assign({}, defaultConfig, userConfig)); -/** Endpoints that bill against a customer and must carry the token. */ -const BILLED_URL_KEYS = [ - 'downloadApiUrl', - 'uploadApiUrl', - 'turnServerCredsApiUrl', - 'logAimApiUrl' -] as const; - describe('applyAuthorizationToken', () => { beforeEach(() => { vi.stubGlobal('window', { @@ -27,23 +22,16 @@ describe('applyAuthorizationToken', () => { vi.unstubAllGlobals(); }); - it('attaches the token to every billed endpoint', () => { - const config = resolveConfig({ authorizationToken: TOKEN }); - - for (const key of BILLED_URL_KEYS) { - expect(new URL(config[key]!).searchParams.get('token'), key).toBe(TOKEN); - } - }); - - it('attaches the token to per-measurement logging when configured', () => { + it('attaches the token to every URL in TOKEN_URL_KEYS', () => { const config = resolveConfig({ authorizationToken: TOKEN, + // Null by default, so set it to cover every key in the list. logMeasurementApiUrl: 'https://speed.cloudflare.com/__log' }); - expect( - new URL(config.logMeasurementApiUrl!).searchParams.get('token') - ).toBe(TOKEN); + for (const key of TOKEN_URL_KEYS) { + expect(new URL(config[key]!).searchParams.get('token'), key).toBe(TOKEN); + } }); it('leaves disabled logging endpoints null', () => { @@ -57,17 +45,17 @@ describe('applyAuthorizationToken', () => { expect(config.logMeasurementApiUrl).toBeNull(); }); - it('does not attach the token to the RPKI probe host', () => { - // Reachability/RPKI probes target unrelated hosts, not billed endpoints. + 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 BILLED_URL_KEYS) { + for (const key of TOKEN_URL_KEYS) { expect(config[key], key).toBe(defaultConfig[key]); } }); From 8ed1e7e7697303452639c2ef05d4a9ac51dfd4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Thu, 30 Jul 2026 15:55:47 +0100 Subject: [PATCH 4/6] fix: redact the authorization token from error paths Address review feedback on the authorizationToken option: - Rename the query param from `token` to `auth`. The measurement log POST body already carries a `token` field holding a server-issued per-measurement value, and both are sent to logMeasurementApiUrl. - Mask the token in the fetch error paths. The failing URL reaches the consumer's onError callback, which is commonly forwarded to third-party log sinks. Also catch the log POST so a rejection cannot print the URL. - Rename TOKEN_URL_KEYS to AUTHORIZABLE_URLS and TokenizableUrls to AuthorizableUrls to convey eligibility rather than parsing. - Expose the merged config to subclasses via a protected getter, so SpeedTestEngine no longer rebuilds it just to read two fields. --- .../BandwidthEngine/BandwidthEngine.ts | 6 ++- .../BandwidthEngine/LoggingBandwidthEngine.ts | 4 +- src/index.ts | 28 ++++------ src/utils/authorization.ts | 52 +++++++++++++++---- tests/unit/config/authorizationToken.test.ts | 12 ++--- tests/unit/utils/authorization.test.ts | 49 +++++++++++++++-- 6 files changed, 110 insertions(+), 41 deletions(-) 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 5495364..e6f677a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -139,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; @@ -738,18 +743,6 @@ class SpeedTestEngine extends MeasurementEngine { constructor(userConfig: ConfigOptions = {}) { super(userConfig); super.onFinish = this.#logFinalResults; - - const config = applyAuthorizationToken( - Object.assign( - {}, - defaultConfig, - userConfig, - internalConfig - ) as SpeedTestConfig - ); - - this.#logAimApiUrl = config.logAimApiUrl; - this.#sessionId = config.sessionId; } // Public attributes @@ -778,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 index 6791e63..1bd3cbd 100644 --- a/src/utils/authorization.ts +++ b/src/utils/authorization.ts @@ -1,5 +1,14 @@ -/** Query-string parameter carrying the authorization token. */ -export const AUTHORIZATION_TOKEN_PARAM = 'token'; +/** + * Query-string parameter carrying the authorization token. + * + * Named `auth`, 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 = 'auth'; + +/** Placeholder substituted for the token in error messages. */ +const REDACTED = 'REDACTED'; /** * Appends the authorization token to `apiUrl`, preserving existing params. @@ -27,6 +36,31 @@ export const withAuthorizationToken = ( 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. @@ -34,7 +68,7 @@ export const withAuthorizationToken = ( * `turnServerUri` is excluded (not HTTP), as are the reachability, RPKI and * NXDOMAIN probe hosts, which are unrelated to the measurement endpoints. */ -export const TOKEN_URL_KEYS = [ +export const AUTHORIZABLE_URLS = [ 'downloadApiUrl', 'uploadApiUrl', 'turnServerCredsApiUrl', @@ -43,24 +77,24 @@ export const TOKEN_URL_KEYS = [ ] as const; /** The config fields {@link applyAuthorizationToken} rewrites. */ -type TokenizableUrls = { authorizationToken: string | null } & { - [K in (typeof TOKEN_URL_KEYS)[number]]: string | null; +type AuthorizableUrls = { authorizationToken: string | null } & { + [K in (typeof AUTHORIZABLE_URLS)[number]]: string | null; }; /** - * Attaches the token to every URL in {@link TOKEN_URL_KEYS}, so the engines + * 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 = ( +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 TokenizableUrls; - for (const key of TOKEN_URL_KEYS) { + const urls = config as AuthorizableUrls; + for (const key of AUTHORIZABLE_URLS) { const url = urls[key]; if (url) urls[key] = withAuthorizationToken(url, token); } diff --git a/tests/unit/config/authorizationToken.test.ts b/tests/unit/config/authorizationToken.test.ts index 9123b0a..d6677f3 100644 --- a/tests/unit/config/authorizationToken.test.ts +++ b/tests/unit/config/authorizationToken.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import defaultConfig from '../../../src/config/defaultConfig.ts'; import { applyAuthorizationToken, - TOKEN_URL_KEYS + AUTHORIZABLE_URLS } from '../../../src/utils/authorization.ts'; const TOKEN = 'test-token-123'; @@ -22,15 +22,15 @@ describe('applyAuthorizationToken', () => { vi.unstubAllGlobals(); }); - it('attaches the token to every URL in TOKEN_URL_KEYS', () => { + 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 TOKEN_URL_KEYS) { - expect(new URL(config[key]!).searchParams.get('token'), key).toBe(TOKEN); + for (const key of AUTHORIZABLE_URLS) { + expect(new URL(config[key]!).searchParams.get('auth'), key).toBe(TOKEN); } }); @@ -55,7 +55,7 @@ describe('applyAuthorizationToken', () => { it('leaves every URL untouched when no token is configured', () => { const config = resolveConfig({}); - for (const key of TOKEN_URL_KEYS) { + for (const key of AUTHORIZABLE_URLS) { expect(config[key], key).toBe(defaultConfig[key]); } }); @@ -84,6 +84,6 @@ describe('applyAuthorizationToken', () => { 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('token')).toBe(TOKEN); + expect(new URL(config.logAimApiUrl!).searchParams.get('auth')).toBe(TOKEN); }); }); diff --git a/tests/unit/utils/authorization.test.ts b/tests/unit/utils/authorization.test.ts index 8528955..6da15a0 100644 --- a/tests/unit/utils/authorization.test.ts +++ b/tests/unit/utils/authorization.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { AUTHORIZATION_TOKEN_PARAM, + redactAuthorizationToken, withAuthorizationToken } from '../../../src/utils/authorization.ts'; @@ -29,12 +30,12 @@ describe('withAuthorizationToken', () => { ); }); - it('uses `token` as the param name', () => { + it('uses `auth` as the param name', () => { stubOrigin('https://speed.example.com'); expect( withAuthorizationToken('https://speed.example.com/__up', 'abc') - ).toBe('https://speed.example.com/__up?token=abc'); + ).toBe('https://speed.example.com/__up?auth=abc'); }); it('preserves query params already present on the URL', () => { @@ -70,7 +71,7 @@ describe('withAuthorizationToken', () => { stubOrigin('https://speed.example.com'); expect(withAuthorizationToken('/__down', TOKEN)).toBe( - `https://speed.example.com/__down?token=${encodeURIComponent(TOKEN)}` + `https://speed.example.com/__down?auth=${encodeURIComponent(TOKEN)}` ); }); @@ -105,7 +106,47 @@ describe('withAuthorizationToken', () => { expect(typeof window).toBe('undefined'); expect( withAuthorizationToken('https://speed.example.com/__down', 'abc') - ).toBe('https://speed.example.com/__down?token=abc'); + ).toBe('https://speed.example.com/__down?auth=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?auth=abc')).toBe( + '/__down?auth=abc' + ); + expect(redactAuthorizationToken('not a url')).toBe('not a url'); + }); +}); From 5853f9f680a628797c57aa005257607e6b15d3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Fri, 31 Jul 2026 09:23:04 +0100 Subject: [PATCH 5/6] refactor: rename the authorization query param to jwt Agreed with the API side. Still avoids colliding with the `token` field in the measurement log POST body. --- src/utils/authorization.ts | 4 ++-- tests/unit/config/authorizationToken.test.ts | 4 ++-- tests/unit/utils/authorization.test.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/utils/authorization.ts b/src/utils/authorization.ts index 1bd3cbd..686486d 100644 --- a/src/utils/authorization.ts +++ b/src/utils/authorization.ts @@ -1,11 +1,11 @@ /** * Query-string parameter carrying the authorization token. * - * Named `auth`, not `token`: the measurement log POST body already has a + * 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 = 'auth'; +export const AUTHORIZATION_TOKEN_PARAM = 'jwt'; /** Placeholder substituted for the token in error messages. */ const REDACTED = 'REDACTED'; diff --git a/tests/unit/config/authorizationToken.test.ts b/tests/unit/config/authorizationToken.test.ts index d6677f3..2a6fdd9 100644 --- a/tests/unit/config/authorizationToken.test.ts +++ b/tests/unit/config/authorizationToken.test.ts @@ -30,7 +30,7 @@ describe('applyAuthorizationToken', () => { }); for (const key of AUTHORIZABLE_URLS) { - expect(new URL(config[key]!).searchParams.get('auth'), key).toBe(TOKEN); + expect(new URL(config[key]!).searchParams.get('jwt'), key).toBe(TOKEN); } }); @@ -84,6 +84,6 @@ describe('applyAuthorizationToken', () => { 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('auth')).toBe(TOKEN); + expect(new URL(config.logAimApiUrl!).searchParams.get('jwt')).toBe(TOKEN); }); }); diff --git a/tests/unit/utils/authorization.test.ts b/tests/unit/utils/authorization.test.ts index 6da15a0..5181a3d 100644 --- a/tests/unit/utils/authorization.test.ts +++ b/tests/unit/utils/authorization.test.ts @@ -30,12 +30,12 @@ describe('withAuthorizationToken', () => { ); }); - it('uses `auth` as the param name', () => { + 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?auth=abc'); + ).toBe('https://speed.example.com/__up?jwt=abc'); }); it('preserves query params already present on the URL', () => { @@ -71,7 +71,7 @@ describe('withAuthorizationToken', () => { stubOrigin('https://speed.example.com'); expect(withAuthorizationToken('/__down', TOKEN)).toBe( - `https://speed.example.com/__down?auth=${encodeURIComponent(TOKEN)}` + `https://speed.example.com/__down?jwt=${encodeURIComponent(TOKEN)}` ); }); @@ -106,7 +106,7 @@ describe('withAuthorizationToken', () => { expect(typeof window).toBe('undefined'); expect( withAuthorizationToken('https://speed.example.com/__down', 'abc') - ).toBe('https://speed.example.com/__down?auth=abc'); + ).toBe('https://speed.example.com/__down?jwt=abc'); expect(() => withAuthorizationToken('/__down', null)).not.toThrow(); }); }); @@ -144,8 +144,8 @@ describe('redactAuthorizationToken', () => { 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?auth=abc')).toBe( - '/__down?auth=abc' + expect(redactAuthorizationToken('/__down?jwt=abc')).toBe( + '/__down?jwt=abc' ); expect(redactAuthorizationToken('not a url')).toBe('not a url'); }); From 27bc39074f91b95ab03c1128c8dc2cf8e21ae99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Jesus?= Date: Fri, 31 Jul 2026 09:37:56 +0100 Subject: [PATCH 6/6] docs: correct the authorization param name to jwt The JSDoc and README still documented the param as `token`, which a consumer would wire up server-side. Both predate the rename to `jwt` in 5853f9f. --- README.md | 2 +- src/config/defaultConfig.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 41e3c38..4d78a8a 100644 --- a/README.md +++ b/README.md @@ -48,7 +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 `token` 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` | +| **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 e606eb5..4def431 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -69,7 +69,7 @@ export interface Config { sessionId: string | undefined; /** * Opaque token attributing this test to a registered customer, sent as a - * `token` query-string param on the measurement, TURN credential and + * `jwt` query-string param on the measurement, TURN credential and * results-logging requests. Not sent over plain HTTP. Default: `null`. */ authorizationToken: string | null;