@jrpool/kilotest 50.0.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/requestFeature.test.ts +81 -3
- package/api/requestFeature.ts +46 -10
- package/api/requestRetest.test.ts +7 -0
- package/api/requestTest.test.ts +23 -0
- package/api/requestTest.ts +7 -1
- package/api/util.test.ts +9 -0
- package/apiIntegration.test.ts +17 -0
- package/docs/SERVICE.md +27 -6
- package/env.example +8 -0
- package/index.test.ts +137 -0
- package/index.ts +52 -5
- package/linkValidation.test.ts +1 -0
- package/package.json +1 -1
- package/robots.txt +1 -0
- package/smokeTest.ts +10 -5
- package/test/setup.ts +5 -0
- package/util.test.ts +171 -13
- package/util.ts +127 -16
- package/web/deleteNotesForm/deleteNotesForm.test.ts +237 -0
- package/web/deleteNotesForm/index.html +29 -0
- package/web/deleteNotesForm/index.ts +167 -0
- package/web/manage/index.html +1 -0
- package/web/tutorialAI/index.ts +6 -11
- package/web/tutorialAI/tutorialAI.test.ts +9 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +8 -2
- package/.claude/scheduled_tasks.lock +0 -1
package/index.test.ts
CHANGED
|
@@ -15,9 +15,23 @@ process.env.DB_DIR = fixtureDBDir;
|
|
|
15
15
|
process.env.AUTH_CODE = 'test-auth-code';
|
|
16
16
|
process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialWeb.json');
|
|
17
17
|
process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialAI.json');
|
|
18
|
+
process.env.FEATURE_REQUESTS_PATH = path.join(testCommentsDir, 'featureRequests.json');
|
|
18
19
|
process.env.TESTARO_WORKERS = JSON.stringify({
|
|
19
20
|
worker1: {secret: 'secret1', name: 'Worker One'}
|
|
20
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';
|
|
26
|
+
// Blank the alert configuration unconditionally, so this file sends no real alert
|
|
27
|
+
// emails (e.g. from the requestTest/requestRetest/tutorial-comment tests below) even
|
|
28
|
+
// when run directly (e.g. `npx tsx --test index.test.ts`) rather than via `npm test`,
|
|
29
|
+
// which normally guards against this by --require-ing test/setup.ts first. dotenv does
|
|
30
|
+
// not override an already-set env var, so setting these here, before index.ts is ever
|
|
31
|
+
// imported, prevents a real .env file's alert credentials from ever taking effect.
|
|
32
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
33
|
+
process.env[key] = '';
|
|
34
|
+
}
|
|
21
35
|
|
|
22
36
|
// IMPORTS
|
|
23
37
|
|
|
@@ -58,6 +72,7 @@ beforeEach(async () => {
|
|
|
58
72
|
await fs.mkdir(testCommentsDir, {recursive: true});
|
|
59
73
|
await fs.writeFile(path.join(testCommentsDir, 'tutorialWeb.json'), '[]\n');
|
|
60
74
|
await fs.writeFile(path.join(testCommentsDir, 'tutorialAI.json'), '[]\n');
|
|
75
|
+
await fs.writeFile(path.join(testCommentsDir, 'featureRequests.json'), '[]\n');
|
|
61
76
|
});
|
|
62
77
|
|
|
63
78
|
after(async () => {
|
|
@@ -215,6 +230,30 @@ test('GET /index.html serves the home page', async () => {
|
|
|
215
230
|
assert.ok(res.headers['content-type'].includes('text/html'));
|
|
216
231
|
});
|
|
217
232
|
|
|
233
|
+
// TESTS: HEAD routes
|
|
234
|
+
|
|
235
|
+
test('HEAD / returns the same status and headers as GET /, with no body', async () => {
|
|
236
|
+
const getRes = await request('GET', '/');
|
|
237
|
+
const headRes = await request('HEAD', '/');
|
|
238
|
+
assert.equal(headRes.statusCode, getRes.statusCode);
|
|
239
|
+
assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
|
|
240
|
+
assert.equal(headRes.body, '');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('HEAD /index.html returns the same status and headers as GET /, with no body', async () => {
|
|
244
|
+
const getRes = await request('GET', '/');
|
|
245
|
+
const headRes = await request('HEAD', '/index.html');
|
|
246
|
+
assert.equal(headRes.statusCode, getRes.statusCode);
|
|
247
|
+
assert.equal(headRes.headers['content-type'], getRes.headers['content-type']);
|
|
248
|
+
assert.equal(headRes.body, '');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('HEAD /robots.txt returns an invalid HEAD request error, since only the home page supports HEAD', async () => {
|
|
252
|
+
const res = await request('HEAD', '/robots.txt');
|
|
253
|
+
assert.equal(res.statusCode, 400);
|
|
254
|
+
assert.equal(res.body, '');
|
|
255
|
+
});
|
|
256
|
+
|
|
218
257
|
test('GET /robots.txt serves the robots file as text', async () => {
|
|
219
258
|
const res = await request('GET', '/robots.txt');
|
|
220
259
|
assert.equal(res.statusCode, 200);
|
|
@@ -870,6 +909,21 @@ test('POST /requestTest.html with non-https URL returns an error', async () => {
|
|
|
870
909
|
assert.ok(res.body.includes('Invalid test request'));
|
|
871
910
|
});
|
|
872
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
|
+
|
|
873
927
|
test('POST /requestRetest.html/260202T0000/new with missing why returns an error', async () => {
|
|
874
928
|
const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
|
|
875
929
|
why: ''
|
|
@@ -1192,6 +1246,62 @@ test('POST /worker/report with a claimed job but an unusable report returns an e
|
|
|
1192
1246
|
await fs.unlink(failedJobPath).catch(() => {});
|
|
1193
1247
|
});
|
|
1194
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
|
+
|
|
1195
1305
|
// TESTS: remaining error branches and web pages
|
|
1196
1306
|
|
|
1197
1307
|
test('GET /style.css with a read error returns an error page', async () => {
|
|
@@ -1247,6 +1357,7 @@ const htmlPagePaths = [
|
|
|
1247
1357
|
'/pruneReportsForm.html',
|
|
1248
1358
|
'/rewindReportsForm.html',
|
|
1249
1359
|
'/ai0BalanceForm.html',
|
|
1360
|
+
'/deleteNotesForm.html',
|
|
1250
1361
|
'/metrics.html'
|
|
1251
1362
|
];
|
|
1252
1363
|
|
|
@@ -1424,6 +1535,32 @@ test('POST /rewindReportsForm.html with no selections shows the form and records
|
|
|
1424
1535
|
assert.equal(await getManagerActivityCount('rewindReportsForm', 'ok'), countBefore + 1);
|
|
1425
1536
|
});
|
|
1426
1537
|
|
|
1538
|
+
test('POST /deleteNotesForm.html deletes multiple selected notes across sources in one submission', async () => {
|
|
1539
|
+
await fs.writeFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, JSON.stringify([
|
|
1540
|
+
{timeStamp: '260101T0000', content: 'A web tutorial comment'}
|
|
1541
|
+
]));
|
|
1542
|
+
await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, JSON.stringify([
|
|
1543
|
+
{timeStamp: '260101T0001', content: 'A feature request'},
|
|
1544
|
+
{timeStamp: '260101T0001', content: 'A different feature request'}
|
|
1545
|
+
]));
|
|
1546
|
+
// Reconstructing the POST body's query string uses querystring.stringify, which (unlike
|
|
1547
|
+
// the URLSearchParams object constructor) preserves multiple values for the same field
|
|
1548
|
+
// name as repeated key=value pairs rather than collapsing them into one comma-joined
|
|
1549
|
+
// value, so this exercises that multi-checkbox submission actually deletes every
|
|
1550
|
+
// selected note, not just the one whose value happens to survive the collapse.
|
|
1551
|
+
const res = await formRequest('POST', '/deleteNotesForm.html', [
|
|
1552
|
+
['authCode', 'test-auth-code'],
|
|
1553
|
+
['note', `tutorialWeb\t${encodeURIComponent('A web tutorial comment')}`],
|
|
1554
|
+
['note', `featureRequest\t${encodeURIComponent('A feature request')}`]
|
|
1555
|
+
]);
|
|
1556
|
+
assert.equal(res.statusCode, 200);
|
|
1557
|
+
const webComments = JSON.parse(await fs.readFile(process.env.TUTORIAL_WEB_COMMENTS_PATH!, 'utf8'));
|
|
1558
|
+
assert.equal(webComments.length, 0);
|
|
1559
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
1560
|
+
assert.equal(featureRequests.length, 1);
|
|
1561
|
+
assert.equal(featureRequests[0].content, 'A different feature request');
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1427
1564
|
test('GET /pruneReportsForm.html records a managerActivity failure when a report file is corrupt', {timeout: 500}, async () => {
|
|
1428
1565
|
const reportPath = path.join(fixtureDBDir, 'reports', '260101T0001-ct.json');
|
|
1429
1566
|
const backup = await fs.readFile(reportPath, 'utf8');
|
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,
|
|
@@ -42,7 +44,9 @@ import {answer as tutorialAI, handleComment as handleTutorialAIComment} from './
|
|
|
42
44
|
import http, {type IncomingMessage, type ServerResponse} from 'node:http';
|
|
43
45
|
import https from 'node:https';
|
|
44
46
|
import path from 'node:path';
|
|
47
|
+
import querystring from 'node:querystring';
|
|
45
48
|
import {answer as ai0BalanceForm} from './web/ai0BalanceForm/index.ts';
|
|
49
|
+
import {answer as deleteNotesForm} from './web/deleteNotesForm/index.ts';
|
|
46
50
|
import {answer as enqueue} from './web/enqueue/index.ts';
|
|
47
51
|
import {answer as enqueueForm} from './web/enqueueForm/index.ts';
|
|
48
52
|
import {answer as expungeReportsForm} from './web/expungeReportsForm/index.ts';
|
|
@@ -97,6 +101,7 @@ type PageHandler = (...args: any[]) => Promise<AnswerData>;
|
|
|
97
101
|
|
|
98
102
|
const answer: {
|
|
99
103
|
ai0BalanceForm: PageHandler;
|
|
104
|
+
deleteNotesForm: PageHandler;
|
|
100
105
|
enqueue: PageHandler;
|
|
101
106
|
enqueueForm: PageHandler;
|
|
102
107
|
expungeReportsForm: PageHandler;
|
|
@@ -126,6 +131,7 @@ const answer: {
|
|
|
126
131
|
[key: string]: PageHandler | undefined;
|
|
127
132
|
} = {
|
|
128
133
|
ai0BalanceForm,
|
|
134
|
+
deleteNotesForm,
|
|
129
135
|
enqueue,
|
|
130
136
|
enqueueForm,
|
|
131
137
|
expungeReportsForm,
|
|
@@ -207,6 +213,7 @@ export const routes = {
|
|
|
207
213
|
'/api/*',
|
|
208
214
|
'/mcp',
|
|
209
215
|
'/ai0BalanceForm.html',
|
|
216
|
+
'/deleteNotesForm.html',
|
|
210
217
|
'/expungeReportsForm.html',
|
|
211
218
|
'/hideReportForm.html',
|
|
212
219
|
'/metrics.html',
|
|
@@ -240,6 +247,7 @@ const managerPages = new Set([
|
|
|
240
247
|
'unhideReportForm',
|
|
241
248
|
'ai0BalanceForm',
|
|
242
249
|
'renewWCAGForm',
|
|
250
|
+
'deleteNotesForm',
|
|
243
251
|
'metrics'
|
|
244
252
|
]);
|
|
245
253
|
// The set of manager pages (a subset of managerPages) whose single answer() function both
|
|
@@ -255,6 +263,7 @@ const selfSubmittingManagerPages = new Set([
|
|
|
255
263
|
'showHiddenReportsForm',
|
|
256
264
|
'unhideReportForm',
|
|
257
265
|
'ai0BalanceForm',
|
|
266
|
+
'deleteNotesForm',
|
|
258
267
|
'metrics'
|
|
259
268
|
]);
|
|
260
269
|
// The subset of selfSubmittingManagerPages that must never be served on a direct GET
|
|
@@ -857,6 +866,19 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
857
866
|
);
|
|
858
867
|
}
|
|
859
868
|
}
|
|
869
|
+
// Otherwise, if the request is a HEAD request: only the home page supports HEAD, since
|
|
870
|
+
// UptimeRobot's uptime check is the only known HEAD caller and it targets only that path
|
|
871
|
+
// (see "Health monitoring" in docs/SERVICE.md). Its response must match what GET / would
|
|
872
|
+
// send, minus the body, so that a monitor reading headers alone sees the real page state.
|
|
873
|
+
else if (method === 'HEAD') {
|
|
874
|
+
if (['/', '/index.html'].includes(pathname)) {
|
|
875
|
+
setHeaders('text/html', '/index.html', 'medium');
|
|
876
|
+
response.end();
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
await serveError({message: `ERROR: Invalid HEAD request (${pathname})`}, response, true);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
860
882
|
// Otherwise, if the request is a POST request:
|
|
861
883
|
else if (method === 'POST') {
|
|
862
884
|
// If the path is not authorized for POST requests:
|
|
@@ -879,7 +901,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
879
901
|
else if (pageName === 'requestTest.html') {
|
|
880
902
|
const {description, url, why} = postData as {description?: string; url: string; why?: string};
|
|
881
903
|
// If the request is valid:
|
|
882
|
-
if (description && isURL(url) && why) {
|
|
904
|
+
if (description && isURL(url) && why && await isAllowedTarget(url)) {
|
|
883
905
|
// If a report on the page is already available:
|
|
884
906
|
if (await isReportAvailable(description, url)) {
|
|
885
907
|
// Report the error.
|
|
@@ -1023,7 +1045,12 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
1023
1045
|
setHeaders('text/html', pathname, 'ultra');
|
|
1024
1046
|
// Reconstruct a query string from the POST body, so the page's answer() function
|
|
1025
1047
|
// can read its submitted parameters the same way it reads a GET query string.
|
|
1026
|
-
|
|
1048
|
+
// querystring.stringify (rather than the URLSearchParams object constructor) is
|
|
1049
|
+
// required here, because postData, from querystring.parse in getPOSTData, holds
|
|
1050
|
+
// repeated form fields (e.g. multiple checked checkboxes sharing a name) as an
|
|
1051
|
+
// array; the URLSearchParams constructor would join such an array into a single
|
|
1052
|
+
// comma-separated value instead of preserving it as repeated key=value pairs.
|
|
1053
|
+
const search = `?${querystring.stringify(postData as Record<string, string | string[]>)}`;
|
|
1027
1054
|
// Get the answer data. The method is passed so the handler only processes a
|
|
1028
1055
|
// submission for POST, never for GET, regardless of what its query string contains.
|
|
1029
1056
|
const answerData = await answer[topic]!(pathTail, search, method);
|
|
@@ -1078,8 +1105,28 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
1078
1105
|
}
|
|
1079
1106
|
// If the job was actually assigned to this worker:
|
|
1080
1107
|
if (typeof claimedJob === 'object' && claimedJob !== null && (claimedJob as {sources?: {worker?: string}}).sources?.worker === workerName) {
|
|
1081
|
-
// If the report is
|
|
1082
|
-
|
|
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)) {
|
|
1083
1130
|
console.log(`Testaro report ${id} was received from worker ${workerName}`);
|
|
1084
1131
|
// Add the public worker name to the report.
|
|
1085
1132
|
report!.sources = {...report!.sources, worker: workerName};
|
|
@@ -1214,7 +1261,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
1214
1261
|
}
|
|
1215
1262
|
}
|
|
1216
1263
|
}
|
|
1217
|
-
// Otherwise, i.e. if it is
|
|
1264
|
+
// Otherwise, i.e. if it is none of GET, HEAD, or POST:
|
|
1218
1265
|
else {
|
|
1219
1266
|
// Report its invalidity. (Caddy handles OPTIONS requests.)
|
|
1220
1267
|
await serveError({message: 'ERROR: Invalid request method'}, response, true);
|
package/linkValidation.test.ts
CHANGED
package/package.json
CHANGED
package/robots.txt
CHANGED
package/smokeTest.ts
CHANGED
|
@@ -41,6 +41,7 @@ const concretePaths: Record<string, Record<string, string>> = {
|
|
|
41
41
|
'/api/*': '/api/requestFeature',
|
|
42
42
|
'/mcp': '/mcp',
|
|
43
43
|
'/ai0BalanceForm.html': '/ai0BalanceForm.html',
|
|
44
|
+
'/deleteNotesForm.html': '/deleteNotesForm.html',
|
|
44
45
|
'/expungeReportsForm.html': '/expungeReportsForm.html',
|
|
45
46
|
'/hideReportForm.html': '/hideReportForm.html',
|
|
46
47
|
'/metrics.html': '/metrics.html',
|
|
@@ -64,6 +65,7 @@ const postBodies: Record<string, object> = {
|
|
|
64
65
|
'/api/requestFeature': {feature: 'smoke test'},
|
|
65
66
|
'/mcp': {},
|
|
66
67
|
'/ai0BalanceForm.html': {authCode: 'invalid'},
|
|
68
|
+
'/deleteNotesForm.html': {authCode: 'invalid'},
|
|
67
69
|
'/expungeReportsForm.html': {authCode: 'invalid'},
|
|
68
70
|
'/hideReportForm.html': {authCode: 'invalid'},
|
|
69
71
|
'/metrics.html': {authCode: 'invalid'},
|
|
@@ -229,20 +231,23 @@ const isMCPErrorResponse = (result: {statusCode: number | undefined, body: strin
|
|
|
229
231
|
console.log(`FAIL: POST ${mcpPath} initialize -> error: ${message}`);
|
|
230
232
|
failures++;
|
|
231
233
|
}
|
|
232
|
-
// Send a
|
|
234
|
+
// Send a POST with an Accept header the MCP SDK itself rejects (missing text/event-stream)
|
|
235
|
+
// to verify that invalid MCP requests get JSON-RPC errors end-to-end. A browser-style GET
|
|
236
|
+
// is not used here because index.ts deliberately intercepts it before handleMCP, serving a
|
|
237
|
+
// human-readable explainer page instead of a JSON-RPC error.
|
|
233
238
|
try {
|
|
234
|
-
const probe = await sendMCPRequest('
|
|
239
|
+
const probe = await sendMCPRequest('POST', 'application/json', mcpInitialize);
|
|
235
240
|
if (isMCPErrorResponse(probe)) {
|
|
236
|
-
console.log(`PASS:
|
|
241
|
+
console.log(`PASS: POST ${mcpPath} bad accept -> ${probe.statusCode} (JSON-RPC error)`);
|
|
237
242
|
}
|
|
238
243
|
else {
|
|
239
|
-
console.log(`FAIL:
|
|
244
|
+
console.log(`FAIL: POST ${mcpPath} bad accept -> ${probe.statusCode} (body: ${probe.body.slice(0, 120)})`);
|
|
240
245
|
failures++;
|
|
241
246
|
}
|
|
242
247
|
}
|
|
243
248
|
catch (error) {
|
|
244
249
|
const message = error instanceof Error ? error.message : String(error);
|
|
245
|
-
console.log(`FAIL:
|
|
250
|
+
console.log(`FAIL: POST ${mcpPath} bad accept -> error: ${message}`);
|
|
246
251
|
failures++;
|
|
247
252
|
}
|
|
248
253
|
console.log(`\n=== ${failures === 0 ? 'All checks passed' : `${failures} failure(s)`} ===`);
|
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,8 +7,18 @@
|
|
|
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';
|
|
13
|
+
|
|
14
|
+
// Blank the alert configuration unconditionally, before util.ts (which processTestRequest
|
|
15
|
+
// calls sendAlert from) is ever imported below, so this file sends no real alert emails
|
|
16
|
+
// even when run directly (e.g. `npx tsx --test util.test.ts`) rather than via `npm test`,
|
|
17
|
+
// which normally guards against this via test/setup.ts.
|
|
18
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
19
|
+
process.env[key] = '';
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
import {
|
|
13
23
|
annotateReportObject,
|
|
14
24
|
checkCommentDuplicate,
|
|
@@ -42,6 +52,8 @@ import {
|
|
|
42
52
|
getWeightName,
|
|
43
53
|
hiddenReportsPath,
|
|
44
54
|
htmlSafe,
|
|
55
|
+
isAllowedReport,
|
|
56
|
+
isAllowedTarget,
|
|
45
57
|
isJobID,
|
|
46
58
|
isReportAvailable,
|
|
47
59
|
isTimeStamp,
|
|
@@ -116,26 +128,18 @@ test('checkCommentDuplicate accepts a comment with no existing comments', () =>
|
|
|
116
128
|
assert.deepEqual(result, {status: 'ok'});
|
|
117
129
|
});
|
|
118
130
|
|
|
119
|
-
test('checkCommentDuplicate rejects a comment
|
|
131
|
+
test('checkCommentDuplicate rejects a comment identical to one already stored, regardless of when', () => {
|
|
120
132
|
const content = 'a'.repeat(20);
|
|
121
|
-
const comments = [{
|
|
133
|
+
const comments = [{content}];
|
|
122
134
|
const result = checkCommentDuplicate(comments, content);
|
|
123
135
|
assert.deepEqual(result, {
|
|
124
136
|
status: 'error',
|
|
125
|
-
message: 'Your comment
|
|
137
|
+
message: 'Your comment is identical to one already submitted, but you are welcome to submit a different comment'
|
|
126
138
|
});
|
|
127
139
|
});
|
|
128
140
|
|
|
129
|
-
test('checkCommentDuplicate accepts a comment
|
|
130
|
-
const
|
|
131
|
-
const oldTimeStamp = getTimeStamp(new Date(Date.now() - 2000000));
|
|
132
|
-
const comments = [{timeStamp: oldTimeStamp, content}];
|
|
133
|
-
const result = checkCommentDuplicate(comments, content);
|
|
134
|
-
assert.deepEqual(result, {status: 'ok'});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('checkCommentDuplicate accepts a comment that differs from a recent one', () => {
|
|
138
|
-
const comments = [{timeStamp: getNowStamp(), content: 'a'.repeat(20)}];
|
|
141
|
+
test('checkCommentDuplicate accepts a comment that differs from a stored one', () => {
|
|
142
|
+
const comments = [{content: 'a'.repeat(20)}];
|
|
139
143
|
const result = checkCommentDuplicate(comments, 'b'.repeat(20));
|
|
140
144
|
assert.deepEqual(result, {status: 'ok'});
|
|
141
145
|
});
|
|
@@ -365,6 +369,160 @@ test('isURL returns false for an invalid URL', () => {
|
|
|
365
369
|
assert.equal(isURL('not-a-url'), false);
|
|
366
370
|
});
|
|
367
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
|
+
|
|
368
526
|
test('isValidAuthCode returns true when the code matches AUTH_CODE', () => {
|
|
369
527
|
const saved = process.env.AUTH_CODE;
|
|
370
528
|
process.env.AUTH_CODE = 'secret-code';
|