@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.
- package/.github/workflows/ci.yml +2 -2
- package/.github/workflows/periodic-smoke-tests.yml +2 -2
- package/.github/workflows/publish-mcp.yml +1 -1
- package/README.md +5 -5
- package/api/requestTest.test.ts +16 -0
- package/api/requestTest.ts +7 -1
- package/docs/SERVICE.md +9 -1
- package/env.example +8 -0
- package/index.test.ts +75 -0
- package/index.ts +25 -3
- package/package.json +1 -1
- package/test/setup.ts +5 -0
- package/util.test.ts +157 -0
- package/util.ts +116 -0
package/.github/workflows/ci.yml
CHANGED
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
|
|
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
|
|
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
|
|
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
|
|
package/api/requestTest.test.ts
CHANGED
|
@@ -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;
|
package/api/requestTest.ts
CHANGED
|
@@ -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
|
|
1107
|
-
|
|
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
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,
|