Skip to content
Merged
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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
7 changes: 7 additions & 0 deletions src/config/defaultConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -140,6 +146,7 @@ const defaultConfig: Config = {
rpkiInvalidHost: 'invalid.rpki.cloudflare.com',
includeCredentials: false,
sessionId: undefined,
authorizationToken: null,

// Measurements
measurements: [
Expand Down
6 changes: 4 additions & 2 deletions src/engines/BandwidthEngine/BandwidthEngine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Engine } from '../Engine';
import { redactAuthorizationToken } from '../../utils/authorization';

const MAX_RETRIES = 20;

Expand Down Expand Up @@ -553,15 +554,16 @@ 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
} else {
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.`
);
}
});
Expand Down
4 changes: 3 additions & 1 deletion src/engines/BandwidthEngine/LoggingBandwidthEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
}
}

Expand Down
41 changes: 18 additions & 23 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
Expand Down
103 changes: 103 additions & 0 deletions src/utils/authorization.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends AuthorizableUrls>(
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;
};
89 changes: 89 additions & 0 deletions tests/unit/config/authorizationToken.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof defaultConfig>) =>
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);
});
});
4 changes: 4 additions & 0 deletions tests/unit/config/defaultConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading