@zerotal/core 1.6.3 → 1.7.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/CHANGELOG.md +74 -1
- package/api-surface.md +3609 -0
- package/package.json +2 -1
- package/src/application/Application.ts +121 -8
- package/src/command/builtin/DoctorCommand.ts +53 -6
- package/src/doctor/HeaderProbe.ts +164 -0
- package/src/events/Emitter.ts +24 -0
- package/src/events/FrameworkEvents.ts +42 -0
- package/src/index.ts +2 -0
- package/src/middleware/BaseMiddleware.ts +12 -1
- package/src/middleware/SecureHeadersMiddleware.ts +54 -23
- package/src/provider/StorageProvider.ts +4 -1
- package/src/router/RouteHandler.ts +5 -0
- package/src/router/Router.ts +8 -2
- package/src/security/index.ts +6 -0
- package/src/security/redactGraph.ts +98 -0
- package/src/support/deepMerge.ts +42 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
36
|
"CHANGELOG.md",
|
|
37
|
+
"api-surface.md",
|
|
37
38
|
"src",
|
|
38
39
|
"!src/**/*.test.ts",
|
|
39
40
|
"!src/**/*.test.tsx",
|
|
@@ -27,7 +27,10 @@ import {
|
|
|
27
27
|
import { builtinConcerns } from "../conventions/builtinConcerns.ts";
|
|
28
28
|
import { ConfigManager } from "../config/ConfigManager.ts";
|
|
29
29
|
import { DEFAULT_MAX_REQUEST_BODY_SIZE } from "../config/AppConfig.ts";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
SecureHeadersMiddleware,
|
|
32
|
+
staticSecurityHeaders,
|
|
33
|
+
} from "../middleware/SecureHeadersMiddleware.ts";
|
|
31
34
|
import { isAllowedOrigin, allowedOriginsFrom } from "../http/originGuard.ts";
|
|
32
35
|
import { rescueSync } from "../helpers/index.ts";
|
|
33
36
|
import { configureAssets, setAssetVersion, assetVersion } from "../assets/assets.ts";
|
|
@@ -237,10 +240,13 @@ export async function _lazyStaticResponse(
|
|
|
237
240
|
const relative = pathname.slice(trimmedPrefix.length).replace(/^\//, "");
|
|
238
241
|
const file = Bun.file(`${rootDir}/${relative}`);
|
|
239
242
|
if (await file.exists()) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
243
|
+
// The same headers Bun's native static routes carry (see Router.compile).
|
|
244
|
+
// This path returns before the pipeline runs, so without it a file served
|
|
245
|
+
// lazily — every file under the dev worker — would be the one response in
|
|
246
|
+
// the app with no security headers on it.
|
|
247
|
+
return new Response(file as unknown as BodyInit, {
|
|
248
|
+
headers: { ...staticSecurityHeaders(), ...options?.headers },
|
|
249
|
+
});
|
|
244
250
|
}
|
|
245
251
|
}
|
|
246
252
|
return undefined;
|
|
@@ -260,6 +266,39 @@ export interface WebSocketHandlers {
|
|
|
260
266
|
*/
|
|
261
267
|
export type AppScopeInstaller = () => () => void;
|
|
262
268
|
|
|
269
|
+
/**
|
|
270
|
+
* What one provider cost to boot, and what it contributed.
|
|
271
|
+
*
|
|
272
|
+
* @see Application.providerReport
|
|
273
|
+
*/
|
|
274
|
+
export interface ProviderReport {
|
|
275
|
+
/** Provider class name. The array is in boot order. */
|
|
276
|
+
name: string;
|
|
277
|
+
/**
|
|
278
|
+
* Wall-clock milliseconds across all three lifecycle hooks.
|
|
279
|
+
*
|
|
280
|
+
* `onBooted` runs concurrently across providers, so these do not sum to the
|
|
281
|
+
* application's boot time — they overlap, and the report reports that rather
|
|
282
|
+
* than serialising the boot to produce a tidier number.
|
|
283
|
+
*/
|
|
284
|
+
durationMs: number;
|
|
285
|
+
/** Container tokens this provider bound, as names. */
|
|
286
|
+
bindings: string[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A container token as a readable name.
|
|
291
|
+
*
|
|
292
|
+
* Tokens are class constructors or plain strings; the map keys are neither
|
|
293
|
+
* sorted nor serialisable as they stand, and a reader wants `CacheManager`, not
|
|
294
|
+
* `[class CacheManager]`.
|
|
295
|
+
*/
|
|
296
|
+
function _tokenName(token: unknown): string {
|
|
297
|
+
if (typeof token === "string") return token;
|
|
298
|
+
if (typeof token === "function") return token.name || "‹anonymous›";
|
|
299
|
+
return String(token);
|
|
300
|
+
}
|
|
301
|
+
|
|
263
302
|
const _appScopeInstallers: AppScopeInstaller[] = [];
|
|
264
303
|
|
|
265
304
|
/**
|
|
@@ -318,6 +357,8 @@ export class Application {
|
|
|
318
357
|
_env: Environment = "web";
|
|
319
358
|
private _booted = false;
|
|
320
359
|
private _bootDurationMs: number | undefined = undefined;
|
|
360
|
+
/** Per-provider boot cost and container provenance; see {@link providerReport}. */
|
|
361
|
+
private _providerReport: ProviderReport[] = [];
|
|
321
362
|
private _static?: ReturnType<typeof Bun.serve>;
|
|
322
363
|
private _configMap: Record<string, Record<string, unknown>> | undefined = undefined;
|
|
323
364
|
/** Tracks where config came from, to reject conflicting overrides via useConfig(). */
|
|
@@ -609,6 +650,60 @@ export class Application {
|
|
|
609
650
|
return this._bootDurationMs;
|
|
610
651
|
}
|
|
611
652
|
|
|
653
|
+
/**
|
|
654
|
+
* What each provider cost to boot, and what it put in the container.
|
|
655
|
+
*
|
|
656
|
+
* `bootDurationMs` says the app took 240ms and nothing said which provider
|
|
657
|
+
* spent it; the container lists a hundred bindings and nothing said who bound
|
|
658
|
+
* them. Both are answered here, in boot order — which is itself the answer to
|
|
659
|
+
* a third question, since provider order decides who wins a contested binding.
|
|
660
|
+
*
|
|
661
|
+
* Empty until `boot()` runs. Populated in every environment: it costs one
|
|
662
|
+
* `performance.now()` pair and one registry diff per provider at boot, and a
|
|
663
|
+
* report that only exists in development is one you cannot ask for when a
|
|
664
|
+
* staging boot is the slow one.
|
|
665
|
+
*
|
|
666
|
+
* @category Lifecycle
|
|
667
|
+
*/
|
|
668
|
+
get providerReport(): readonly ProviderReport[] {
|
|
669
|
+
return this._providerReport;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Time one provider lifecycle hook and attribute anything it bound.
|
|
674
|
+
*
|
|
675
|
+
* Accumulates across the three phases, so a provider that binds in
|
|
676
|
+
* `onRegister` and spends its time in `onBooting` reads as one row.
|
|
677
|
+
*/
|
|
678
|
+
private _recordProviderWork<T>(provider: ServiceProvider, work: () => T): T {
|
|
679
|
+
const name = provider.constructor.name;
|
|
680
|
+
let row = this._providerReport.find((entry) => entry.name === name);
|
|
681
|
+
if (!row) {
|
|
682
|
+
row = { name, durationMs: 0, bindings: [] };
|
|
683
|
+
this._providerReport.push(row);
|
|
684
|
+
}
|
|
685
|
+
const before = new Set(this.container.registry.keys());
|
|
686
|
+
const started = performance.now();
|
|
687
|
+
const finish = (): T => {
|
|
688
|
+
row!.durationMs = Math.round((row!.durationMs + performance.now() - started) * 100) / 100;
|
|
689
|
+
for (const token of this.container.registry.keys()) {
|
|
690
|
+
if (!before.has(token)) row!.bindings.push(_tokenName(token));
|
|
691
|
+
}
|
|
692
|
+
return undefined as T;
|
|
693
|
+
};
|
|
694
|
+
const result = work();
|
|
695
|
+
// An async hook is only finished when its promise is — timing it
|
|
696
|
+
// synchronously would report every `await` in it as free.
|
|
697
|
+
if (result instanceof Promise) {
|
|
698
|
+
return result.then((value: unknown) => {
|
|
699
|
+
finish();
|
|
700
|
+
return value;
|
|
701
|
+
}) as T;
|
|
702
|
+
}
|
|
703
|
+
finish();
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
|
|
612
707
|
/**
|
|
613
708
|
* The runtime environment this application is running in.
|
|
614
709
|
*
|
|
@@ -1056,7 +1151,15 @@ export class Application {
|
|
|
1056
1151
|
for (const callback of this._bindCallbacks) callback(this.container);
|
|
1057
1152
|
|
|
1058
1153
|
// Phase 1 — synchronous, binds into container.
|
|
1059
|
-
|
|
1154
|
+
//
|
|
1155
|
+
// Timed, and the container is diffed around each provider, so the inspector
|
|
1156
|
+
// can answer "who bound `cache`, and what did booting it cost". Provenance
|
|
1157
|
+
// by diff rather than by having the container record a registrar: it keeps
|
|
1158
|
+
// the cost at boot instead of on every binding, and adds no mutable state to
|
|
1159
|
+
// the container for a question only a debugging tool asks.
|
|
1160
|
+
for (const provider of this._activeProviders) {
|
|
1161
|
+
this._recordProviderWork(provider, () => provider.onRegister());
|
|
1162
|
+
}
|
|
1060
1163
|
|
|
1061
1164
|
// Config validation — providers have registered their namespace validators
|
|
1062
1165
|
// in onRegister; run them before anything boots. In a production-like
|
|
@@ -1069,10 +1172,20 @@ export class Application {
|
|
|
1069
1172
|
}
|
|
1070
1173
|
|
|
1071
1174
|
// Phase 2 — sequential in registration order.
|
|
1072
|
-
for (const provider of this._activeProviders)
|
|
1175
|
+
for (const provider of this._activeProviders) {
|
|
1176
|
+
await this._recordProviderWork(provider, () => provider.onBooting());
|
|
1177
|
+
}
|
|
1073
1178
|
|
|
1074
1179
|
// Phase 3 — async, all providers have finished booting.
|
|
1075
|
-
|
|
1180
|
+
//
|
|
1181
|
+
// These run concurrently, so the recorded durations overlap and do not sum
|
|
1182
|
+
// to the phase. That is the truth about this phase and the report says so
|
|
1183
|
+
// rather than serialising the boot to make a tidier number.
|
|
1184
|
+
await Promise.all(
|
|
1185
|
+
this._activeProviders.map((provider) =>
|
|
1186
|
+
this._recordProviderWork(provider, () => provider.onBooted()),
|
|
1187
|
+
),
|
|
1188
|
+
);
|
|
1076
1189
|
|
|
1077
1190
|
// Ensure config and events are resolved so makeSync() works below.
|
|
1078
1191
|
await this.container.make("config");
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
* `bun zt doctor` — run every static sanity check against this app and print
|
|
3
3
|
* the findings with their fixes. Exits 1 when anything is broken outright.
|
|
4
4
|
*
|
|
5
|
-
* With `--url`, it also
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* With `--url`, it also reads the deployed app from the outside, through whatever proxy is
|
|
6
|
+
* in front of it — the only way to see two classes of failure. The WebSocket transport,
|
|
7
|
+
* which a proxy can gate or drop while leaving the app healthy from the inside and inert in
|
|
8
|
+
* the browser. And security headers sent twice, because a header the app sets and the proxy
|
|
9
|
+
* also sets is invisible from in here: the app's own view is the value it wrote.
|
|
8
10
|
*/
|
|
9
11
|
import { Command } from "../Command.ts";
|
|
10
12
|
import type { Application } from "../../application/Application.ts";
|
|
11
13
|
import { runDoctor } from "../../doctor/AppDoctor.ts";
|
|
12
14
|
import type { DoctorReportEntry } from "../../doctor/AppDoctor.ts";
|
|
13
15
|
import { probeTransport } from "../../doctor/TransportProbe.ts";
|
|
16
|
+
import { probeHeaders } from "../../doctor/HeaderProbe.ts";
|
|
14
17
|
|
|
15
18
|
export class DoctorCommand extends Command {
|
|
16
19
|
static override commandName = "doctor";
|
|
@@ -21,7 +24,8 @@ export class DoctorCommand extends Command {
|
|
|
21
24
|
name: "url",
|
|
22
25
|
type: "string" as const,
|
|
23
26
|
description:
|
|
24
|
-
"Also
|
|
27
|
+
"Also read the deployed app at this public URL: handshake its WebSocket transport " +
|
|
28
|
+
"as a browser would, and report security headers the response carries twice",
|
|
25
29
|
},
|
|
26
30
|
];
|
|
27
31
|
|
|
@@ -34,9 +38,13 @@ export class DoctorCommand extends Command {
|
|
|
34
38
|
for (const entry of report) this._print(entry);
|
|
35
39
|
|
|
36
40
|
const probeFailures = await this._probeTransport(app);
|
|
41
|
+
const headerFindings = await this._probeHeaders();
|
|
37
42
|
|
|
38
|
-
const warns = report.filter((e) => e.result.status === "warn").length;
|
|
39
|
-
const fails =
|
|
43
|
+
const warns = report.filter((e) => e.result.status === "warn").length + headerFindings.warnings;
|
|
44
|
+
const fails =
|
|
45
|
+
report.filter((e) => e.result.status === "fail").length +
|
|
46
|
+
probeFailures +
|
|
47
|
+
headerFindings.failures;
|
|
40
48
|
this.newLine();
|
|
41
49
|
// Throw rather than `process.exit(1)` — same exit code from the CLI (the runner
|
|
42
50
|
// converts it), but composable. Exiting here killed any caller running the
|
|
@@ -86,6 +94,45 @@ export class DoctorCommand extends Command {
|
|
|
86
94
|
return failures;
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Report security headers the deployed response carries more than once.
|
|
99
|
+
*
|
|
100
|
+
* Only reachable from outside: the app's own view of a header is the value it
|
|
101
|
+
* wrote, which is correct as far as it goes — the proxy's copy is invisible
|
|
102
|
+
* from in here. Conflicting values count as failures because a control that
|
|
103
|
+
* different browsers apply differently is not a control; identical duplicates
|
|
104
|
+
* are a warning, because they are a conflict waiting for someone to edit one
|
|
105
|
+
* side.
|
|
106
|
+
*/
|
|
107
|
+
private async _probeHeaders(): Promise<{ failures: number; warnings: number }> {
|
|
108
|
+
const url = this.flags["url"] as string | undefined;
|
|
109
|
+
if (!url) return { failures: 0, warnings: 0 };
|
|
110
|
+
|
|
111
|
+
const findings = await probeHeaders(url);
|
|
112
|
+
this.newLine();
|
|
113
|
+
this.section("Response headers");
|
|
114
|
+
|
|
115
|
+
if (findings.length === 0) {
|
|
116
|
+
this.line("✓ No duplicated security headers.");
|
|
117
|
+
return { failures: 0, warnings: 0 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let failures = 0;
|
|
121
|
+
let warnings = 0;
|
|
122
|
+
for (const finding of findings) {
|
|
123
|
+
const line = `${finding.conflicting ? "✗" : "!"} ${finding.header} — ${finding.message}`;
|
|
124
|
+
if (finding.conflicting) {
|
|
125
|
+
this.error(line);
|
|
126
|
+
failures++;
|
|
127
|
+
} else {
|
|
128
|
+
this.warn(line);
|
|
129
|
+
warnings++;
|
|
130
|
+
}
|
|
131
|
+
if (finding.fix) this.line(` fix: ${finding.fix}`);
|
|
132
|
+
}
|
|
133
|
+
return { failures, warnings };
|
|
134
|
+
}
|
|
135
|
+
|
|
89
136
|
private _print({ check, result }: DoctorReportEntry): void {
|
|
90
137
|
const mark = result.status === "ok" ? "✓" : result.status === "warn" ? "!" : "✗";
|
|
91
138
|
const line = `${mark} ${check.label} — ${result.message}`;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a deployed app's security headers from the outside, and report the ones
|
|
3
|
+
* that arrive twice.
|
|
4
|
+
*
|
|
5
|
+
* A header the app sets and the proxy also sets is invisible from inside the
|
|
6
|
+
* process: the app's own view is the value it wrote, and the app is right about
|
|
7
|
+
* that. Only a request that has been through the proxy sees both. Deploying
|
|
8
|
+
* zerotal.dev turned up exactly that — `X-Frame-Options: DENY` from the proxy
|
|
9
|
+
* and `SAMEORIGIN` from the app, on the same response — and browsers do not
|
|
10
|
+
* agree on which one wins. A security control that applies inconsistently is
|
|
11
|
+
* worse than one that is simply absent, because it looks configured.
|
|
12
|
+
*
|
|
13
|
+
* ## How a duplicate is visible at all
|
|
14
|
+
*
|
|
15
|
+
* `fetch` folds repeated headers into one comma-joined value, so
|
|
16
|
+
* `X-Frame-Options` sent twice reads back as `"DENY, SAMEORIGIN"`. For headers
|
|
17
|
+
* whose grammar has no comma in it that is unambiguous evidence of a duplicate.
|
|
18
|
+
* For `Permissions-Policy` and `Referrer-Policy` it is not — a comma is
|
|
19
|
+
* legitimate syntax there — so those are deliberately not checked. A probe that
|
|
20
|
+
* cried wolf on a correct `Permissions-Policy` would be switched off within a
|
|
21
|
+
* week, and then it would not catch the `X-Frame-Options` either.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One header's finding. */
|
|
25
|
+
export interface HeaderProbeResult {
|
|
26
|
+
/** The URL that was read. */
|
|
27
|
+
url: string;
|
|
28
|
+
/** The header, in the casing it is conventionally written. */
|
|
29
|
+
header: string;
|
|
30
|
+
/** The distinct values received, in the order sent. */
|
|
31
|
+
values: string[];
|
|
32
|
+
/** False when this needs attention. */
|
|
33
|
+
ok: boolean;
|
|
34
|
+
/** Whether the duplicated values disagree — the case browsers handle inconsistently. */
|
|
35
|
+
conflicting: boolean;
|
|
36
|
+
message: string;
|
|
37
|
+
fix?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Headers that take exactly one value, so a comma in the received value means
|
|
42
|
+
* the header was sent more than once.
|
|
43
|
+
*
|
|
44
|
+
* `Permissions-Policy`, `Referrer-Policy` and `Accept-CH` are absent on purpose:
|
|
45
|
+
* each takes a comma-separated list, so duplication is undetectable this way.
|
|
46
|
+
*/
|
|
47
|
+
const SINGLE_VALUE_HEADERS: Record<string, string> = {
|
|
48
|
+
"x-frame-options": "X-Frame-Options",
|
|
49
|
+
"x-content-type-options": "X-Content-Type-Options",
|
|
50
|
+
"strict-transport-security": "Strict-Transport-Security",
|
|
51
|
+
"cross-origin-opener-policy": "Cross-Origin-Opener-Policy",
|
|
52
|
+
"cross-origin-resource-policy": "Cross-Origin-Resource-Policy",
|
|
53
|
+
"cross-origin-embedder-policy": "Cross-Origin-Embedder-Policy",
|
|
54
|
+
"x-xss-protection": "X-XSS-Protection",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* CSP is its own case: a comma separates *whole policies*, and a browser
|
|
59
|
+
* enforces every one of them — the effective policy is their intersection. Two
|
|
60
|
+
* policies that were each written to be sufficient usually intersect into
|
|
61
|
+
* something that blocks the page.
|
|
62
|
+
*/
|
|
63
|
+
const CSP_HEADERS: Record<string, string> = {
|
|
64
|
+
"content-security-policy": "Content-Security-Policy",
|
|
65
|
+
"content-security-policy-report-only": "Content-Security-Policy-Report-Only",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Split a folded header value into the values that were actually sent. */
|
|
69
|
+
export function splitFolded(value: string): string[] {
|
|
70
|
+
return value
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((part) => part.trim())
|
|
73
|
+
.filter((part) => part.length > 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Inspect a set of response headers for duplicates.
|
|
78
|
+
*
|
|
79
|
+
* Exported separately from {@link probeHeaders} so the analysis can be tested
|
|
80
|
+
* without a network round-trip — the fetch is the only part that needs one.
|
|
81
|
+
*/
|
|
82
|
+
export function analyseHeaders(url: string, headers: Headers): HeaderProbeResult[] {
|
|
83
|
+
const findings: HeaderProbeResult[] = [];
|
|
84
|
+
|
|
85
|
+
for (const [key, label] of Object.entries(SINGLE_VALUE_HEADERS)) {
|
|
86
|
+
const raw = headers.get(key);
|
|
87
|
+
if (raw === null) continue;
|
|
88
|
+
const values = splitFolded(raw);
|
|
89
|
+
if (values.length < 2) continue;
|
|
90
|
+
|
|
91
|
+
const distinct = [...new Set(values.map((value) => value.toLowerCase()))];
|
|
92
|
+
if (distinct.length > 1) {
|
|
93
|
+
findings.push({
|
|
94
|
+
url,
|
|
95
|
+
header: label,
|
|
96
|
+
values,
|
|
97
|
+
ok: false,
|
|
98
|
+
conflicting: true,
|
|
99
|
+
message:
|
|
100
|
+
`sent ${values.length} times with different values (${values.join(" / ")}). ` +
|
|
101
|
+
`Browsers do not agree on which one applies, so this control is enforced ` +
|
|
102
|
+
`inconsistently across your visitors.`,
|
|
103
|
+
fix:
|
|
104
|
+
`Set ${label} in exactly one place — either the app (config/app.ts → ` +
|
|
105
|
+
`app.secureHeaders) or the proxy — and remove the other.`,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
findings.push({
|
|
111
|
+
url,
|
|
112
|
+
header: label,
|
|
113
|
+
values,
|
|
114
|
+
ok: false,
|
|
115
|
+
conflicting: false,
|
|
116
|
+
message:
|
|
117
|
+
`sent ${values.length} times with the same value (${values[0]}). Harmless today, ` +
|
|
118
|
+
`and a conflict the moment either side is changed without the other.`,
|
|
119
|
+
fix: `Remove the duplicate — keep ${label} in one place.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const [key, label] of Object.entries(CSP_HEADERS)) {
|
|
124
|
+
const raw = headers.get(key);
|
|
125
|
+
if (raw === null) continue;
|
|
126
|
+
// A comma inside a policy is not valid in the directives apps actually use,
|
|
127
|
+
// so one here means a second policy was appended.
|
|
128
|
+
const policies = splitFolded(raw);
|
|
129
|
+
if (policies.length < 2) continue;
|
|
130
|
+
|
|
131
|
+
findings.push({
|
|
132
|
+
url,
|
|
133
|
+
header: label,
|
|
134
|
+
values: policies,
|
|
135
|
+
ok: false,
|
|
136
|
+
conflicting: true,
|
|
137
|
+
message:
|
|
138
|
+
`${policies.length} separate policies were sent. A browser enforces all of them at ` +
|
|
139
|
+
`once, so the policy in force is their intersection — usually stricter than either ` +
|
|
140
|
+
`author intended, and a page that breaks for no visible reason.`,
|
|
141
|
+
fix: `Send one ${label}, from the app or the proxy but not both.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return findings;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fetch `url` and report every duplicated security header on the response.
|
|
150
|
+
*
|
|
151
|
+
* Returns an empty array when the request fails: an unreachable URL is the
|
|
152
|
+
* transport probe's finding to make, and reporting it twice would be noise.
|
|
153
|
+
*/
|
|
154
|
+
export async function probeHeaders(url: string): Promise<HeaderProbeResult[]> {
|
|
155
|
+
let response: Response;
|
|
156
|
+
try {
|
|
157
|
+
// `redirect: "manual"` on purpose: a redirect's own headers are what the
|
|
158
|
+
// proxy adds, and following it would report the destination's instead.
|
|
159
|
+
response = await fetch(url, { method: "GET", redirect: "manual" });
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
return analyseHeaders(url, response.headers);
|
|
164
|
+
}
|
package/src/events/Emitter.ts
CHANGED
|
@@ -270,6 +270,30 @@ export class Emitter {
|
|
|
270
270
|
return (this._listeners.get(eventClass)?.length ?? 0) > 0;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Every event with a listener, and the listeners it has, by name.
|
|
275
|
+
*
|
|
276
|
+
* The wiring between an application's events and what reacts to them is
|
|
277
|
+
* spread across every provider that calls `listen()`, so "what happens when an
|
|
278
|
+
* order is placed" is a question you answer by searching. This is that map,
|
|
279
|
+
* and it is what the inspector's Events tab draws.
|
|
280
|
+
*
|
|
281
|
+
* Names rather than classes, because the answer is read by a human or crosses
|
|
282
|
+
* a wire — and a listener class is not serialisable either way.
|
|
283
|
+
*
|
|
284
|
+
* @returns One row per event with at least one listener, sorted by name.
|
|
285
|
+
* @category Subscription
|
|
286
|
+
*/
|
|
287
|
+
registrations(): Array<{ event: string; listeners: string[] }> {
|
|
288
|
+
return [...this._listeners.entries()]
|
|
289
|
+
.filter(([, listeners]) => listeners.length > 0)
|
|
290
|
+
.map(([eventClass, listeners]) => ({
|
|
291
|
+
event: (eventClass as { name?: string }).name ?? String(eventClass),
|
|
292
|
+
listeners: listeners.map((listener) => listener.name),
|
|
293
|
+
}))
|
|
294
|
+
.sort((a, b) => a.event.localeCompare(b.event));
|
|
295
|
+
}
|
|
296
|
+
|
|
273
297
|
/**
|
|
274
298
|
* Remove every registered listener.
|
|
275
299
|
* @category Subscription
|
|
@@ -133,6 +133,34 @@ export const FrameworkEvents = {
|
|
|
133
133
|
for (const handlers of _byKind.values()) count += handlers.size;
|
|
134
134
|
return count;
|
|
135
135
|
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Which events currently have subscribers, and how many each has.
|
|
139
|
+
*
|
|
140
|
+
* The bus is the framework's nervous system and has been invisible: "does
|
|
141
|
+
* anything actually listen to `ModelChanged`" was a question you answered by
|
|
142
|
+
* reading every package. Sorted by name so two calls are comparable.
|
|
143
|
+
*
|
|
144
|
+
* Class- and kind-keyed subscriptions are merged, because a subscriber that
|
|
145
|
+
* listened by string and one that imported the class are subscribed to the
|
|
146
|
+
* same event and a reader does not care which door they came through.
|
|
147
|
+
*
|
|
148
|
+
* @returns One row per event with at least one live handler.
|
|
149
|
+
* @category Subscription
|
|
150
|
+
*/
|
|
151
|
+
subscriptions(): Array<{ event: string; handlers: number }> {
|
|
152
|
+
const counts = new Map<string, number>();
|
|
153
|
+
for (const [ctor, handlers] of _byClass) {
|
|
154
|
+
if (handlers.size)
|
|
155
|
+
counts.set(_kindOf(ctor), (counts.get(_kindOf(ctor)) ?? 0) + handlers.size);
|
|
156
|
+
}
|
|
157
|
+
for (const [kind, handlers] of _byKind) {
|
|
158
|
+
if (handlers.size) counts.set(kind, (counts.get(kind) ?? 0) + handlers.size);
|
|
159
|
+
}
|
|
160
|
+
return [...counts.entries()]
|
|
161
|
+
.map(([event, handlers]) => ({ event, handlers }))
|
|
162
|
+
.sort((a, b) => a.event.localeCompare(b.event));
|
|
163
|
+
},
|
|
136
164
|
};
|
|
137
165
|
|
|
138
166
|
// ── Framework event types ─────────────────────────────────────────────────────
|
|
@@ -218,6 +246,20 @@ export class RequestFailed {
|
|
|
218
246
|
readonly durationMs: number,
|
|
219
247
|
readonly error: string,
|
|
220
248
|
readonly status: number,
|
|
249
|
+
/**
|
|
250
|
+
* The error's class name, when the failure was an `Error`.
|
|
251
|
+
*
|
|
252
|
+
* `message` alone cannot tell a `ValidationError` from a `TypeError`, and
|
|
253
|
+
* which one it was is usually the first thing you want to know.
|
|
254
|
+
*/
|
|
255
|
+
readonly type?: string,
|
|
256
|
+
/**
|
|
257
|
+
* The raw `Error.stack`, for subscribers that render a trace.
|
|
258
|
+
*
|
|
259
|
+
* Carried as the unparsed string: the shape differs between runtimes, and
|
|
260
|
+
* this event should not be the thing that decides how a frame is spelled.
|
|
261
|
+
*/
|
|
262
|
+
readonly stack?: string,
|
|
221
263
|
) {}
|
|
222
264
|
}
|
|
223
265
|
|
package/src/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ export type {
|
|
|
66
66
|
FileRoutingEntry,
|
|
67
67
|
FileRoutingConfig,
|
|
68
68
|
AppScopeInstaller,
|
|
69
|
+
ProviderReport,
|
|
69
70
|
} from "./application/Application.ts";
|
|
70
71
|
export { ExceptionHandler } from "./application/ExceptionHandler.ts";
|
|
71
72
|
|
|
@@ -161,6 +162,7 @@ export {
|
|
|
161
162
|
export { config } from "./helpers/config.ts";
|
|
162
163
|
export { pluralize, singularize, snakeCase, camelCase, tableNameFor } from "./support/str.ts";
|
|
163
164
|
export { deepMerge } from "./support/deepMerge.ts";
|
|
165
|
+
export type { DeepPartial } from "./support/deepMerge.ts";
|
|
164
166
|
// The type every class-keyed registry uses — a class rather than an instance.
|
|
165
167
|
export type { ClassRef } from "./support/classRef.ts";
|
|
166
168
|
export { safeEqual, sha256Hex, hmacHex } from "./support/crypto.ts";
|
|
@@ -8,6 +8,7 @@ import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
|
8
8
|
// Canonical implementation now lives in support/deepMerge.ts; re-exported here so existing
|
|
9
9
|
// `import { deepMerge } from "./BaseMiddleware.ts"` call sites keep working.
|
|
10
10
|
import { deepMerge } from "../support/deepMerge.ts";
|
|
11
|
+
import type { DeepPartial } from "../support/deepMerge.ts";
|
|
11
12
|
|
|
12
13
|
export { deepMerge };
|
|
13
14
|
|
|
@@ -44,13 +45,23 @@ export abstract class BaseMiddleware<O extends object = object> implements Pipe<
|
|
|
44
45
|
/**
|
|
45
46
|
* Returns a zero-arg subclass with the given options deep-merged on top of
|
|
46
47
|
* the subclass defaults, usable directly in app.use([...]).
|
|
48
|
+
*
|
|
49
|
+
* `NoInfer` on the parameter is what makes this type-check at all. `Opts` has
|
|
50
|
+
* a default computed from the middleware class, but a type parameter that
|
|
51
|
+
* appears in an argument position is inferred from the *argument* first and
|
|
52
|
+
* only falls back to its default when inference finds nothing — so
|
|
53
|
+
* `Middleware.with({ resolve: (claims) => … })` used to infer `Opts` from the
|
|
54
|
+
* object literal it was handed, which meant the literal type-checked against
|
|
55
|
+
* itself. Every callback parameter arrived implicitly `any`, and a misspelled
|
|
56
|
+
* option was accepted in silence. Blocking inference makes the middleware's
|
|
57
|
+
* own option type the one that governs.
|
|
47
58
|
*/
|
|
48
59
|
static with<
|
|
49
60
|
// 1. Constrain T to be a concrete class (not abstract) that extends BaseMiddleware
|
|
50
61
|
T extends new (...args: any[]) => BaseMiddleware<any>,
|
|
51
62
|
// 2. Dynamically infer the specific options type (U) from that concrete class
|
|
52
63
|
Opts = T extends new (...args: any[]) => BaseMiddleware<infer U> ? U : object,
|
|
53
|
-
>(this: T, options:
|
|
64
|
+
>(this: T, options: DeepPartial<NoInfer<Opts>>): new () => InstanceType<T> {
|
|
54
65
|
const configured = class extends (this as any) {
|
|
55
66
|
constructor() {
|
|
56
67
|
super();
|