@jrpool/kilotest 51.1.0 → 52.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,10 +12,10 @@ jobs:
12
12
 
13
13
  steps:
14
14
  - name: Checkout code
15
- uses: actions/checkout@v4
15
+ uses: actions/checkout@v5
16
16
 
17
17
  - name: Set Node.js version
18
- uses: actions/setup-node@v4
18
+ uses: actions/setup-node@v5
19
19
  with:
20
20
  node-version: '>=24.16.0'
21
21
 
@@ -14,10 +14,10 @@ jobs:
14
14
 
15
15
  steps:
16
16
  - name: Checkout code
17
- uses: actions/checkout@v4
17
+ uses: actions/checkout@v5
18
18
 
19
19
  - name: Set Node.js version
20
- uses: actions/setup-node@v4
20
+ uses: actions/setup-node@v5
21
21
  with:
22
22
  node-version: '>=24.16.0'
23
23
 
@@ -19,7 +19,7 @@ jobs:
19
19
  uses: actions/checkout@v7
20
20
 
21
21
  - name: Set Node.js version
22
- uses: actions/setup-node@v4
22
+ uses: actions/setup-node@v5
23
23
  with:
24
24
  node-version: '>=24.16.0'
25
25
 
package/README.md CHANGED
@@ -10,7 +10,7 @@ An ensemble testing and reporting service for front-end web quality
10
10
 
11
11
  ## Features
12
12
 
13
- This application uses an ensemble of 12 rule engines to test public web pages for front-end quality (i.e. accessibility, usability, and standards conformity).
13
+ This application uses an ensemble of 12 rule engines to test web pages for front-end quality (i.e. accessibility, usability, and standards conformity).
14
14
 
15
15
  The testing paradigm employed by Kilotest is discussed in these papers:
16
16
 
@@ -20,11 +20,11 @@ The testing paradigm employed by Kilotest is discussed in these papers:
20
20
 
21
21
  Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access.
22
22
 
23
- Testing for Kilotest is performed by one or more testing agents that obtain jobs from Kilotest, perform them, and send reports of the results back to Kilotest. Those agents are instances of the [Testaro](https://www.npmjs.com/package/testaro) package.
23
+ Testing for Kilotest is performed by one or more workers (testing agents) that obtain jobs from Kilotest, perform them, and send reports of the results back to Kilotest. Those agents are instances of the [Testaro](https://www.npmjs.com/package/testaro) package. Thus, the pages that Kilotest tests need not be on the public Internet. They need only be reachable by the Testaro workers that do the testing for Kilotest. So, one or more Testaro workers running inside a private intranet can test internal pages, as well as any external ones reachable from inside the intranet.
24
24
 
25
- An active production instance of Kilotest may require multiple testing agents to handle the load, because testing one web page typically takes about 3 minutes and agents test only one page at a time.
25
+ An active production instance of Kilotest may require multiple Testaro workers to handle the load, because testing one web page typically takes about 3 minutes and workers test only one page at a time.
26
26
 
27
- ## Getting started locally with 1 testing worker
27
+ ## Getting started locally with 1 worker
28
28
 
29
29
  ### Installation
30
30
 
@@ -36,7 +36,7 @@ In the steps below, hosts `T` and `K` may be the same host or two different host
36
36
  1. Clone the Kilotest repository into a new directory on host `K`.
37
37
  1. In that directory, install the Kilotest dependencies: `npm install`.
38
38
  1. Copy the `env.testaro` file from the `kilotest` directory to `.env` in the `testaro` directory and replace the `__placeholder__` values in `.env` with actual values.
39
- 1. Copy the `env.example` file in the `kilotest` directory to a new `.env` file in the same directory and replace the `__placeholder__` values in `.env` with actual values. `TESTARO_WORKERS` is a JSON object mapping each Testaro worker's ID to an object with its `secret` and its `name`; the ID and secret for a worker must match the `NETWATCH_URL_WORKER_ID` and `NETWATCH_URL_AUTH` values in that worker's `.env`. A worker authenticates each request with an HTTP Basic `Authorization` header (`id:secret`, base64-encoded); as of this writing that header is not yet sent by the published Testaro package, so this scheme requires a matching update to Testaro's `netWatch.js` request code (only to send the header; no change to Testaro's handling of jobs or reports is needed). The ID is a credential and is never published; the `name` is written by Kilotest into a job's and report's `sources.worker` property, which Testaro does not read or alter, so it is a distinct property from `sources.agent` (Testaro's own, untrusted, self-reported label, which Kilotest ignores). Choose a `name` that is safe to appear in every report Kilotest serves.
39
+ 1. Copy the `env.example` file in the `kilotest` directory to a new `.env` file in the same directory and replace the `__placeholder__` values in `.env` with actual values. `TESTARO_WORKERS` is a JSON object mapping each Testaro worker's ID to an object with its `secret` and its `name`; the ID and secret for a worker must match the `NETWATCH_URL_WORKER_ID` and `NETWATCH_URL_AUTH` values in that worker's `.env`. A worker authenticates each request with an HTTP Basic `Authorization` header (`id:secret`, base64-encoded); as of this writing that header is not yet sent by the published Testaro package, so this scheme requires a matching update to Testaro's `netWatch.js` request code (only to send the header; no change to Testaro's handling of jobs or reports is needed). The ID is a credential and is never published; the `name` is written by Kilotest into a job's and report's `sources.worker` property, which Testaro does not read or alter, so it is a distinct property from `sources.agent` (Testaro's own, untrusted, self-reported label, which Kilotest ignores). Choose a `name` that is safe to appear in every report Kilotest serves. `ALLOW_INTERNAL_TARGETS` controls whether this deployment can be induced to test pages at private, loopback, or link-local addresses; see [`SERVICE.md`](docs/SERVICE.md#testing-target-restrictions) before changing it from its default.
40
40
 
41
41
  ### Usage
42
42
 
@@ -17,6 +17,10 @@ import {fixtureDBDir} from '../test/dbFixture.ts';
17
17
  for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
18
18
  process.env[key] = '';
19
19
  }
20
+ // Allow internal targets by default, so tests that submit an ordinary https://example.com/...
21
+ // URL do not depend on real DNS/network access to pass the resolution check that
22
+ // isAllowedTarget performs. The test of the check itself (below) overrides this.
23
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
20
24
 
21
25
  // SETUP AND TEARDOWN
22
26
 
@@ -77,6 +81,18 @@ test('requestTest rejects a syntactically invalid URL with the correct length',
77
81
  assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
78
82
  });
