dsh-mini-utility-dock 0.5.1 → 0.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/README.en.md +22 -6
- package/README.md +22 -6
- package/bin/dsh-mini-utility-dock.js +71 -26
- package/bin/dsh-plugin-docs.js +101 -37
- package/bin/dsh-plugin-parity.js +105 -60
- package/dist/guard.js +18 -21
- package/dist/host-http.js +207 -0
- package/dist/launcher.js +19 -16
- package/dist/loopback.js +11 -14
- package/docs/CHANGELOG.en.md +91 -0
- package/docs/CHANGELOG.md +91 -0
- package/package.json +10 -5
- package/CHANGELOG.en.md +0 -73
- package/CHANGELOG.md +0 -73
package/bin/dsh-plugin-parity.js
CHANGED
|
@@ -1,38 +1,44 @@
|
|
|
1
1
|
// Cross-repo drift check for the shared host fragments.
|
|
2
2
|
//
|
|
3
|
-
// Why this exists:
|
|
4
|
-
// predicates and the request guard, because
|
|
5
|
-
//
|
|
3
|
+
// Why this exists: every member repository embeds its own copy of the loopback
|
|
4
|
+
// predicates and the request guard, because every package has to stand alone, and
|
|
5
|
+
// hand-copied enforcement code drifts in ways that are always
|
|
6
|
+
// security-relevant — an IPv6 loopback that reads as foreign and locks a
|
|
7
|
+
// legitimate browser out of its own API, Host spellings two copies disagree on,
|
|
8
|
+
// a Host that parses to no hostname skipping the allowlist in one copy while
|
|
9
|
+
// another denies it.
|
|
6
10
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// copy and was denied in the others.
|
|
11
|
+
// The copies are no longer hand-written: they are embedded blocks generated from
|
|
12
|
+
// dsh-mini-utility-dock by the dock CLI — `dist/loopback.js` (the predicates),
|
|
13
|
+
// `dist/guard.js` (the enforcement policy) and `dist/host-http.js` (the response
|
|
14
|
+
// glue: JSON replies, the POST gate, the browser authorizer). So this checker
|
|
15
|
+
// asserts three things a generator alone cannot:
|
|
13
16
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// and
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// * both embedded blocks are byte-identical in every member (a hand edit to
|
|
20
|
-
// one copy, or a repo that never re-ran `loopback:sync` / `guard:sync`, fails
|
|
21
|
-
// here), and they appear in dependency order — the guard uses the predicates
|
|
22
|
-
// the loopback block declares in the same file;
|
|
17
|
+
// * every embedded block is byte-identical in every member (a hand edit to
|
|
18
|
+
// one copy, or a repo that never re-ran `loopback:sync` / `guard:sync` /
|
|
19
|
+
// `http:sync`, fails here), and they appear in dependency order — the guard
|
|
20
|
+
// uses the predicates the loopback block declares in the same file;
|
|
23
21
|
// * no repo keeps a private copy of an enforcement decision beside them — that
|
|
24
|
-
// is how drifts
|
|
25
|
-
// also branches on its own version;
|
|
22
|
+
// is how the drifts above happened, and embedding the blocks is worthless if
|
|
23
|
+
// a repo also branches on its own version;
|
|
26
24
|
// * the members AGREE ON EVERY DECISION. Error codes and wording deliberately
|
|
27
25
|
// differ (each plugin's published API vocabulary), so the comparison is on
|
|
28
26
|
// the allow/deny outcome only. This is the assertion that survives a policy
|
|
29
27
|
// change: byte equality implies it, but only this proves each consumer
|
|
30
28
|
// actually routes through the blocks.
|
|
31
29
|
//
|
|
30
|
+
// The host-HTTP block is in the compared set for the same reason the guard is:
|
|
31
|
+
// what it carries is policy. A response header that keeps live port and session
|
|
32
|
+
// data out of caches, and the reply for "browser authorization is unavailable",
|
|
33
|
+
// are exactly the lines a hand-copied glue function loses on one repo.
|
|
34
|
+
//
|
|
32
35
|
// Usage:
|
|
33
36
|
// dsh-plugin-parity --root <dir> --member <repo>:<export> [--member ...]
|
|
37
|
+
// dsh-plugin-parity --root <dir> --member ... --fleet <repo>
|
|
38
|
+
// # `--fleet` names the one member that opts into fleet mode, so the check
|
|
39
|
+
// # that the relaxation stays bounded runs against its guard alone
|
|
34
40
|
// dsh-plugin-parity --root <dir> # static checks for every member found
|
|
35
|
-
// dsh-plugin-parity --self-test # exercises the extractor
|
|
41
|
+
// dsh-plugin-parity --self-test # exercises the block extractor and the block-dropping step
|
|
36
42
|
//
|
|
37
43
|
// `--member` names one participating repository and the guard factory it
|
|
38
44
|
// exports. That name is per-repository by design — the shared block is not
|
|
@@ -44,11 +50,12 @@
|
|
|
44
50
|
// markers is checked statically, and the decision comparison is skipped.
|
|
45
51
|
//
|
|
46
52
|
// This is a LOCAL DIAGNOSTIC, deliberately not a CI gate. Per-repo CI already
|
|
47
|
-
// proves the stronger local property — `loopback:check` / `guard:check`
|
|
48
|
-
// each repo's embedded blocks against the `dist/` of the
|
|
49
|
-
// pins, and that version is immutable on npm. Members
|
|
50
|
-
// therefore hold byte-identical blocks by construction,
|
|
51
|
-
// is redundant as a gate and
|
|
53
|
+
// proves the stronger local property — `loopback:check` / `guard:check` /
|
|
54
|
+
// `http:check` compare each repo's embedded blocks against the `dist/` of the
|
|
55
|
+
// exact dock version it pins, and that version is immutable on npm. Members
|
|
56
|
+
// pinning one version therefore hold byte-identical blocks by construction,
|
|
57
|
+
// which is why this script is redundant as a CI gate and stays a manual
|
|
58
|
+
// diagnostic instead.
|
|
52
59
|
//
|
|
53
60
|
// The property it asserts is inherently cross-repository and cannot hold at an
|
|
54
61
|
// arbitrary moment: on a `dev` push the peer checkouts resolve to their default
|
|
@@ -99,25 +106,42 @@ const discovered = () => readdirSync(root, { withFileTypes: true })
|
|
|
99
106
|
const repos = members.length ? members : discovered().map((name) => [name, name, null])
|
|
100
107
|
const tags = repos.map(([, tag]) => tag).join('/')
|
|
101
108
|
|
|
102
|
-
//
|
|
103
|
-
// declares, so
|
|
109
|
+
// The order the blocks sit in `lib/shared.js`: the guard block uses the
|
|
110
|
+
// predicates the loopback block declares in the same file, so the predicates must
|
|
111
|
+
// come first, and the host-HTTP block lands below the guard, where a consumer's
|
|
112
|
+
// own glue has always lived. `FRAGMENTS` in the dock CLI lists the same order, so
|
|
113
|
+
// `dropBlocks` and the position check below can treat this table as the file's
|
|
114
|
+
// true layout.
|
|
104
115
|
const BLOCKS = [
|
|
105
116
|
{ name: 'dsh-loopback-helpers', label: 'loopback predicates' },
|
|
106
|
-
{ name: 'dsh-host-guard', label: 'host guard' }
|
|
117
|
+
{ name: 'dsh-host-guard', label: 'host guard' },
|
|
118
|
+
{ name: 'dsh-host-http', label: 'host HTTP glue' }
|
|
107
119
|
]
|
|
108
120
|
|
|
109
121
|
const marks = (name) => ({ start: `// <${name}>`, end: `// </${name}>` })
|
|
110
122
|
|
|
111
123
|
// Pull a generated block out of a consumer's lib/shared.js. Everything between
|
|
112
|
-
// the markers is generator output, so it must match
|
|
113
|
-
// indents
|
|
124
|
+
// the markers is generator output, so it must match once re-indented. A consumer
|
|
125
|
+
// re-indents the whole block to its marker's own indentation, and the dock's
|
|
126
|
+
// `sync` preserves that indentation, so two repos that indent their markers
|
|
127
|
+
// differently still hold the same block. Stripping the block's shared leading
|
|
128
|
+
// whitespace — not a fixed 2 spaces — is what makes them compare equal.
|
|
114
129
|
const extractBlock = (src, name) => {
|
|
115
130
|
const { start, end } = marks(name)
|
|
116
131
|
const lines = src.split(/\r?\n/)
|
|
117
132
|
const starts = lines.reduce((hits, line, i) => line.trim() === start ? [...hits, i] : hits, [])
|
|
118
133
|
const ends = lines.reduce((hits, line, i) => line.trim() === end ? [...hits, i] : hits, [])
|
|
119
134
|
if (starts.length !== 1 || ends.length !== 1 || ends[0] <= starts[0]) return null
|
|
120
|
-
|
|
135
|
+
const body = lines.slice(starts[0] + 1, ends[0])
|
|
136
|
+
// The least-indented non-blank line carries exactly the block's indentation, so
|
|
137
|
+
// removing that many leading whitespace characters from every line restores the
|
|
138
|
+
// fragment's own relative indentation, whether the marker used 2 spaces, 4, or a
|
|
139
|
+
// tab. A fragment always has top-level (column-0) lines, so the minimum is its
|
|
140
|
+
// indent, not a deeper body line.
|
|
141
|
+
const indent = body.reduce((min, line) => line.trim() === '' ? min
|
|
142
|
+
: Math.min(min, (/^[ \t]*/.exec(line) || [''])[0].length), Infinity)
|
|
143
|
+
const strip = Number.isFinite(indent) ? indent : 0
|
|
144
|
+
return body.map((line) => line.trim() === '' ? '' : line.slice(strip)).join('\n').trim()
|
|
121
145
|
}
|
|
122
146
|
|
|
123
147
|
const read = (repo) => readFileSync(join(root, repo, 'lib', 'shared.js'), 'utf8')
|
|
@@ -153,12 +177,19 @@ const dockPinOf = (repo) => {
|
|
|
153
177
|
if (process.argv.includes('--self-test')) {
|
|
154
178
|
const good = [
|
|
155
179
|
marks('dsh-loopback-helpers').start, 'const a = 1', marks('dsh-loopback-helpers').end,
|
|
156
|
-
marks('dsh-host-guard').start, 'const b = 2', marks('dsh-host-guard').end
|
|
180
|
+
marks('dsh-host-guard').start, 'const b = 2', marks('dsh-host-guard').end,
|
|
181
|
+
marks('dsh-host-http').start, 'const c = 3', marks('dsh-host-http').end,
|
|
182
|
+
// The plugin's own code, which `dropBlocks` must leave behind.
|
|
183
|
+
'export const pluginLine = 7'
|
|
157
184
|
].join('\n')
|
|
158
185
|
const cases = [
|
|
159
186
|
['first block extracted', 'dsh-loopback-helpers', good, 'const a = 1'],
|
|
160
187
|
['second block extracted', 'dsh-host-guard', good, 'const b = 2'],
|
|
188
|
+
['third block extracted', 'dsh-host-http', good, 'const c = 3'],
|
|
161
189
|
['indent is stripped', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, ' const a = 1', marks('dsh-loopback-helpers').end].join('\n'), 'const a = 1'],
|
|
190
|
+
['four-space indent is stripped', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, ' const a = 1', marks('dsh-loopback-helpers').end].join('\n'), 'const a = 1'],
|
|
191
|
+
['tab indent is stripped', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, '\tconst a = 1', marks('dsh-loopback-helpers').end].join('\n'), 'const a = 1'],
|
|
192
|
+
['relative indent is kept', 'dsh-host-guard', [marks('dsh-host-guard').start, ' const b = 2', ' inner', marks('dsh-host-guard').end].join('\n'), 'const b = 2\n inner'],
|
|
162
193
|
['missing end marker returns null', 'dsh-loopback-helpers', [marks('dsh-loopback-helpers').start, 'const a = 1'].join('\n'), null],
|
|
163
194
|
['absent block returns null', 'dsh-host-guard', good.replace(/dsh-host-guard/g, 'other'), null]
|
|
164
195
|
]
|
|
@@ -170,17 +201,19 @@ if (process.argv.includes('--self-test')) {
|
|
|
170
201
|
else console.log(`self-test ok ${label}`)
|
|
171
202
|
if (!ok) bad++
|
|
172
203
|
}
|
|
173
|
-
// Dropping
|
|
204
|
+
// Dropping every block must remove the generated bodies and leave the plugin's
|
|
205
|
+
// own code intact. A leftover body would read as a private enforcement decision;
|
|
206
|
+
// a dropped plugin line would mean the splices overreached.
|
|
174
207
|
const residual = dropBlocks(good)
|
|
175
|
-
const dropOk = !/const a = 1|const b = 2/.test(residual)
|
|
176
|
-
console.log(dropOk ? 'self-test ok
|
|
208
|
+
const dropOk = !/const a = 1|const b = 2|const c = 3/.test(residual) && /export const pluginLine = 7/.test(residual)
|
|
209
|
+
console.log(dropOk ? 'self-test ok every block dropped, plugin code kept' : `self-test FAIL block dropping: ${JSON.stringify(residual)}`)
|
|
177
210
|
if (!dropOk) bad++
|
|
178
211
|
process.exit(bad ? 1 : 0)
|
|
179
212
|
}
|
|
180
213
|
|
|
181
214
|
let failures = 0
|
|
182
215
|
|
|
183
|
-
// 1.
|
|
216
|
+
// 1. Every generated block must be present, byte-identical everywhere, and in
|
|
184
217
|
// dependency order.
|
|
185
218
|
for (const block of BLOCKS) {
|
|
186
219
|
const copies = repos.map(([repo, tag]) => ({ repo, tag, body: extractBlock(read(repo), block.name) }))
|
|
@@ -207,7 +240,7 @@ for (const block of BLOCKS) {
|
|
|
207
240
|
// 2. Every repo must embed from the same dock version. A skew here is the one
|
|
208
241
|
// cross-repo cause of divergence that per-repo checks cannot see: each repo's
|
|
209
242
|
// own `guard:check` passes against whatever it pins, so a repo left on an older
|
|
210
|
-
// dock stays green while its blocks differ from
|
|
243
|
+
// dock stays green while its blocks differ from the other members'.
|
|
211
244
|
{
|
|
212
245
|
const pins = repos.map(([repo, tag]) => ({ tag, pin: dockPinOf(repo) }))
|
|
213
246
|
const unset = pins.filter((p) => p.pin === null)
|
|
@@ -219,7 +252,7 @@ for (const block of BLOCKS) {
|
|
|
219
252
|
console.log(`FAIL dock pin: versions differ — ${pins.map((p) => `${p.tag}=${p.pin}`).join(' ')}`)
|
|
220
253
|
failures++
|
|
221
254
|
} else {
|
|
222
|
-
// An exact pin is what makes
|
|
255
|
+
// An exact pin is what makes every member hold identical blocks: a range
|
|
223
256
|
// could resolve to different builds under one declared value.
|
|
224
257
|
const exact = /^\d+\.\d+\.\d+$/.test(distinct[0])
|
|
225
258
|
if (!exact) {
|
|
@@ -232,45 +265,50 @@ for (const block of BLOCKS) {
|
|
|
232
265
|
}
|
|
233
266
|
|
|
234
267
|
// The guard block reads what the loopback block declares in the same file, so the
|
|
235
|
-
// order is load-bearing, not cosmetic.
|
|
268
|
+
// order is load-bearing, not cosmetic. `FRAGMENTS` in the dock CLI documents one
|
|
269
|
+
// order and every consumer writes that one, so a member whose blocks sit any
|
|
270
|
+
// other way has either hand-moved a block or embedded from a different dock —
|
|
271
|
+
// both of which make its `check` results harder to read than this line makes them.
|
|
236
272
|
{
|
|
237
273
|
let orderBad = 0
|
|
238
274
|
for (const [repo, tag] of repos) {
|
|
239
275
|
const src = read(repo)
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
276
|
+
const placed = BLOCKS
|
|
277
|
+
.map((block) => ({ block, span: spanOf(src, block.name) }))
|
|
278
|
+
.filter((entry) => entry.span !== null)
|
|
279
|
+
for (let i = 1; i < placed.length; i++) {
|
|
280
|
+
if (placed[i - 1].span.from > placed[i].span.from) {
|
|
281
|
+
console.log(`FAIL ${tag}: the ${placed[i].block.label} block precedes the ${placed[i - 1].block.label} block`)
|
|
282
|
+
orderBad++
|
|
283
|
+
}
|
|
246
284
|
}
|
|
247
285
|
}
|
|
248
286
|
if (orderBad) failures += orderBad
|
|
249
|
-
else console.log('ok block order:
|
|
287
|
+
else console.log('ok block order: loopback predicates, then host guard, then host HTTP glue, everywhere')
|
|
250
288
|
}
|
|
251
289
|
|
|
252
290
|
// 3. Embedding the blocks is worthless if a repo also keeps its own enforcement
|
|
253
291
|
// beside them. Policy is expected outside the blocks; decisions are not.
|
|
254
292
|
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
293
|
+
// Every pattern here matches a CODE SHAPE, not a word. These files are scanned
|
|
294
|
+
// as text with the generated blocks dropped, so a bare keyword also matches
|
|
295
|
+
// prose: a doc comment explaining why a private helper is NOT a second guard
|
|
296
|
+
// used to fail the very repository that documented its reasoning. An
|
|
297
|
+
// explanation is not enforcement; reading a header or declaring a function is.
|
|
260
298
|
const ENFORCEMENT = [
|
|
261
|
-
[/LOOPBACK_HOSTNAMES\s
|
|
299
|
+
[/LOOPBACK_HOSTNAMES\s*=(?!=)/, 'redefines LOOPBACK_HOSTNAMES'],
|
|
262
300
|
[/(?:const|function|let)\s+isLoopbackName\b/, 'defines a private isLoopbackName'],
|
|
263
301
|
[/(?:const|function|let)\s+isLoopbackAddress\b/, 'defines a private isLoopbackAddress'],
|
|
264
302
|
[/(?:const|function|let)\s+hostHostname\b/, 'defines a private hostHostname'],
|
|
265
303
|
[/(?:const|function|let)\s+bindGuard\b/, 'defines a private guard factory'],
|
|
266
|
-
[
|
|
304
|
+
[/\[\s*['"]sec-fetch-site['"]\s*\]/, 'reads Fetch Metadata outside the blocks'],
|
|
305
|
+
[/\{[^}]*['"]sec-fetch-site['"]\s*[,:}]/, 'reads Fetch Metadata outside the blocks']
|
|
267
306
|
]
|
|
268
307
|
for (const [repo, tag] of repos) {
|
|
269
308
|
const outside = dropBlocks(read(repo))
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
309
|
+
// The bracket read and the destructuring read share one reason, so collapse the
|
|
310
|
+
// two patterns' matches into a single report line.
|
|
311
|
+
const problems = [...new Set(ENFORCEMENT.filter(([re]) => re.test(outside)).map(([, why]) => why))]
|
|
274
312
|
if (problems.length) {
|
|
275
313
|
console.log(`FAIL ${tag}: ${problems.join('; ')}`)
|
|
276
314
|
failures++
|
|
@@ -398,7 +436,8 @@ else console.log(`ok decisions: ${cases.length} cases agree across ${tags}`)
|
|
|
398
436
|
// member named by `--fleet` opts in. Assert the relaxation stays bounded: a
|
|
399
437
|
// fleet guard still rejects a cross-site request, a foreign Origin, and a
|
|
400
438
|
// missing peer.
|
|
401
|
-
const
|
|
439
|
+
const fleetFlag = flagOf('--fleet')
|
|
440
|
+
const fleetMember = fleetFlag ? members.find(([repo]) => repo === fleetFlag) : null
|
|
402
441
|
const fleetCases = [
|
|
403
442
|
['fleet admits a peer host', { host: 'box.lan:3080' }, 'allow'],
|
|
404
443
|
['fleet admits a remote peer', { host: '127.0.0.1:3080', peer: '203.0.113.7' }, 'allow'],
|
|
@@ -406,8 +445,14 @@ const fleetCases = [
|
|
|
406
445
|
['fleet still rejects foreign Origin', { host: 'box.lan:3080', origin: 'https://evil.example' }, 'deny'],
|
|
407
446
|
['fleet still rejects a missing peer', { host: 'box.lan:3080', peer: null }, 'deny']
|
|
408
447
|
]
|
|
409
|
-
if (!
|
|
448
|
+
if (!fleetFlag) {
|
|
410
449
|
console.log('ok fleet bounds: skipped (pass --fleet <repo> to check the member that opts in)')
|
|
450
|
+
} else if (!fleetMember) {
|
|
451
|
+
// Passing `--fleet` asks for the relaxation boundary to be tested; a name that
|
|
452
|
+
// matches no member means that test never ran, so say FAIL rather than let the
|
|
453
|
+
// "skipped" line read as a pass for a check that was requested but not performed.
|
|
454
|
+
console.log(`FAIL fleet bounds: --fleet ${fleetFlag} is not one of the --member repositories (${members.map(([, tag]) => tag).join(', ')})`)
|
|
455
|
+
failures++
|
|
411
456
|
} else {
|
|
412
457
|
const fleetVerdict = (req) => verdict(fleetMember[0], fleetMember[2], { ...req, fleet: true })
|
|
413
458
|
let fleetBad = 0
|
package/dist/guard.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// Host-side request guard
|
|
1
|
+
// Host-side same-origin request guard, for a plugin's host half that binds an
|
|
2
|
+
// API to loopback.
|
|
2
3
|
//
|
|
3
4
|
// This fragment has ONE source of truth: dsh-mini-utility-dock/dist/guard.js.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
5
|
+
// A host half is plain Node ESM that its package ships standalone, so the
|
|
6
|
+
// fragment is embedded into `lib/shared.js` at build time by
|
|
6
7
|
// npm run guard:sync (write it)
|
|
7
8
|
// npm run guard:check (fail on drift)
|
|
8
9
|
// instead of being imported: a bare `import 'dsh-mini-utility-dock/...'` would
|
|
9
|
-
// put a runtime dependency on
|
|
10
|
-
//
|
|
10
|
+
// put a runtime dependency on this package into the file that embeds it, and an
|
|
11
|
+
// embedding plugin ships standalone, with nothing else required.
|
|
11
12
|
//
|
|
12
13
|
// This file is the POLICY half. What counts as loopback is a separate fragment
|
|
13
14
|
// (`dist/loopback.js`, embedded under the `dsh-loopback-helpers` marker), and
|
|
@@ -21,18 +22,15 @@
|
|
|
21
22
|
// file, so an import of another module would both break the standalone promise
|
|
22
23
|
// and collide with the exports the block above already declares.
|
|
23
24
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// its own copy and made drift possible. Here the enforcement order and every
|
|
29
|
-
// decision are fixed, and the wording is supplied as data by the caller
|
|
30
|
-
// (`policy`), so a plugin customizes its vocabulary without forking the logic.
|
|
25
|
+
// The enforcement order and every decision are fixed here, and the wording is
|
|
26
|
+
// supplied as data by the caller (`policy`): a plugin customizes the codes and
|
|
27
|
+
// messages its own API publishes without forking the checks. Keeping the
|
|
28
|
+
// vocabulary out of the decisions is what makes one copy of them enough.
|
|
31
29
|
|
|
32
30
|
// Default ports each scheme normalises away, so an Origin carrying no explicit
|
|
33
31
|
// port (for example `http://127.0.0.1`) compares equal to a server on 80/443.
|
|
34
|
-
// `new URL('http://127.0.0.1:80').port` is '', which
|
|
35
|
-
// and
|
|
32
|
+
// `new URL('http://127.0.0.1:80').port` is '', which would read as unequal to
|
|
33
|
+
// `80` and reject a legitimate same-origin request.
|
|
36
34
|
const DEFAULT_PORTS = { 'http:': '80', 'https:': '443' }
|
|
37
35
|
export const portOf = (url) => url.port || DEFAULT_PORTS[url.protocol] || ''
|
|
38
36
|
|
|
@@ -41,10 +39,9 @@ export const portOf = (url) => url.port || DEFAULT_PORTS[url.protocol] || ''
|
|
|
41
39
|
// plugin's API exposes and the human wording are policy.
|
|
42
40
|
//
|
|
43
41
|
// An unidentifiable peer and an off-loopback peer are deliberately distinct
|
|
44
|
-
// decisions
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
// because every reason rejects.
|
|
42
|
+
// decisions, because they are distinct facts about the request. A caller may map
|
|
43
|
+
// both to the same `code`: that is a vocabulary choice about its published API,
|
|
44
|
+
// and nothing widens either way, because every reason rejects.
|
|
48
45
|
export const GUARD_REASONS = Object.freeze([
|
|
49
46
|
'non_loopback_peer',
|
|
50
47
|
'cross_site',
|
|
@@ -66,9 +63,9 @@ export const DEFAULT_GUARD_POLICY = Object.freeze({
|
|
|
66
63
|
* Build the same-origin request guard for a loopback-bound API route.
|
|
67
64
|
*
|
|
68
65
|
* Not exported under a plugin-facing name: each plugin publishes its own guard
|
|
69
|
-
* bound to its own error vocabulary, so the name it exports —
|
|
70
|
-
*
|
|
71
|
-
*
|
|
66
|
+
* bound to its own error vocabulary, so the name it exports — `createGuard` is
|
|
67
|
+
* the conventional one — is the plugin's own to declare. This is the one factory
|
|
68
|
+
* every plugin that embeds this block calls.
|
|
72
69
|
*
|
|
73
70
|
* Enforces, in order: Fetch Metadata, an unparseable Host, the TCP peer address,
|
|
74
71
|
* then the Host allowlist, then the Origin. Rejects by calling
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Host-side HTTP glue, for a plugin's host half that answers its own API.
|
|
2
|
+
//
|
|
3
|
+
// This fragment has ONE source of truth: dsh-mini-utility-dock/dist/host-http.js.
|
|
4
|
+
// A host half is plain Node ESM that its package ships standalone, so the
|
|
5
|
+
// fragment is embedded into `lib/shared.js` at build time by
|
|
6
|
+
// npm run http:sync (write it)
|
|
7
|
+
// npm run http:check (fail on drift)
|
|
8
|
+
// instead of being imported: a bare `import 'dsh-mini-utility-dock/...'` would
|
|
9
|
+
// put a runtime dependency on this package into the file that embeds it, and an
|
|
10
|
+
// embedding plugin ships standalone, with nothing else required.
|
|
11
|
+
//
|
|
12
|
+
// There is deliberately no `import` here. The block sits BELOW `dsh-host-guard`
|
|
13
|
+
// in the consumer's `lib/shared.js` and declares nothing the blocks above it
|
|
14
|
+
// already declared, so the three host-side blocks coexist in one file and a
|
|
15
|
+
// plugin that needs only this one can embed only this one.
|
|
16
|
+
//
|
|
17
|
+
// These lines are the response policy, so they are stated once here: every JSON
|
|
18
|
+
// reply carries `cache-control: no-store`, because a body naming instance ports,
|
|
19
|
+
// pids or session ids must never be servable from an intermediary cache — that
|
|
20
|
+
// header is a security posture, not cosmetics. The "browser authorization is
|
|
21
|
+
// unavailable" reply and the POST gate each have one definition, so a change to
|
|
22
|
+
// one reaches every file that embeds this block at the same time.
|
|
23
|
+
//
|
|
24
|
+
// What legitimately differs between plugins — a machine-readable `code`, the
|
|
25
|
+
// human wording, whether the rejected `action` is echoed — is supplied as data
|
|
26
|
+
// (`policy`), exactly the way `dist/guard.js` separates enforcement from
|
|
27
|
+
// vocabulary.
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Send a JSON reply and end the response.
|
|
31
|
+
*
|
|
32
|
+
* `cache-control: no-store` is part of this function rather than of each call
|
|
33
|
+
* site: a route that reports live host facts (ports, pids, session data) must
|
|
34
|
+
* not be servable from an intermediary cache, and a two-line helper that each
|
|
35
|
+
* call site restates is where such a header goes missing.
|
|
36
|
+
*/
|
|
37
|
+
export function sendJson(res, status, body) {
|
|
38
|
+
const text = JSON.stringify(body)
|
|
39
|
+
res.writeHead(status, {
|
|
40
|
+
'content-type': 'application/json; charset=utf-8',
|
|
41
|
+
'cache-control': 'no-store'
|
|
42
|
+
})
|
|
43
|
+
res.end(text)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The one reply for "browser authorization is unavailable right now". Exported
|
|
48
|
+
* as data so a call site that cannot go through `connectionUnavailable()` — a
|
|
49
|
+
* test, or a route that composes its own body — still states it once.
|
|
50
|
+
*/
|
|
51
|
+
export const CONNECTION_UNAVAILABLE = Object.freeze({
|
|
52
|
+
ok: false,
|
|
53
|
+
code: 'connection_unavailable',
|
|
54
|
+
error: 'browser authentication unavailable'
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Answer `res` with 503 and the shared `connection_unavailable` payload, and
|
|
59
|
+
* return false so a caller can hand its own verdict back in one statement.
|
|
60
|
+
* `respond` has the same injectable sink shape as the guard block's `bindGuard`
|
|
61
|
+
* — `(res, status, body)` — and it defaults to this block's `sendJson`, so the
|
|
62
|
+
* no-store policy is not something a call site can drop.
|
|
63
|
+
*/
|
|
64
|
+
export const connectionUnavailable = (res, respond = sendJson) => {
|
|
65
|
+
respond(res, 503, { ...CONNECTION_UNAVAILABLE })
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The reasons the POST gate can reject. One today; the table exists so the
|
|
71
|
+
* wording is overridable by key and a typo in that key is an error rather than a
|
|
72
|
+
* silent no-op — the same contract the guard block's `GUARD_REASONS` offers.
|
|
73
|
+
*/
|
|
74
|
+
export const REQUIRE_POST_REASONS = Object.freeze(['method_not_allowed'])
|
|
75
|
+
|
|
76
|
+
/** Default code and wording. `{action}` in `error` is substituted per call. */
|
|
77
|
+
export const DEFAULT_REQUIRE_POST_POLICY = Object.freeze({
|
|
78
|
+
method_not_allowed: { code: 'method', error: 'action "{action}" requires POST' }
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the POST-only gate for a mutating action.
|
|
83
|
+
*
|
|
84
|
+
* Behavior is fixed: the method is read case-insensitively (an absent method
|
|
85
|
+
* reads as `GET`, which is not POST), POST passes, anything else is answered
|
|
86
|
+
* 405 and returns false. What a plugin publishes — the `code`, the language of
|
|
87
|
+
* `error`, and whether the rejected `action` is echoed in the body — is policy:
|
|
88
|
+
*
|
|
89
|
+
* createRequirePost() // `{ code: 'method' }`
|
|
90
|
+
* createRequirePost({ policy: { method_not_allowed: { // an established API keeps its shape
|
|
91
|
+
* code: 'need_post', error: '{action} 需要 POST 请求', includeAction: true
|
|
92
|
+
* } } })
|
|
93
|
+
*
|
|
94
|
+
* @param respond - rejection sink, defaults to this block's `sendJson`, so the
|
|
95
|
+
* `no-store` header is not a thing a caller can forget to pass.
|
|
96
|
+
* @param policy - optional per-reason `{ code, error, includeAction }` overrides
|
|
97
|
+
* keyed by `REQUIRE_POST_REASONS`; a partial override merges with the default,
|
|
98
|
+
* and an unknown key throws.
|
|
99
|
+
*/
|
|
100
|
+
export const createRequirePost = ({ respond = sendJson, policy } = {}) => {
|
|
101
|
+
const overrides = policy || {}
|
|
102
|
+
for (const key of Object.keys(overrides)) {
|
|
103
|
+
if (!REQUIRE_POST_REASONS.includes(key)) {
|
|
104
|
+
throw new Error(`createRequirePost: unknown policy key ${JSON.stringify(key)}; expected one of ${REQUIRE_POST_REASONS.join(', ')}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const say = Object.fromEntries(REQUIRE_POST_REASONS.map((reason) => [
|
|
108
|
+
reason,
|
|
109
|
+
{ ...DEFAULT_REQUIRE_POST_POLICY[reason], ...(overrides[reason] || {}) }
|
|
110
|
+
]))
|
|
111
|
+
const render = (template, action) => String(template).replace(/\{action\}/g, action == null ? '' : String(action))
|
|
112
|
+
|
|
113
|
+
return function requirePost(req, res, action) {
|
|
114
|
+
if (String((req && req.method) || 'GET').toUpperCase() === 'POST') return true
|
|
115
|
+
const words = say.method_not_allowed
|
|
116
|
+
respond(res, 405, {
|
|
117
|
+
ok: false,
|
|
118
|
+
code: words.code,
|
|
119
|
+
error: render(words.error, action),
|
|
120
|
+
...(words.includeAction ? { action } : {})
|
|
121
|
+
})
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the browser authorizer for a route that is guarded by RC1's Connection
|
|
128
|
+
* when the host provides one, and by the plugin's own same-origin guard when it
|
|
129
|
+
* does not.
|
|
130
|
+
*
|
|
131
|
+
* @param getConnection - `() => connection`, an ACCESSOR, never the value. A host
|
|
132
|
+
* half keeps its Connection in a closure variable that service unload/reload
|
|
133
|
+
* reassigns to `null` and later back to a new instance; a captured value would
|
|
134
|
+
* keep authorizing against a disposed Connection forever.
|
|
135
|
+
* @param getConnectionSeen - `() => connectionSeen`, an accessor for the latch
|
|
136
|
+
* that records "this host has had an RC1 Connection at least once". Same
|
|
137
|
+
* reason, same consequence: read it per request.
|
|
138
|
+
* @param guard - the plugin's bound request guard, used only on a host that has
|
|
139
|
+
* never had a Connection (older hosts, where the guard IS the boundary).
|
|
140
|
+
* @param respond - rejection sink, defaults to this block's `sendJson`.
|
|
141
|
+
* @returns `(req, res) => boolean`, true when the request may proceed.
|
|
142
|
+
*
|
|
143
|
+
* The decision order is the security property, so it is fixed here:
|
|
144
|
+
*
|
|
145
|
+
* 1. A Connection that throws is not a Connection that permits. The request is
|
|
146
|
+
* rejected 503 and must never fall through to the route handler.
|
|
147
|
+
* 2. A rejection code from the Connection is final: the status is that code,
|
|
148
|
+
* 401 reads as `unauthorized`, anything else as `forbidden`.
|
|
149
|
+
* 3. No Connection but a seen one: 503, and deliberately NOT the guard. Once
|
|
150
|
+
* the host has had an RC1 Connection, an unload gap must not reopen the
|
|
151
|
+
* route through the weaker loopback fence — a local socket peer is not the
|
|
152
|
+
* same statement as an authorized browser.
|
|
153
|
+
* 4. No Connection and none ever seen: this is a pre-RC1 host, and the
|
|
154
|
+
* plugin's own guard is the whole boundary, so it decides.
|
|
155
|
+
*
|
|
156
|
+
* These two codes and their wording state the decision itself rather than a
|
|
157
|
+
* plugin's published vocabulary, so they are not policy and are not injectable;
|
|
158
|
+
* only wording that legitimately differs between plugins belongs in a table.
|
|
159
|
+
*/
|
|
160
|
+
export const createBrowserAuthorizer = ({ getConnection, getConnectionSeen, guard, respond = sendJson }) => {
|
|
161
|
+
if (typeof getConnection !== 'function') {
|
|
162
|
+
throw new Error('createBrowserAuthorizer: getConnection must be an accessor (`() => connection`); a Connection captured here is stale the first time the service reloads')
|
|
163
|
+
}
|
|
164
|
+
if (typeof getConnectionSeen !== 'function') {
|
|
165
|
+
throw new Error('createBrowserAuthorizer: getConnectionSeen must be an accessor (`() => connectionSeen`); the latch is set once an RC1 Connection exists and must never be read from a captured copy')
|
|
166
|
+
}
|
|
167
|
+
if (typeof guard !== 'function') {
|
|
168
|
+
throw new Error('createBrowserAuthorizer: guard is required; a host without an RC1 Connection falls back to the plugin request guard')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return function authorizeBrowser(req, res) {
|
|
172
|
+
const connection = getConnection()
|
|
173
|
+
if (connection) {
|
|
174
|
+
let rejection
|
|
175
|
+
try {
|
|
176
|
+
rejection = connection.requestRejection(req)
|
|
177
|
+
} catch {
|
|
178
|
+
return connectionUnavailable(res, respond)
|
|
179
|
+
}
|
|
180
|
+
if (rejection !== undefined) {
|
|
181
|
+
respond(res, rejection, {
|
|
182
|
+
ok: false,
|
|
183
|
+
code: rejection === 401 ? 'unauthorized' : 'forbidden',
|
|
184
|
+
error: rejection === 401 ? 'browser authentication required' : 'request rejected'
|
|
185
|
+
})
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
// Once an RC1 Connection has existed, a reload gap answers 503 — it does not
|
|
191
|
+
// downgrade to the guard fence.
|
|
192
|
+
if (getConnectionSeen()) return connectionUnavailable(res, respond)
|
|
193
|
+
return guard(req, res)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Accept only a bounded, plausible session id from query or body input.
|
|
199
|
+
* Returns the trimmed value or null; `null` is the caller's "missing/invalid"
|
|
200
|
+
* signal, so an empty string never reaches a lookup.
|
|
201
|
+
*/
|
|
202
|
+
export function optionalSessionId(value) {
|
|
203
|
+
if (typeof value !== 'string') return null
|
|
204
|
+
const trimmed = value.trim()
|
|
205
|
+
if (!trimmed || trimmed.length > 512) return null
|
|
206
|
+
return trimmed
|
|
207
|
+
}
|