Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 `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` |
Comment thread
andre-j3sus marked this conversation as resolved.
Outdated
| **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
* `token` query-string param on the measurement, TURN credential and
Comment thread
andre-j3sus marked this conversation as resolved.
Outdated
* 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 `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.
*
* 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('auth'), 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('auth')).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
Loading