79
83
 
84
+ test('requestTest rejects a private-address URL unless internal targets are allowed', async () => {
85
+ delete process.env.ALLOW_INTERNAL_TARGETS;
86
+ try {
87
+ const body = await response(['Internal Page', 'https://192.168.1.1/page', 'A reason that is long enough.']);
88
+ const details = body['response content']['details about your request'] as any;
89
+ assert.ok(details.error.includes('does not resolve to a page that this deployment can test'));
90
+ assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
91
+ } finally {
92
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
93
+ }
94
+ });
95
+
80
96
  test('requestTest rejects an already-tested page', async () => {
81
97
  const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
82
98
  const details = body['response content']['details about your request'] as any;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {z} from 'zod';
9
9
  import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
- import {checkLength, isURL, processTestRequest} from '../util.ts';
10
+ import {checkLength, isAllowedTarget, isURL, processTestRequest} from '../util.ts';
11
11
  import {requestTestResponseSchema} from './schemas.ts';
12
12
 
13
13
  // TYPES
@@ -43,6 +43,12 @@ export const response = async (args: string[]) => {
43
43
  error: 'request invalid: you specified an invalid URL for the page'
44
44
  };
45
45
  }
46
+ // Otherwise, i.e. if the URL does not resolve to an allowed target:
47
+ else if (!(await isAllowedTarget(url))) {
48
+ requestDetails = {
49
+ error: 'request invalid: the URL you specified does not resolve to a page that this deployment can test'
50
+ };
51
+ }
46
52
  // Otherwise, i.e. if the description and URL are valid:
47
53
  else {
48
54
  // Process the request.
package/docs/SERVICE.md CHANGED
@@ -270,7 +270,7 @@ kilotest.com {
270
270
  }
271
271
  @allowedPOST {
272
272
  method POST
273
- path /mcp /requestRetest.html/* /requestTest.html /requestAction.html /reannotate.html /renewWCAG.html /ai0BalanceForm.html /expungeReportsForm.html /hideReportForm.html /metrics.html /pruneReportsForm.html /rewindReportsForm.html /showHiddenReportsForm.html /unhideReportForm.html /api/* /tutorialWebComment.html /tutorialAIComment.html /worker/job /worker/report
273
+ path /mcp /requestRetest.html/* /requestTest.html /requestAction.html /reannotate.html /renewWCAG.html /ai0BalanceForm.html /deleteNotesForm.html /expungeReportsForm.html /hideReportForm.html /metrics.html /pruneReportsForm.html /rewindReportsForm.html /showHiddenReportsForm.html /unhideReportForm.html /api/* /tutorialWebComment.html /tutorialAIComment.html /worker/job /worker/report
274
274
  }
275
275
  # Respond to OPTIONS requests.
276
276
  @allowedOPTIONS method OPTIONS
@@ -595,6 +595,14 @@ If restoration becomes necessary, the files can be copied in the opposite direct
595
595
 
596
596
  After that the maintainer can inspect the original and restored directories and delete or move report files as needed. After that the `db/reportsExtract.json` file should be deleted, so it will be regenerated when next needed.
597
597
 
598
+ ### Testing target restrictions
599
+
600
+ A page submitted for testing need not be publicly accessible; it need only be reachable from the Testaro worker assigned the job. Without a restriction on which addresses a worker may be sent to, an arbitrary requester could induce a worker to fetch a target it has no legitimate reason to reach, such as [a private or link-local address](https://developer.mozilla.org/en-US/docs/Glossary/Private_network) on the network local to that worker, including a metadata service run by a cloud host. This class of attack is known as [server-side request forgery (SSRF)](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/SSRF).
601
+
602
+ Kilotest guards against this with the `ALLOW_INTERNAL_TARGETS` environment variable (documented in `env.example`). Left unset, a deployment restricts testing to addresses that resolve to the public address space, checked both when a URL is submitted and again against the actually visited URL of each report before that report is stored (since a redirect could otherwise route a worker to a disallowed target after submission-time validation passed). Setting it to `true` opts a deployment in to testing private, loopback, and link-local addresses, and removes this protection entirely for that Kilotest instance.
603
+
604
+ Setting `ALLOW_INTERNAL_TARGETS` to `true` is not safe merely because every Testaro worker able to poll that Kilotest instance is confined to a private network. A report on an internal page is stored and served by Kilotest itself, so if that Kilotest instance is reachable from the public Internet, anyone able to reach it, not only someone with access to the private network, can read the content of that report, including page text and, depending on job configuration, a screenshot or the HTML source of the page. Setting this variable to `true` is appropriate only for a Kilotest instance that is itself kept off the public Internet, or otherwise restricts who can request and retrieve reports, in addition to confining every one of its Testaro workers to the private network being tested.
605
+
598
606
  ### Possible future Testaro integration
599
607
 
600
608
  Kilotest uses Testaro to run jobs. In previous versions of Kilotest, Testaro was a dependency. It is currently not a dependency. Instead, Testaro instances are installed on one or more other hosts, and each instance polls Kilotest to ask for jobs to run.
package/env.example CHANGED
@@ -15,6 +15,14 @@ DEPLOYED_KILOTEST_HOST=https://kilotest.com
15
15
  # Path of the data directory (defaults to the db subdirectory of the project root). Tests set
16
16
  # this to a fixture directory so they never read or write the real db.
17
17
  DB_DIR=__placeholder__
18
+ # Whether this deployment allows testing pages at private, loopback, or link-local addresses
19
+ # (true) or restricts testing to publicly routable addresses (any other value or omission).
20
+ # Set this to true only when every Testaro worker that can poll this Kilotest instance is
21
+ # intentionally deployed on a private network to test that network’s own internal pages;
22
+ # leave it unset for a deployment whose Testaro workers accept jobs from arbitrary requesters,
23
+ # since such a worker could otherwise be induced to fetch a target it has no legitimate reason
24
+ # to reach, such as its own host’s cloud-metadata service.
25
+ ALLOW_INTERNAL_TARGETS=__placeholder__
18
26
 
19
27
  # Alert configuration:
20
28
 
package/index.test.ts CHANGED
@@ -19,6 +19,10 @@ process.env.FEATURE_REQUESTS_PATH = path.join(testCommentsDir, 'featureRequests.
19
19
  process.env.TESTARO_WORKERS = JSON.stringify({
20
20
  worker1: {secret: 'secret1', name: 'Worker One'}
21
21
  });
22
+ // Allow internal targets by default, so tests that submit an ordinary https://example.com/...
23
+ // URL do not depend on real DNS/network access to pass the resolution check that
24
+ // isAllowedTarget performs. Tests of the check itself (below) override this per test.
25
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
22
26
  // Blank the alert configuration unconditionally, so this file sends no real alert
23
27
  // emails (e.g. from the requestTest/requestRetest/tutorial-comment tests below) even
24
28
  // when run directly (e.g. `npx tsx --test index.test.ts`) rather than via `npm test`,
@@ -905,6 +909,21 @@ test('POST /requestTest.html with non-https URL returns an error', async () => {
905
909
  assert.ok(res.body.includes('Invalid test request'));
906
910
  });
907
911
 
912
+ test('POST /requestTest.html with a private-address URL returns an error unless internal targets are allowed', async () => {
913
+ delete process.env.ALLOW_INTERNAL_TARGETS;
914
+ try {
915
+ const res = await formRequest('POST', '/requestTest.html', {
916
+ description: 'Internal Page',
917
+ url: 'https://192.168.1.1/page',
918
+ why: 'Because accessibility matters'
919
+ });
920
+ assert.equal(res.statusCode, 400);
921
+ assert.ok(res.body.includes('Invalid test request'));
922
+ } finally {
923
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
924
+ }
925
+ });
926
+
908
927
  test('POST /requestRetest.html/260202T0000/new with missing why returns an error', async () => {
909
928
  const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
910
929
  why: ''
@@ -1227,6 +1246,62 @@ test('POST /worker/report with a claimed job but an unusable report returns an e
1227
1246
  await fs.unlink(failedJobPath).catch(() => {});
1228
1247
  });
1229
1248
 
1249
+ test('POST /worker/report with a report on a disallowed target rejects it and reclassifies the job', {timeout: 500}, async () => {
1250
+ delete process.env.ALLOW_INTERNAL_TARGETS;
1251
+ // Use a unique job ID that does not conflict with existing fixtures.
1252
+ const jobID = '990101T0002-ssr';
1253
+ const reportPath = path.join(fixtureDBDir, 'reports', `${jobID}.json`);
1254
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
1255
+ const failedDir = path.join(fixtureDBDir, 'jobs', 'failed');
1256
+ const jobFile = `${jobID}.json`;
1257
+ const claimedJobPath = path.join(claimedDir, jobFile);
1258
+ const failedJobPath = path.join(failedDir, jobFile);
1259
+ try {
1260
+ // Clean up any leftover files.
1261
+ await fs.unlink(reportPath).catch(() => {});
1262
+ await fs.unlink(failedJobPath).catch(() => {});
1263
+ // Create a claimed job assigned to Worker One.
1264
+ await fs.writeFile(claimedJobPath, JSON.stringify({
1265
+ id: jobID,
1266
+ target: {what: 'Test', url: 'https://example.com/test'},
1267
+ sources: {worker: 'Worker One'}
1268
+ }));
1269
+ const auth = Buffer.from('worker1:secret1').toString('base64');
1270
+ // A report that is otherwise usable, but whose test act was redirected (actualURL)
1271
+ // to a private address that this deployment does not allow as a testing target.
1272
+ const report = {
1273
+ id: jobID,
1274
+ target: {what: 'Test', url: 'https://example.com/test'},
1275
+ acts: [{
1276
+ type: 'test', which: 'axe', actualURL: 'https://192.168.1.1/test',
1277
+ result: {standardResult: {instances: []}}
1278
+ }],
1279
+ jobData: {endTime: '26-01-01T00:00'},
1280
+ catalog: {}
1281
+ };
1282
+ const res = await request('POST', '/worker/report', {report}, {
1283
+ authorization: `Basic ${auth}`
1284
+ });
1285
+ assert.equal(res.statusCode, 400);
1286
+ const body = jsonBody(res);
1287
+ assert.ok(body.error.message.includes('disallowed target'));
1288
+ // The report should not have been recorded.
1289
+ const reportExists = await fs.access(reportPath).then(() => true).catch(() => false);
1290
+ assert.equal(reportExists, false, 'Report on a disallowed target should not be saved');
1291
+ // Wait for the async rename to complete.
1292
+ await new Promise<void>(resolve => setTimeout(resolve, 100));
1293
+ // The job should have been reclassified as failed rather than left claimed.
1294
+ const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
1295
+ assert.ok(failedExists, 'Job should be moved to the failed directory');
1296
+ const claimedExists = await fs.access(claimedJobPath).then(() => true).catch(() => false);
1297
+ assert.equal(claimedExists, false, 'Job should be removed from the claimed directory');
1298
+ } finally {
1299
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
1300
+ // Clean up.
1301
+ await fs.unlink(failedJobPath).catch(() => {});
1302
+ }
1303
+ });
1304
+
1230
1305
  // TESTS: remaining error branches and web pages
1231
1306
 
1232
1307
  test('GET /style.css with a read error returns an error page', async () => {
package/index.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  getObject,
18
18
  getPOSTData,
19
19
  getReport,
20
+ isAllowedReport,
21
+ isAllowedTarget,
20
22
  isReportError,
21
23
  getReportPath,
22
24
  hiddenReportsPath,
@@ -899,7 +901,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
899
901
  else if (pageName === 'requestTest.html') {
900
902
  const {description, url, why} = postData as {description?: string; url: string; why?: string};
901
903
  // If the request is valid:
902
- if (description && isURL(url) && why) {
904
+ if (description && isURL(url) && why && await isAllowedTarget(url)) {
903
905
  // If a report on the page is already available:
904
906
  if (await isReportAvailable(description, url)) {
905
907
  // Report the error.
@@ -1103,8 +1105,28 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
1103
1105
  }
1104
1106
  // If the job was actually assigned to this worker:
1105
1107
  if (typeof claimedJob === 'object' && claimedJob !== null && (claimedJob as {sources?: {worker?: string}}).sources?.worker === workerName) {
1106
- // If the report is usable by Kilotest (as any report of an assigned job should be):
1107
- if (isUsableReport(reportObj)) {
1108
+ // If the report is on a target this deployment allows (a redirect during
1109
+ // navigation, possibly via DNS rebinding, can take a worker to a target that
1110
+ // was disallowed even though the originally submitted URL passed that check
1111
+ // at submission time):
1112
+ if (!(await isAllowedReport(reportObj))) {
1113
+ console.error(`ERROR: Report ${id} from worker ${workerName} is on a disallowed target`);
1114
+ // Alert a manager, since this indicates an attempted or accidental SSRF
1115
+ // rather than an ordinary usability problem with the report.
1116
+ await sendAlert(
1117
+ 'Kilotest: report on disallowed target rejected',
1118
+ `Job ${id} from worker ${workerName} resulted in a visit to a target this deployment does not allow (e.g. a private, loopback, or link-local address), possibly via a redirect. The report was discarded and the job was reclassified as failed.`
1119
+ );
1120
+ // Reclassify the job as failed, instead of recording the disallowed report or
1121
+ // leaving the job claimed indefinitely.
1122
+ await fs.rename(
1123
+ path.join(claimedPath(), `${id}.json`), path.join(failedPath(), `${id}.json`)
1124
+ );
1125
+ // Report the error.
1126
+ await serveError({message: `ERROR: Report ${id} is on a disallowed target`}, response, false);
1127
+ }
1128
+ // Otherwise, if the report is usable by Kilotest (as any report of an assigned job should be):
1129
+ else if (isUsableReport(reportObj)) {
1108
1130
  console.log(`Testaro report ${id} was received from worker ${workerName}`);
1109
1131
  // Add the public worker name to the report.
1110
1132
  report!.sources = {...report!.sources, worker: workerName};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "51.1.0",
3
+ "version": "52.0.0",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/test/setup.ts CHANGED
@@ -12,3 +12,8 @@
12
12
  for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
13
13
  process.env[key] = '';
14
14
  }
15
+ // Allow internal targets by default, so that tests submitting an ordinary
16
+ // https://example.com/... URL for testing do not depend on real DNS/network access
17
+ // to pass the resolution check that isAllowedTarget performs. A test of that check
18
+ // itself sets this env var back to unset (or false) around the specific call being tested.
19
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
package/util.test.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {test, before, after} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
+ import net from 'node:net';
10
11
  import path from 'node:path';
11
12
  import fs from 'node:fs/promises';
12
13
 
@@ -51,6 +52,8 @@ import {
51
52
  getWeightName,
52
53
  hiddenReportsPath,
53
54
  htmlSafe,
55
+ isAllowedReport,
56
+ isAllowedTarget,
54
57
  isJobID,
55
58
  isReportAvailable,
56
59
  isTimeStamp,
@@ -366,6 +369,160 @@ test('isURL returns false for an invalid URL', () => {
366
369
  assert.equal(isURL('not-a-url'), false);
367
370
  });
368
371
 
372
+ // A fake resolver, so isAllowedTarget tests need no real DNS/network access.
373
+ const fakeLookup = (address: string) => async () => [{address, family: net.isIP(address)}];
374
+
375
+ // isAllowedTarget and isAllowedReport branch on ALLOW_INTERNAL_TARGETS, which other test
376
+ // files (sharing this process) set to "true" for their own unrelated tests, so every test
377
+ // below sets the env var itself, rather than relying on it being unset by default, and
378
+ // restores whatever value preceded it.
379
+ let savedAllowInternalTargets: string | undefined;
380
+
381
+ before(() => {
382
+ savedAllowInternalTargets = process.env.ALLOW_INTERNAL_TARGETS;
383
+ });
384
+
385
+ after(() => {
386
+ if (savedAllowInternalTargets === undefined) {
387
+ delete process.env.ALLOW_INTERNAL_TARGETS;
388
+ }
389
+ else {
390
+ process.env.ALLOW_INTERNAL_TARGETS = savedAllowInternalTargets;
391
+ }
392
+ });
393
+
394
+ test('isAllowedTarget returns true for a URL resolving to a public address', async () => {
395
+ delete process.env.ALLOW_INTERNAL_TARGETS;
396
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('93.184.216.34')), true);
397
+ });
398
+
399
+ test('isAllowedTarget returns false for a URL resolving to a loopback address', async () => {
400
+ delete process.env.ALLOW_INTERNAL_TARGETS;
401
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('127.0.0.1')), false);
402
+ });
403
+
404
+ test('isAllowedTarget returns false for a URL resolving to a private address', async () => {
405
+ delete process.env.ALLOW_INTERNAL_TARGETS;
406
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('10.0.0.5')), false);
407
+ });
408
+
409
+ test('isAllowedTarget returns false for a URL resolving to a 172.16/12 private address', async () => {
410
+ delete process.env.ALLOW_INTERNAL_TARGETS;
411
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('172.20.0.5')), false);
412
+ });
413
+
414
+ test('isAllowedTarget returns false for a URL resolving to a carrier-grade NAT address', async () => {
415
+ delete process.env.ALLOW_INTERNAL_TARGETS;
416
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('100.70.0.5')), false);
417
+ });
418
+
419
+ test('isAllowedTarget returns false for a URL resolving to a link-local/cloud-metadata address', async () => {
420
+ delete process.env.ALLOW_INTERNAL_TARGETS;
421
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('169.254.169.254')), false);
422
+ });
423
+
424
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 loopback address', async () => {
425
+ delete process.env.ALLOW_INTERNAL_TARGETS;
426
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('::1')), false);
427
+ });
428
+
429
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 unspecified address', async () => {
430
+ delete process.env.ALLOW_INTERNAL_TARGETS;
431
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('::')), false);
432
+ });
433
+
434
+ test('isAllowedTarget returns false for a URL resolving to an IPv4-mapped private IPv6 address', async () => {
435
+ delete process.env.ALLOW_INTERNAL_TARGETS;
436
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('::ffff:10.0.0.5')), false);
437
+ });
438
+
439
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 unique-local address', async () => {
440
+ delete process.env.ALLOW_INTERNAL_TARGETS;
441
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('fd12:3456:789a:1::1')), false);
442
+ });
443
+
444
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 link-local address', async () => {
445
+ delete process.env.ALLOW_INTERNAL_TARGETS;
446
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('fe80::1')), false);
447
+ });
448
+
449
+ test('isAllowedTarget returns true for a URL resolving to a public IPv6 address', async () => {
450
+ delete process.env.ALLOW_INTERNAL_TARGETS;
451
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('2606:2800:220:1:248:1893:25c8:1946')), true);
452
+ });
453
+
454
+ test('isAllowedTarget returns false for a URL resolving to an address family net.isIP does not recognize', async () => {
455
+ delete process.env.ALLOW_INTERNAL_TARGETS;
456
+ assert.equal(await isAllowedTarget('https://example.com/page', async () => [{address: 'not-an-ip', family: 0}]), false);
457
+ });
458
+
459
+ test('isAllowedTarget returns false when the DNS lookup rejects', async () => {
460
+ delete process.env.ALLOW_INTERNAL_TARGETS;
461
+ const lookup = async () => { throw new Error('lookup failed'); };
462
+ assert.equal(await isAllowedTarget('https://nonexistent.invalid/page', lookup), false);
463
+ });
464
+
465
+ test('isAllowedTarget returns false for a URL whose literal host is a private IP', async () => {
466
+ delete process.env.ALLOW_INTERNAL_TARGETS;
467
+ assert.equal(await isAllowedTarget('https://192.168.1.1/page'), false);
468
+ });
469
+
470
+ test('isAllowedTarget returns true for a URL whose literal host is a public IP', async () => {
471
+ delete process.env.ALLOW_INTERNAL_TARGETS;
472
+ assert.equal(await isAllowedTarget('https://93.184.216.34/page'), true);
473
+ });
474
+
475
+ test('isAllowedTarget returns false for a syntactically invalid URL', async () => {
476
+ delete process.env.ALLOW_INTERNAL_TARGETS;
477
+ assert.equal(await isAllowedTarget('not-a-url'), false);
478
+ });
479
+
480
+ test('isAllowedTarget returns true for a private-address target when ALLOW_INTERNAL_TARGETS is true', async () => {
481
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
482
+ assert.equal(await isAllowedTarget('https://192.168.1.1/page'), true);
483
+ });
484
+
485
+ test('isAllowedReport returns true when every act has a public actualURL', async () => {
486
+ delete process.env.ALLOW_INTERNAL_TARGETS;
487
+ const report = {
488
+ target: {what: 'Example', url: 'https://example.com/page'},
489
+ acts: [
490
+ {type: 'test', actualURL: 'https://example.com/page'},
491
+ {type: 'test', actualURL: 'https://example.com/page2'}
492
+ ]
493
+ };
494
+ assert.equal(await isAllowedReport(report, fakeLookup('93.184.216.34')), true);
495
+ });
496
+
497
+ test('isAllowedReport returns false when any act has a disallowed actualURL', async () => {
498
+ delete process.env.ALLOW_INTERNAL_TARGETS;
499
+ const report = {
500
+ target: {what: 'Example', url: 'https://example.com/page'},
501
+ acts: [
502
+ {type: 'test', actualURL: 'https://example.com/page'},
503
+ {type: 'test', actualURL: 'https://internal.example.net/page'}
504
+ ]
505
+ };
506
+ let callCount = 0;
507
+ const lookup = async (hostname: string) => {
508
+ callCount++;
509
+ return hostname === 'internal.example.net'
510
+ ? [{address: '10.0.0.5', family: 4}]
511
+ : [{address: '93.184.216.34', family: 4}];
512
+ };
513
+ assert.equal(await isAllowedReport(report, lookup), false);
514
+ assert.ok(callCount > 0);
515
+ });
516
+
517
+ test('isAllowedReport falls back to the report target URL when an act has no actualURL', async () => {
518
+ delete process.env.ALLOW_INTERNAL_TARGETS;
519
+ const report = {
520
+ target: {what: 'Example', url: 'https://example.com/page'},
521
+ acts: [{type: 'test'}]
522
+ };
523
+ assert.equal(await isAllowedReport(report, fakeLookup('93.184.216.34')), true);
524
+ });
525
+
369
526
  test('isValidAuthCode returns true when the code matches AUTH_CODE', () => {
370
527
  const saved = process.env.AUTH_CODE;
371
528
  process.env.AUTH_CODE = 'secret-code';
package/util.ts CHANGED
@@ -12,7 +12,9 @@ import {sendAlert} from './alerts.ts';
12
12
  import {issues as issueSpecs, rules as ruleSpecs} from 'testaro-issues';
13
13
  import type {Act, Catalog, Report, StandardInstance} from 'testaro';
14
14
  import crypto from 'node:crypto';
15
+ import dns from 'node:dns/promises';
15
16
  import fs from 'node:fs/promises';
17
+ import net from 'node:net';
16
18
  import path from 'node:path';
17
19
  import querystring from 'node:querystring';
18
20
  /* c8 ignore stop */
@@ -549,6 +551,95 @@ export const isURL = (string: string): boolean => {
549
551
  return false;
550
552
  }
551
553
  };
554
+ // Returns whether an IPv4 address (in dotted-decimal form) is in a private,
555
+ // loopback, link-local, or otherwise non-public range, including the
556
+ // 169.254.169.254 cloud-metadata address (which falls under link-local).
557
+ const isNonPublicIPv4 = (address: string): boolean => {
558
+ const octets = address.split('.').map(Number);
559
+ const [a, b = 0] = octets;
560
+ return (
561
+ a === 127 || // loopback
562
+ a === 10 || // private
563
+ a === 0 || // "this network"
564
+ (a === 172 && b >= 16 && b <= 31) || // private
565
+ (a === 192 && b === 168) || // private
566
+ (a === 169 && b === 254) || // link-local, incl. cloud metadata
567
+ (a === 100 && b >= 64 && b <= 127) // carrier-grade NAT
568
+ );
569
+ };
570
+ // Returns whether an IPv6 address is in a loopback, unique-local, or
571
+ // link-local range.
572
+ const isNonPublicIPv6 = (address: string): boolean => {
573
+ const normalized = address.toLowerCase();
574
+ return (
575
+ normalized === '::1' // loopback
576
+ || normalized === '::' // unspecified
577
+ || /^::ffff:/.test(normalized) && isNonPublicIPv4(normalized.replace(/^::ffff:/, '')) // IPv4-mapped
578
+ || /^f[cd][0-9a-f]{2}:/.test(normalized) // unique local (fc00::/7)
579
+ || /^fe[89ab][0-9a-f]:/.test(normalized) // link-local (fe80::/10)
580
+ );
581
+ };
582
+ // Returns whether an IP address (v4 or v6) is outside the public address
583
+ // space, i.e. is a loopback, private, link-local, or other reserved address
584
+ // that a public deployment should never intend to test.
585
+ const isNonPublicIP = (address: string): boolean => {
586
+ const version = net.isIP(address);
587
+ if (version === 4) {
588
+ return isNonPublicIPv4(address);
589
+ }
590
+ if (version === 6) {
591
+ return isNonPublicIPv6(address);
592
+ }
593
+ // Not a recognizable IP address, so treat it as non-public, i.e. as unsafe.
594
+ return true;
595
+ };
596
+ // Returns whether ALLOW_INTERNAL_TARGETS opts this deployment in to testing
597
+ // pages at private, loopback, or link-local addresses. A deployment that
598
+ // intentionally runs a Testaro worker inside a private network to test
599
+ // intranet pages sets this to "true"; a public deployment, whose Testaro
600
+ // workers may run on hosts with access to their own cloud metadata service,
601
+ // leaves it unset so private addresses are rejected by default (fail safe).
602
+ const allowsInternalTargets = (): boolean => process.env.ALLOW_INTERNAL_TARGETS === 'true';
603
+ // The signature of dns.lookup with {all: true}, extracted so tests can inject
604
+ // a fake resolver and avoid depending on real network/DNS access.
605
+ type LookupAll = (hostname: string, options: {all: true}) => Promise<{address: string; family: number}[]>;
606
+ // Returns whether the host a URL resolves to consists only of addresses this
607
+ // deployment is configured to allow as testing targets. This governs where
608
+ // the Testaro browser is allowed to navigate, so it is the control against
609
+ // submitting (or, via a redirect, being routed to) an unintended internal
610
+ // target such as a cloud metadata service or another host on a private
611
+ // network. It is deliberately independent of the syntax check that isURL
612
+ // performs, because it requires a DNS lookup, and therefore an internal
613
+ // deployment can use it to check both a requested URL (at submission time)
614
+ // and the actual, possibly redirected, URL of a report (at report-ingestion
615
+ // time). The lookup parameter defaults to the real dns.lookup and exists so
616
+ // tests can inject a fake resolver.
617
+ export const isAllowedTarget = async (url: string, lookup: LookupAll = dns.lookup): Promise<boolean> => {
618
+ // If this deployment allows internal targets, every syntactically valid URL is allowed.
619
+ if (allowsInternalTargets()) {
620
+ return true;
621
+ }
622
+ let hostname: string;
623
+ try {
624
+ hostname = new URL(url).hostname;
625
+ } catch {
626
+ return false;
627
+ }
628
+ // If the hostname is itself a literal IP address, check it directly.
629
+ if (net.isIP(hostname)) {
630
+ return !isNonPublicIP(hostname);
631
+ }
632
+ // Otherwise, resolve the hostname and reject it if any address it resolves
633
+ // to is non-public, since a hostname can resolve to more than one address
634
+ // and an attacker need control only one of them.
635
+ try {
636
+ const addresses = await lookup(hostname, {all: true});
637
+ return addresses.length > 0 && addresses.every(({address}) => !isNonPublicIP(address));
638
+ } catch {
639
+ // An unresolvable hostname cannot be a usable testing target either way.
640
+ return false;
641
+ }
642
+ };
552
643
  // Returns whether a string is the authorization code, the shared check every
553
644
  // manager-facing page and action uses to gate a submission. Centralized so all call
554
645
  // sites compare against process.env.AUTH_CODE the same way.
@@ -601,6 +692,31 @@ export const deleteTestRequests = (url: string) => testRequestsLock(async (): Pr
601
692
  // Returns the test acts of a report.
602
693
  export const getTestActs = (report: UsableReport): AnnotatedAct[] =>
603
694
  report.acts.filter(act => act.type === 'test');
695
+ // Returns whether all URLs actually visited for a report are allowed targets.
696
+ // A URL submitted for testing can pass isAllowedTarget at submission time and
697
+ // still result in a report on a disallowed target, because the browser
698
+ // running in a worker can be redirected (possibly through DNS rebinding, so
699
+ // the same hostname resolves differently at fetch time) to a host this
700
+ // deployment does not allow as a testing target. Since each test act records
701
+ // the URL it actually visited (actualURL, defaulting to the requested URL of
702
+ // the job if a tool did not report one), checking every act here, right
703
+ // before a report is stored or served, is what actually closes that gap,
704
+ // independently of whatever happened during navigation.
705
+ export const isAllowedReport = async (
706
+ report: Partial<Report>, lookup: LookupAll = dns.lookup
707
+ ): Promise<boolean> => {
708
+ const acts = Array.isArray(report.acts) ? report.acts : [];
709
+ const testActs = acts.filter((act): act is AnnotatedAct => act?.type === 'test');
710
+ const urlsVisited = new Set(
711
+ testActs.map(act => act.actualURL ?? report.target?.url).filter((url): url is string => !!url)
712
+ );
713
+ for (const url of urlsVisited) {
714
+ if (!(await isAllowedTarget(url, lookup))) {
715
+ return false;
716
+ }
717
+ }
718
+ return true;
719
+ };
604
720
  // Returns the standard instances of a report's test acts, optionally filtered.
605
721
  export const getTestActInstances = (
606
722
  report: UsableReport,