Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 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.

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

// Measurements
measurements: [
Expand Down
29 changes: 17 additions & 12 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 Down Expand Up @@ -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
);
Comment thread
andre-j3sus marked this conversation as resolved.
Outdated

this.#logAimApiUrl = config.logAimApiUrl;
this.#sessionId = config.sessionId;
Expand Down
69 changes: 69 additions & 0 deletions src/utils/authorization.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
andre-j3sus marked this conversation as resolved.
Outdated
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;
}
Comment thread
andre-j3sus marked this conversation as resolved.
Outdated

/**
* 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 = <T extends AuthorizableUrls>(
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;
};
101 changes: 101 additions & 0 deletions tests/unit/config/authorizationToken.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof defaultConfig>) =>
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);
});
});
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();
});
});
107 changes: 107 additions & 0 deletions tests/unit/utils/authorization.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading