claude-code-kanban 4.17.0 → 4.19.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/lib/contain.js +78 -0
- package/lib/inline-assets.js +238 -0
- package/lib/net-guard.js +267 -0
- package/lib/open-editor.js +267 -0
- package/lib/parsers.js +8 -1
- package/package.json +3 -3
- package/public/app.js +181 -77
- package/public/index.html +19 -11
- package/public/style.css +35 -2
- package/server.js +146 -52
package/lib/contain.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Path containment check.
|
|
4
|
+
//
|
|
5
|
+
// The obvious implementation — `child.startsWith(root)` — is wrong twice over:
|
|
6
|
+
// it accepts a sibling directory that merely shares a prefix ("/plugins/foo-evil"
|
|
7
|
+
// passes a check for "/plugins/foo"), and it is blind to symlinks, `..`, casing
|
|
8
|
+
// and Windows 8.3 short names. This uses path.relative instead, and canonicalises
|
|
9
|
+
// both sides first.
|
|
10
|
+
//
|
|
11
|
+
// CANONICAL COPY: scripts/security-lib/contain.js in the claude-code-hub repo.
|
|
12
|
+
// Run scripts/sync-security-lib.sh after editing.
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
|
|
17
|
+
const IS_WIN = process.platform === 'win32';
|
|
18
|
+
|
|
19
|
+
// realpath the deepest existing ancestor, then re-append the non-existent tail,
|
|
20
|
+
// so a not-yet-created file under a symlinked directory still canonicalises.
|
|
21
|
+
function realpathDeepest(target) {
|
|
22
|
+
let cur = target;
|
|
23
|
+
const tail = [];
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
const real = fs.realpathSync.native(cur);
|
|
27
|
+
return tail.length ? path.join(real, ...tail.reverse()) : real;
|
|
28
|
+
} catch {
|
|
29
|
+
const parent = path.dirname(cur);
|
|
30
|
+
if (parent === cur) return target; // reached the volume root, nothing resolvable
|
|
31
|
+
tail.push(path.basename(cur));
|
|
32
|
+
cur = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonical(target, deepest) {
|
|
38
|
+
let out = path.resolve(target);
|
|
39
|
+
if (deepest) out = realpathDeepest(out);
|
|
40
|
+
else { try { out = fs.realpathSync.native(out); } catch { /* may not exist yet */ } }
|
|
41
|
+
// realpathSync.native has already expanded 8.3 short names (PROGRA~1) and fixed
|
|
42
|
+
// the casing of the existing portion; lowercase covers the rest.
|
|
43
|
+
return IS_WIN ? out.toLowerCase() : out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function beneath(canonicalChild, root) {
|
|
47
|
+
const rel = path.relative(canonical(root, false), canonicalChild);
|
|
48
|
+
// '' means child === root. An absolute rel means a different drive or UNC share,
|
|
49
|
+
// which path.relative signals by giving up — reject those.
|
|
50
|
+
if (rel === '') return true;
|
|
51
|
+
if (path.isAbsolute(rel)) return false;
|
|
52
|
+
return rel !== '..' && !rel.startsWith(`..${path.sep}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True iff `child` is `root` itself or lies beneath it.
|
|
57
|
+
* @param {string} child
|
|
58
|
+
* @param {string} root
|
|
59
|
+
*/
|
|
60
|
+
function isContained(child, root) {
|
|
61
|
+
if (typeof child !== 'string' || typeof root !== 'string' || !child || !root) return false;
|
|
62
|
+
return beneath(canonical(child, true), root);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True iff `child` is contained by any of `roots`. Canonicalises the child once
|
|
67
|
+
* instead of once per root — callers testing a single path against several roots
|
|
68
|
+
* were paying that walk repeatedly.
|
|
69
|
+
* @param {string} child
|
|
70
|
+
* @param {string[]} roots
|
|
71
|
+
*/
|
|
72
|
+
function isContainedAny(child, roots) {
|
|
73
|
+
if (typeof child !== 'string' || !child || !Array.isArray(roots)) return false;
|
|
74
|
+
const c = canonical(child, true);
|
|
75
|
+
return roots.some((r) => typeof r === 'string' && r && beneath(c, r));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { isContained, isContainedAny, realpathDeepest };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Inlines an HTML file's local assets so it renders standalone inside the preview's
|
|
2
|
+
// `srcdoc` iframe. An `about:srcdoc` document has no URL of its own, so every relative
|
|
3
|
+
// href resolves against the app origin and 404s — the only way a sibling stylesheet,
|
|
4
|
+
// script or image can reach the frame is embedded in the document text.
|
|
5
|
+
//
|
|
6
|
+
// Remote refs (https:, //cdn, data:) are left untouched: they already resolve.
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const fs = require('fs').promises;
|
|
10
|
+
|
|
11
|
+
// Individually large assets are the ones that blow up as base64 (+33%), and the total
|
|
12
|
+
// guards the modal: the whole document is handed to the client in one JSON response.
|
|
13
|
+
// Both count bytes on disk, charged once per unique file — the delivered document is
|
|
14
|
+
// larger than the total whenever an asset is base64'd or referenced twice.
|
|
15
|
+
const MAX_ASSET_BYTES = 4 * 1024 * 1024;
|
|
16
|
+
const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
17
|
+
// @import chains are followed, so a cycle needs both a depth stop and a seen-set.
|
|
18
|
+
const MAX_IMPORT_DEPTH = 5;
|
|
19
|
+
|
|
20
|
+
const MIME_BY_EXT = {
|
|
21
|
+
'.png': 'image/png',
|
|
22
|
+
'.jpg': 'image/jpeg',
|
|
23
|
+
'.jpeg': 'image/jpeg',
|
|
24
|
+
'.gif': 'image/gif',
|
|
25
|
+
'.webp': 'image/webp',
|
|
26
|
+
'.avif': 'image/avif',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.ico': 'image/x-icon',
|
|
29
|
+
'.bmp': 'image/bmp',
|
|
30
|
+
'.woff': 'font/woff',
|
|
31
|
+
'.woff2': 'font/woff2',
|
|
32
|
+
'.ttf': 'font/ttf',
|
|
33
|
+
'.otf': 'font/otf',
|
|
34
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
35
|
+
'.mp4': 'video/mp4',
|
|
36
|
+
'.webm': 'video/webm',
|
|
37
|
+
'.ogv': 'video/ogg',
|
|
38
|
+
'.mp3': 'audio/mpeg',
|
|
39
|
+
'.wav': 'audio/wav',
|
|
40
|
+
'.ogg': 'audio/ogg',
|
|
41
|
+
'.json': 'application/json',
|
|
42
|
+
'.css': 'text/css',
|
|
43
|
+
'.js': 'text/javascript'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function isRemoteRef(ref) {
|
|
47
|
+
return !ref || /^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith('//') || ref.startsWith('#');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mimeFor(file) {
|
|
51
|
+
return MIME_BY_EXT[path.extname(file).toLowerCase()] || 'application/octet-stream';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Reads an attribute out of a tag, quoted or bare. Returns undefined when absent.
|
|
55
|
+
function attrOf(tag, name) {
|
|
56
|
+
const q = new RegExp(`${name.replace(':', '\\:')}\\s*=\\s*(['"])(.*?)\\1`, 'i');
|
|
57
|
+
return tag.match(q)?.[2] ?? tag.match(new RegExp(`${name.replace(':', '\\:')}\\s*=\\s*([^\\s>]+)`, 'i'))?.[1];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Rewrites every match of `re` in one pass: `fn` receives the match array and returns
|
|
61
|
+
// the replacement, or null to leave that match alone. Replacements are resolved
|
|
62
|
+
// concurrently (each reads its own asset) and spliced by offset, so the string is
|
|
63
|
+
// rebuilt once instead of once per match.
|
|
64
|
+
async function replaceMatches(text, re, fn) {
|
|
65
|
+
const matches = [...text.matchAll(re)];
|
|
66
|
+
if (!matches.length) return text;
|
|
67
|
+
const replacements = await Promise.all(matches.map(m => fn(m)));
|
|
68
|
+
const out = [];
|
|
69
|
+
let cursor = 0;
|
|
70
|
+
matches.forEach((m, i) => {
|
|
71
|
+
if (replacements[i] == null) return;
|
|
72
|
+
out.push(text.slice(cursor, m.index), replacements[i]);
|
|
73
|
+
cursor = m.index + m[0].length;
|
|
74
|
+
});
|
|
75
|
+
out.push(text.slice(cursor));
|
|
76
|
+
return out.join('');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// One context threads through a whole document: the cache means an asset referenced by
|
|
80
|
+
// twenty rules is read, size-checked and charged exactly once, and nested @import/url()
|
|
81
|
+
// chains can't each spend the full allowance.
|
|
82
|
+
async function loadAsset(absPath, ctx) {
|
|
83
|
+
if (!ctx.cache.has(absPath)) {
|
|
84
|
+
ctx.cache.set(
|
|
85
|
+
absPath,
|
|
86
|
+
(async () => {
|
|
87
|
+
const stat = await fs.stat(absPath);
|
|
88
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
89
|
+
if (stat.size > MAX_ASSET_BYTES || ctx.spent + stat.size > MAX_TOTAL_BYTES) {
|
|
90
|
+
ctx.skipped.push({ path: absPath, size: stat.size, reason: 'too large' });
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
ctx.spent += stat.size;
|
|
94
|
+
return fs.readFile(absPath);
|
|
95
|
+
})()
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return ctx.cache.get(absPath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function loadText(absPath, ctx) {
|
|
102
|
+
return (await loadAsset(absPath, ctx))?.toString('utf8') ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function toDataUri(absPath, ctx) {
|
|
106
|
+
const buf = await loadAsset(absPath, ctx);
|
|
107
|
+
return buf === null ? null : `data:${mimeFor(absPath)};base64,${buf.toString('base64')}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Resolves a ref against `baseDir` and hands the loaded asset to `use`. Remote refs and
|
|
111
|
+
// unreadable files yield null, which every caller treats as "leave the markup as
|
|
112
|
+
// authored" so the frame degrades to the pre-inlining behaviour.
|
|
113
|
+
async function withAsset(ref, baseDir, use) {
|
|
114
|
+
if (isRemoteRef(ref)) return null;
|
|
115
|
+
try {
|
|
116
|
+
return await use(path.resolve(baseDir, ref));
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const CSS_IMPORT = /@import\s+(?:url\(\s*)?(['"]?)([^'")\s;]+)\1\s*\)?\s*([^;]*);/gi;
|
|
123
|
+
const CSS_URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
|
|
124
|
+
|
|
125
|
+
// Rewrites url(...) targets and follows @import into the imported sheet's own directory —
|
|
126
|
+
// a nested sheet's relative refs are relative to *it*, not to the document.
|
|
127
|
+
async function inlineCss(css, baseDir, ctx, depth = 0, seen = new Set()) {
|
|
128
|
+
let out = css;
|
|
129
|
+
|
|
130
|
+
if (depth < MAX_IMPORT_DEPTH) {
|
|
131
|
+
out = await replaceMatches(out, CSS_IMPORT, ([, , ref, media]) =>
|
|
132
|
+
withAsset(ref, baseDir, async abs => {
|
|
133
|
+
if (seen.has(abs)) return null;
|
|
134
|
+
const text = await loadText(abs, ctx);
|
|
135
|
+
if (text === null) return null;
|
|
136
|
+
const nested = await inlineCss(text, path.dirname(abs), ctx, depth + 1, new Set([...seen, abs]));
|
|
137
|
+
// A media query on the @import has to survive as a wrapper or the rules leak.
|
|
138
|
+
return media.trim() ? `@media ${media.trim()}{\n${nested}\n}` : nested;
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return replaceMatches(out, CSS_URL, ([, , ref]) =>
|
|
144
|
+
withAsset(ref, baseDir, async abs => {
|
|
145
|
+
const uri = await toDataUri(abs, ctx);
|
|
146
|
+
return uri && `url("${uri}")`;
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// `</script>` anywhere in inlined JS (a string, a regex, a template) would close the tag
|
|
152
|
+
// early and dump the rest as markup.
|
|
153
|
+
function escapeScript(js) {
|
|
154
|
+
return js.replace(/<\/script/gi, '<\\/script');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function inlineStylesheets(html, baseDir, ctx) {
|
|
158
|
+
return replaceMatches(html, /<link\b[^>]*>/gi, async ([tag]) => {
|
|
159
|
+
if (!/rel\s*=\s*(['"]?)[^'">]*\bstylesheet\b/i.test(tag)) return null;
|
|
160
|
+
return withAsset(attrOf(tag, 'href'), baseDir, async abs => {
|
|
161
|
+
const css = await loadText(abs, ctx);
|
|
162
|
+
if (css === null) return null;
|
|
163
|
+
const inlined = await inlineCss(css, path.dirname(abs), ctx, 0, new Set([abs]));
|
|
164
|
+
const media = attrOf(tag, 'media');
|
|
165
|
+
return `<style${media ? ` media="${media}"` : ''}>\n${inlined}\n</style>`;
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function inlineStyleElements(html, baseDir, ctx) {
|
|
171
|
+
return replaceMatches(html, /<style\b[^>]*>([\s\S]*?)<\/style>/gi, async ([tag, css]) => {
|
|
172
|
+
if (!/url\(|@import/i.test(css)) return null;
|
|
173
|
+
const inlined = await inlineCss(css, baseDir, ctx);
|
|
174
|
+
return inlined === css ? null : tag.replace(css, inlined);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function inlineScripts(html, baseDir, ctx) {
|
|
179
|
+
return replaceMatches(html, /<script\b[^>]*\bsrc\s*=[^>]*>\s*<\/script>/gi, ([tag]) =>
|
|
180
|
+
withAsset(attrOf(tag, 'src'), baseDir, async abs => {
|
|
181
|
+
const js = await loadText(abs, ctx);
|
|
182
|
+
if (js === null) return null;
|
|
183
|
+
// Keep type= (module vs classic changes semantics); drop src/defer/async, which
|
|
184
|
+
// mean nothing once the body is inline.
|
|
185
|
+
const type = attrOf(tag, 'type');
|
|
186
|
+
return `<script${type ? ` type="${type}"` : ''}>\n${escapeScript(js)}\n</script>`;
|
|
187
|
+
})
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/poster/href on media and image tags. `<use href>` covers sprite sheets, which are
|
|
192
|
+
// the one SVG case that silently renders nothing when unresolved.
|
|
193
|
+
const SRC_ATTR_TAGS = /<(img|source|video|audio|use)\b[^>]*>/gi;
|
|
194
|
+
const SRC_ATTR = /\b(src|poster|href|xlink:href)\s*=\s*(['"])(.*?)\2/gi;
|
|
195
|
+
|
|
196
|
+
function inlineElementSrcs(html, baseDir, ctx) {
|
|
197
|
+
return replaceMatches(html, SRC_ATTR_TAGS, async ([tag]) => {
|
|
198
|
+
let out = await replaceMatches(tag, SRC_ATTR, ([, name, , ref]) =>
|
|
199
|
+
withAsset(ref, baseDir, async abs => {
|
|
200
|
+
const uri = await toDataUri(abs, ctx);
|
|
201
|
+
return uri && `${name}="${uri}"`;
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
// srcset is a comma-separated candidate list, each entry `url [descriptor]`.
|
|
205
|
+
const srcset = out.match(/srcset\s*=\s*(['"])(.*?)\1/i);
|
|
206
|
+
if (srcset) {
|
|
207
|
+
const parts = await Promise.all(
|
|
208
|
+
srcset[2].split(',').map(async entry => {
|
|
209
|
+
const [ref, ...rest] = entry.trim().split(/\s+/);
|
|
210
|
+
const uri = await withAsset(ref, baseDir, abs => toDataUri(abs, ctx));
|
|
211
|
+
return uri ? [uri, ...rest].join(' ') : entry.trim();
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
out = out.replace(srcset[0], `srcset="${parts.join(', ')}"`);
|
|
215
|
+
}
|
|
216
|
+
return out === tag ? null : out;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Embeds every resolvable local asset an HTML document references.
|
|
222
|
+
*
|
|
223
|
+
* @returns {Promise<{ html: string, bytes: number, skipped: Array<{path:string,size:number,reason:string}> }>}
|
|
224
|
+
*/
|
|
225
|
+
async function inlineHtmlAssets(html, absHtmlPath) {
|
|
226
|
+
const baseDir = path.dirname(absHtmlPath);
|
|
227
|
+
const ctx = { spent: 0, skipped: [], cache: new Map() };
|
|
228
|
+
// Author-written <style> blocks first: after inlineStylesheets a <link> has become a
|
|
229
|
+
// <style> whose refs are already resolved, and re-walking megabytes of data URIs to
|
|
230
|
+
// find nothing is the one pass worth ordering around.
|
|
231
|
+
let out = await inlineStyleElements(html, baseDir, ctx);
|
|
232
|
+
out = await inlineStylesheets(out, baseDir, ctx);
|
|
233
|
+
out = await inlineScripts(out, baseDir, ctx);
|
|
234
|
+
out = await inlineElementSrcs(out, baseDir, ctx);
|
|
235
|
+
return { html: out, bytes: ctx.spent, skipped: ctx.skipped };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = { inlineHtmlAssets, MAX_ASSET_BYTES };
|
package/lib/net-guard.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Network boundary for a local-only dev server.
|
|
4
|
+
//
|
|
5
|
+
// Binding loopback is necessary but not sufficient. Three distinct attackers:
|
|
6
|
+
//
|
|
7
|
+
// * LAN peer — http://192.168.1.42:3541/api/... The loopback bind stops this
|
|
8
|
+
// outright; the handshake never completes off-box.
|
|
9
|
+
// * DNS rebinding — the victim loads evil.com, the attacker re-resolves that
|
|
10
|
+
// name to 127.0.0.1, and the page fetches http://evil.com:3541/api/...
|
|
11
|
+
// The page's origin *is* evil.com:3541, so the request is same-origin and
|
|
12
|
+
// CORS never fires; the connection is to loopback, so the bind is satisfied.
|
|
13
|
+
// The only thing that differs from a legitimate request is the Host header,
|
|
14
|
+
// which script cannot forge. A Host allowlist is the entire defense.
|
|
15
|
+
// * Plain CSRF — evil.com POSTs straight at 127.0.0.1:3541. Host is a real
|
|
16
|
+
// allowlisted value, so hostGuard passes it; this needs a separate Origin /
|
|
17
|
+
// Sec-Fetch-Site check on state-changing methods.
|
|
18
|
+
//
|
|
19
|
+
// There is no authentication. Exposing this to a network means trusting everyone
|
|
20
|
+
// on it with the user's Claude Code history.
|
|
21
|
+
//
|
|
22
|
+
// CANONICAL COPY: scripts/security-lib/net-guard.js in the claude-code-hub repo.
|
|
23
|
+
// Run scripts/sync-security-lib.sh after editing.
|
|
24
|
+
|
|
25
|
+
const LOOPBACK_HOSTS = new Set([
|
|
26
|
+
'localhost',
|
|
27
|
+
'127.0.0.1',
|
|
28
|
+
'::1',
|
|
29
|
+
'0:0:0:0:0:0:0:1',
|
|
30
|
+
'::ffff:127.0.0.1',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// Hoisted: parseHostHeader runs on every request, and neither the regex nor the
|
|
34
|
+
// predicate depends on the argument.
|
|
35
|
+
const PORT_RE = /^[0-9]{1,5}$/;
|
|
36
|
+
// The port is validated but not compared — the listen(0) fallbacks make the real
|
|
37
|
+
// port dynamic, so only the host is allowlisted.
|
|
38
|
+
const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
|
|
39
|
+
|
|
40
|
+
function isLoopbackAddress(host) {
|
|
41
|
+
if (!host) return false;
|
|
42
|
+
const h = String(host).toLowerCase();
|
|
43
|
+
return LOOPBACK_HOSTS.has(h) || h.startsWith('127.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Hand-parsed rather than via new URL(), which is far more permissive than we
|
|
47
|
+
// want here and happily accepts things no real client sends. Returns the bare
|
|
48
|
+
// hostname, lowercased, or null if the header is malformed.
|
|
49
|
+
//
|
|
50
|
+
// The port is deliberately ignored: ports are dynamic in these apps because of
|
|
51
|
+
// the listen(0) fallback when the default is busy, so pinning one would break
|
|
52
|
+
// the fallback path.
|
|
53
|
+
function parseHostHeader(value) {
|
|
54
|
+
if (typeof value !== 'string') return null;
|
|
55
|
+
const raw = value.trim();
|
|
56
|
+
if (!raw || raw.length > 259) return null;
|
|
57
|
+
|
|
58
|
+
// Reject whitespace and control characters. Written as a code-point scan rather
|
|
59
|
+
// than a character class so it cannot be misread — a stray range in a regex
|
|
60
|
+
// here would silently reject legitimate hostnames containing a dash.
|
|
61
|
+
for (let i = 0; i < raw.length; i++) {
|
|
62
|
+
const code = raw.charCodeAt(i);
|
|
63
|
+
if (code <= 0x20 || code === 0x7f) return null;
|
|
64
|
+
}
|
|
65
|
+
if (raw.includes('@')) return null; // userinfo has no business in a Host header
|
|
66
|
+
|
|
67
|
+
let host;
|
|
68
|
+
if (raw.startsWith('[')) {
|
|
69
|
+
const end = raw.indexOf(']');
|
|
70
|
+
if (end === -1) return null;
|
|
71
|
+
host = raw.slice(1, end);
|
|
72
|
+
const rest = raw.slice(end + 1);
|
|
73
|
+
if (rest && !(rest.startsWith(':') && validPort(rest.slice(1)))) return null;
|
|
74
|
+
} else {
|
|
75
|
+
const parts = raw.split(':');
|
|
76
|
+
if (parts.length > 2) return null; // bare IPv6 without brackets, or garbage
|
|
77
|
+
host = parts[0];
|
|
78
|
+
if (parts.length === 2 && !validPort(parts[1])) return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!host) return null;
|
|
82
|
+
if (host.endsWith('.')) host = host.slice(0, -1); // one trailing root dot is legal
|
|
83
|
+
if (!host) return null;
|
|
84
|
+
return host.toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseList(value) {
|
|
88
|
+
return String(value || '')
|
|
89
|
+
.split(',')
|
|
90
|
+
.map((s) => s.trim().toLowerCase())
|
|
91
|
+
.filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getFlag(name) {
|
|
95
|
+
const idx = process.argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
|
|
96
|
+
if (idx === -1) return null;
|
|
97
|
+
const arg = process.argv[idx];
|
|
98
|
+
if (arg.includes('=')) return arg.slice(arg.indexOf('=') + 1);
|
|
99
|
+
const next = process.argv[idx + 1];
|
|
100
|
+
return next && !next.startsWith('--') ? next : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{appName?: string}} [config]
|
|
105
|
+
*/
|
|
106
|
+
function createNetGuard(config = {}) {
|
|
107
|
+
const appName = config.appName || 'This app';
|
|
108
|
+
|
|
109
|
+
const BIND_HOST = getFlag('host') || process.env.HOST || '127.0.0.1';
|
|
110
|
+
const BIND_HOST_LC = BIND_HOST.toLowerCase();
|
|
111
|
+
const EXPOSED = !isLoopbackAddress(BIND_HOST);
|
|
112
|
+
// Resolved here and re-exported so the hub can forward the same value to its
|
|
113
|
+
// children rather than re-parsing the flag with its own argv scanner — a parent
|
|
114
|
+
// and child that disagree means the shell serves while every iframe 403s.
|
|
115
|
+
const ALLOWED_HOSTS = getFlag('allowed-hosts') || process.env.ALLOWED_HOSTS || '';
|
|
116
|
+
const extraHosts = new Set(parseList(ALLOWED_HOSTS));
|
|
117
|
+
|
|
118
|
+
// The hub frames each sub-app, so its origin must be accepted as an Origin and
|
|
119
|
+
// permitted as a framing ancestor.
|
|
120
|
+
let hubOrigin = null;
|
|
121
|
+
if (process.env.HUB_URL) {
|
|
122
|
+
try { hubOrigin = new URL(process.env.HUB_URL).origin; } catch { /* ignore a malformed HUB_URL */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Resolved only once listen() succeeds — the requested port may have been busy.
|
|
126
|
+
let actualPort = null;
|
|
127
|
+
|
|
128
|
+
function hostAllowed(host) {
|
|
129
|
+
if (!host) return false;
|
|
130
|
+
if (isLoopbackAddress(host)) return true;
|
|
131
|
+
if (extraHosts.has(host)) return true;
|
|
132
|
+
// Binding a specific non-loopback address implies that address is a valid name for us.
|
|
133
|
+
if (EXPOSED && host === BIND_HOST_LC) return true;
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hostGuard(req, res, next) {
|
|
138
|
+
const host = parseHostHeader(req.headers.host);
|
|
139
|
+
if (hostAllowed(host)) return next();
|
|
140
|
+
res.status(403).type('text/plain').send(
|
|
141
|
+
`403 Forbidden - unrecognized Host header: ${req.headers.host || '(none)'}\n\n` +
|
|
142
|
+
`${appName} only answers requests addressed to localhost. This protects you\n` +
|
|
143
|
+
`from DNS rebinding, where a website you visit re-points its own hostname at\n` +
|
|
144
|
+
`127.0.0.1 in order to read your local data.\n\n` +
|
|
145
|
+
`If you meant to reach this from another machine, restart with:\n` +
|
|
146
|
+
` --host 0.0.0.0 --allowed-hosts=${host || '<your-hostname>'}\n\n` +
|
|
147
|
+
`Only do that on a network you trust - there is no authentication.\n`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// true = allow, false = block, null = no Origin header, caller decides.
|
|
152
|
+
function originVerdict(origin) {
|
|
153
|
+
if (!origin || origin === 'null') return null;
|
|
154
|
+
let parsed;
|
|
155
|
+
try { parsed = new URL(origin); } catch { return false; }
|
|
156
|
+
if (hubOrigin && parsed.origin === hubOrigin) return true;
|
|
157
|
+
if (!isLoopbackAddress(parsed.hostname)) return false;
|
|
158
|
+
// Same port, any loopback spelling. A different local port is a different app
|
|
159
|
+
// and has no business driving this one.
|
|
160
|
+
if (actualPort && parsed.port && Number(parsed.port) !== Number(actualPort)) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function originGuard(req, res, next) {
|
|
165
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
|
|
166
|
+
|
|
167
|
+
const verdict = originVerdict(req.headers.origin);
|
|
168
|
+
if (verdict === true) return next();
|
|
169
|
+
if (verdict === false) {
|
|
170
|
+
return res.status(403).type('text/plain')
|
|
171
|
+
.send(`403 Forbidden - cross-origin ${req.method} from ${req.headers.origin} is not allowed.\n`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No Origin header: a non-browser client (curl, the CLI, the hub's own fetch).
|
|
175
|
+
// Trust it unless the browser explicitly tells us otherwise.
|
|
176
|
+
const site = req.headers['sec-fetch-site'];
|
|
177
|
+
if (site && site !== 'same-origin' && site !== 'none') {
|
|
178
|
+
return res.status(403).type('text/plain')
|
|
179
|
+
.send(`403 Forbidden - cross-site ${req.method} (Sec-Fetch-Site: ${site}) is not allowed.\n`);
|
|
180
|
+
}
|
|
181
|
+
return next();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Constant for the life of the process, and frameGuard runs ahead of
|
|
185
|
+
// express.static — so this is built once rather than per asset fetch.
|
|
186
|
+
//
|
|
187
|
+
// Under the hub, X-Frame-Options is deliberately omitted: it has no multi-origin
|
|
188
|
+
// form, and sending SAMEORIGIN would break the hub's iframes.
|
|
189
|
+
//
|
|
190
|
+
// The port is wildcarded rather than pinned to HUB_URL's. The hub spawns its
|
|
191
|
+
// children before it binds, so if its own preferred port is busy it hands them a
|
|
192
|
+
// HUB_URL with the wrong port and every iframe would break. Any loopback port may
|
|
193
|
+
// frame us; that is not the threat being addressed here — a remote page still
|
|
194
|
+
// cannot, which is what matters.
|
|
195
|
+
//
|
|
196
|
+
// No IPv6 literal: Chrome rejects `http://[::1]:*` as a source expression and
|
|
197
|
+
// logs it on every framed load. The hub's own origin is added verbatim instead,
|
|
198
|
+
// which covers a hub reached over [::1].
|
|
199
|
+
let CSP;
|
|
200
|
+
if (hubOrigin) {
|
|
201
|
+
const ancestors = ["'self'", 'http://localhost:*', 'http://127.0.0.1:*'];
|
|
202
|
+
if (!/^http:\/\/(localhost|127\.0\.0\.1)(:|$)/.test(hubOrigin)) ancestors.push(hubOrigin);
|
|
203
|
+
CSP = `frame-ancestors ${ancestors.join(' ')}`;
|
|
204
|
+
} else {
|
|
205
|
+
CSP = "frame-ancestors 'none'";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function frameGuard(_req, res, next) {
|
|
209
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
210
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
211
|
+
res.setHeader('Content-Security-Policy', CSP);
|
|
212
|
+
if (!hubOrigin) res.setHeader('X-Frame-Options', 'DENY');
|
|
213
|
+
next();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Bind BIND_HOST, then opportunistically serve the other loopback family on the
|
|
218
|
+
* same resolved port via a second server sharing the same request handler.
|
|
219
|
+
*
|
|
220
|
+
* That second bind is what keeps `http://localhost:<port>` working no matter how
|
|
221
|
+
* the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
|
|
222
|
+
* where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
|
|
223
|
+
* port means every existing URL, startup banner and postMessage origin is
|
|
224
|
+
* unchanged. Failures are swallowed: it is an optimization, not a requirement.
|
|
225
|
+
*
|
|
226
|
+
* Returns the primary server, so callers keep attaching their own 'error'
|
|
227
|
+
* handler for the EADDRINUSE fallback.
|
|
228
|
+
*
|
|
229
|
+
* @param {any} app express app (or any http request handler)
|
|
230
|
+
* @param {number} port 0 selects a free port
|
|
231
|
+
* @param {(port: number) => void} [onReady]
|
|
232
|
+
* @param {{maxHeaderSize?: number}} [opts]
|
|
233
|
+
*/
|
|
234
|
+
function listenLoopback(app, port, onReady, opts = {}) {
|
|
235
|
+
const http = require('http');
|
|
236
|
+
const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
|
|
237
|
+
const makeServer = (handler) => http.createServer(serverOpts, handler);
|
|
238
|
+
const server = makeServer(app);
|
|
239
|
+
let secondary = null;
|
|
240
|
+
|
|
241
|
+
server.on('listening', () => {
|
|
242
|
+
actualPort = server.address().port;
|
|
243
|
+
if (!EXPOSED) {
|
|
244
|
+
const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
|
|
245
|
+
try {
|
|
246
|
+
secondary = makeServer(app);
|
|
247
|
+
secondary.on('error', () => {}); // that family may not exist on this host
|
|
248
|
+
secondary.listen({ host: otherFamily, port: actualPort, ipv6Only: otherFamily === '::1' });
|
|
249
|
+
} catch { /* best effort */ }
|
|
250
|
+
}
|
|
251
|
+
if (onReady) onReady(actualPort);
|
|
252
|
+
});
|
|
253
|
+
server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
|
|
254
|
+
|
|
255
|
+
server.listen(port, BIND_HOST);
|
|
256
|
+
return server;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function exposureWarning() {
|
|
260
|
+
if (!EXPOSED) return null;
|
|
261
|
+
return `WARNING: listening on ${BIND_HOST} - reachable from your network, with no authentication.`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { BIND_HOST, ALLOWED_HOSTS, hostGuard, originGuard, frameGuard, listenLoopback, exposureWarning };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
|