sidebranch 0.2.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 +83 -0
- package/LICENSE +28 -0
- package/README.md +368 -0
- package/SECURITY.md +161 -0
- package/bin/sidebranch.js +12 -0
- package/package.json +49 -0
- package/src/assets/boot-tag.js +20 -0
- package/src/assets/geist-pixel.LICENSE.txt +133 -0
- package/src/assets/geist-pixel.woff2 +0 -0
- package/src/assets/shell.html +930 -0
- package/src/assets/widget-core.js +898 -0
- package/src/cli.js +342 -0
- package/src/config.js +120 -0
- package/src/daemon.js +323 -0
- package/src/daemonfile.js +156 -0
- package/src/gitops.js +264 -0
- package/src/install.js +115 -0
- package/src/manager.js +260 -0
- package/src/processes.js +250 -0
- package/src/proxy.js +136 -0
- package/src/security.js +123 -0
package/src/processes.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* processes.js — spawn and supervise one dev server per review worktree.
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic contract: a dev server is (command, cwd, port, env) plus
|
|
5
|
+
* a readiness probe. We inject the allocated port as the PORT env var and
|
|
6
|
+
* substitute a literal {port} placeholder in the command for tools that
|
|
7
|
+
* only take a flag (e.g. "vite --port {port}", "python3 -m http.server {port}").
|
|
8
|
+
* Config-supplied `env` is layered on top of the inherited environment (so a
|
|
9
|
+
* project can point a pane at a shared emulator, unset a stale credential
|
|
10
|
+
* path, etc.) but cannot override the vars sidebranch owns — PORT above all.
|
|
11
|
+
* We never parse dev-server output — readiness is confirmed by probing the
|
|
12
|
+
* port, because some servers silently pick a different port than asked.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
import http from "node:http";
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
import { splitCommand, tailText } from "./install.js";
|
|
20
|
+
import { isValidPort } from "./security.js";
|
|
21
|
+
|
|
22
|
+
/** Find a free port on loopback, preferring sequential ports from `base`. */
|
|
23
|
+
export async function allocatePort(base = 4410, taken = new Set()) {
|
|
24
|
+
for (let p = base; p < base + 500; p++) {
|
|
25
|
+
if (taken.has(p)) continue;
|
|
26
|
+
if (await portIsFree(p)) return p;
|
|
27
|
+
}
|
|
28
|
+
throw new Error("No free ports available in range");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function portIsFree(port) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const srv = net.createServer();
|
|
34
|
+
srv.unref();
|
|
35
|
+
srv.once("error", () => resolve(false));
|
|
36
|
+
srv.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
|
|
37
|
+
srv.close(() => resolve(true));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Probe an HTTP endpoint until it answers or timeout.
|
|
44
|
+
* Any HTTP status counts as "up" by default — a 404 or 500 still proves the
|
|
45
|
+
* server is accepting connections; which statuses count is configurable.
|
|
46
|
+
*/
|
|
47
|
+
export function waitForReady({ port, path: probePath = "/", statuses = null, timeoutMs = 120_000, intervalMs = 400, signal }) {
|
|
48
|
+
const deadline = Date.now() + timeoutMs;
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const attempt = () => {
|
|
51
|
+
if (signal?.aborted) return reject(new Error("aborted"));
|
|
52
|
+
const req = http.get(
|
|
53
|
+
{ host: "127.0.0.1", port, path: probePath, timeout: 3000 },
|
|
54
|
+
(res) => {
|
|
55
|
+
res.resume();
|
|
56
|
+
const ok = statuses ? statuses.includes(res.statusCode) : true;
|
|
57
|
+
if (ok) return resolve(res.statusCode);
|
|
58
|
+
retry();
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
req.on("timeout", () => { req.destroy(); retry(); });
|
|
62
|
+
req.on("error", retry);
|
|
63
|
+
};
|
|
64
|
+
const retry = () => {
|
|
65
|
+
if (Date.now() > deadline) return reject(new Error(`Server on :${port} not ready after ${timeoutMs / 1000}s`));
|
|
66
|
+
setTimeout(attempt, intervalMs);
|
|
67
|
+
};
|
|
68
|
+
attempt();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ask a pane's dev server whether it will allow itself to be embedded.
|
|
74
|
+
*
|
|
75
|
+
* `/shell` frames panes from a *different* origin — the daemon is on 49400,
|
|
76
|
+
* panes are on 4410+ — and same-origin is per-port, so any app that sends
|
|
77
|
+
* `X-Frame-Options: SAMEORIGIN` (or a `frame-ancestors` that excludes the
|
|
78
|
+
* daemon) refuses to render there. The browser reports that only as a console
|
|
79
|
+
* message inside the frame, which the shell cannot read cross-origin, so
|
|
80
|
+
* without this probe the compare view is simply, silently blank.
|
|
81
|
+
*
|
|
82
|
+
* Best-effort by construction: `frame-ancestors` is a source-list grammar and
|
|
83
|
+
* this does not implement it. It answers "will this obviously refuse?", which
|
|
84
|
+
* is enough to replace a blank rectangle with a sentence naming the header.
|
|
85
|
+
* Never throws — an unreachable server is not a framing verdict.
|
|
86
|
+
*/
|
|
87
|
+
export function probeFraming({ port, path: probePath = "/", daemonPort }) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const done = (v) => resolve(v);
|
|
90
|
+
const req = http.get(
|
|
91
|
+
{ host: "127.0.0.1", port, path: probePath, timeout: 3000 },
|
|
92
|
+
(res) => {
|
|
93
|
+
res.resume();
|
|
94
|
+
done(readFramingHeaders(res.headers, daemonPort));
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
req.on("timeout", () => { req.destroy(); done(null); });
|
|
98
|
+
req.on("error", () => done(null));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readFramingHeaders(headers, daemonPort) {
|
|
103
|
+
const xfo = String(headers["x-frame-options"] ?? "").trim().toLowerCase();
|
|
104
|
+
if (xfo === "deny" || xfo === "sameorigin") {
|
|
105
|
+
return { blocked: true, header: "X-Frame-Options", value: xfo.toUpperCase() };
|
|
106
|
+
}
|
|
107
|
+
const csp = String(headers["content-security-policy"] ?? "");
|
|
108
|
+
const directive = /(?:^|;)\s*frame-ancestors\s+([^;]+)/i.exec(csp);
|
|
109
|
+
if (!directive) return { blocked: false, header: null, value: null };
|
|
110
|
+
const sources = directive[1].trim().toLowerCase().split(/\s+/);
|
|
111
|
+
const permits = sources.some((src) => (
|
|
112
|
+
src === "*" ||
|
|
113
|
+
src === `http://localhost:${daemonPort}` ||
|
|
114
|
+
src === `http://127.0.0.1:${daemonPort}` ||
|
|
115
|
+
src === "http://localhost:*" ||
|
|
116
|
+
src === "http://127.0.0.1:*"
|
|
117
|
+
));
|
|
118
|
+
return permits
|
|
119
|
+
? { blocked: false, header: null, value: null }
|
|
120
|
+
: { blocked: true, header: "Content-Security-Policy", value: `frame-ancestors ${directive[1].trim()}` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class DevServer {
|
|
124
|
+
constructor({ command, cwd, port, env = {}, readyPath = "/", readyStatuses = null, daemonPort = null }) {
|
|
125
|
+
if (!isValidPort(port)) throw new Error(`Invalid port ${port}`);
|
|
126
|
+
this.command = command;
|
|
127
|
+
this.cwd = cwd;
|
|
128
|
+
this.port = port;
|
|
129
|
+
this.env = env;
|
|
130
|
+
this.readyPath = readyPath;
|
|
131
|
+
this.readyStatuses = readyStatuses;
|
|
132
|
+
this.daemonPort = daemonPort;
|
|
133
|
+
this.child = null;
|
|
134
|
+
this.state = "stopped"; // stopped | starting | ready | crashed
|
|
135
|
+
this.logRing = [];
|
|
136
|
+
this.exitInfo = null;
|
|
137
|
+
// null until the readiness probe has had a chance to look — "unknown",
|
|
138
|
+
// which the shell renders as "embed it and see", not as "blocked".
|
|
139
|
+
this.framing = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
log(line) {
|
|
143
|
+
this.logRing.push(line);
|
|
144
|
+
if (this.logRing.length > 400) this.logRing.shift();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async start() {
|
|
148
|
+
const argv = splitCommand(this.command).map((a) =>
|
|
149
|
+
a.replaceAll("{port}", String(this.port))
|
|
150
|
+
);
|
|
151
|
+
if (argv.length === 0) throw new Error("Empty dev command");
|
|
152
|
+
this.state = "starting";
|
|
153
|
+
this.exitInfo = null;
|
|
154
|
+
this.child = spawn(argv[0], argv.slice(1), {
|
|
155
|
+
cwd: this.cwd,
|
|
156
|
+
env: {
|
|
157
|
+
...process.env,
|
|
158
|
+
...this.env, // config `env` — overrides inherited values
|
|
159
|
+
// (already stripped of PORT et al. in config)
|
|
160
|
+
PORT: String(this.port), // sidebranch-owned; always wins over config
|
|
161
|
+
BROWSER: "none", // stop CRA/next from opening tabs
|
|
162
|
+
FORCE_COLOR: "0",
|
|
163
|
+
SIDEBRANCH: "1",
|
|
164
|
+
},
|
|
165
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
166
|
+
shell: false,
|
|
167
|
+
detached: process.platform !== "win32", // own process group → clean kill of child trees
|
|
168
|
+
windowsHide: true,
|
|
169
|
+
});
|
|
170
|
+
const forward = (buf) => this.log(buf.toString());
|
|
171
|
+
this.child.stdout.on("data", forward);
|
|
172
|
+
this.child.stderr.on("data", forward);
|
|
173
|
+
|
|
174
|
+
// spawn() reports failures (e.g. command not found) via an 'error' event,
|
|
175
|
+
// not 'exit'. ChildProcess is an EventEmitter, so a truly unhandled
|
|
176
|
+
// 'error' event throws and takes down the whole daemon process — every
|
|
177
|
+
// pane, not just this one. Handling it here turns that into an ordinary
|
|
178
|
+
// rejected start() (surfaced to the API caller as a pane error) and
|
|
179
|
+
// aborts the readiness probe immediately instead of waiting out its
|
|
180
|
+
// full timeout.
|
|
181
|
+
const abortReady = new AbortController();
|
|
182
|
+
let startupError = null;
|
|
183
|
+
this.child.on("error", (err) => {
|
|
184
|
+
startupError = err;
|
|
185
|
+
this.exitInfo = { code: null, sig: null };
|
|
186
|
+
this.state = "crashed";
|
|
187
|
+
this.child = null;
|
|
188
|
+
abortReady.abort();
|
|
189
|
+
});
|
|
190
|
+
this.child.on("exit", (code, sig) => {
|
|
191
|
+
this.exitInfo = { code, sig };
|
|
192
|
+
if (this.state !== "stopped") this.state = "crashed";
|
|
193
|
+
this.child = null;
|
|
194
|
+
abortReady.abort();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await waitForReady({
|
|
199
|
+
port: this.port,
|
|
200
|
+
path: this.readyPath,
|
|
201
|
+
statuses: this.readyStatuses,
|
|
202
|
+
signal: abortReady.signal,
|
|
203
|
+
});
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (this.state === "crashed") {
|
|
206
|
+
const reason = startupError
|
|
207
|
+
? startupError.message
|
|
208
|
+
: `exited during startup${this.exitInfo?.code != null ? ` (exit code ${this.exitInfo.code})` : ""}`;
|
|
209
|
+
const tail = tailText(this.logRing);
|
|
210
|
+
throw new Error(`Dev server ${reason}${tail ? `: ${tail}` : ""}`);
|
|
211
|
+
}
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
if (this.state === "crashed") throw new Error("Dev server exited during startup");
|
|
215
|
+
// One extra request, once per server start, on a server we have just
|
|
216
|
+
// proven is answering. Failure here is not a startup failure.
|
|
217
|
+
this.framing = await probeFraming({
|
|
218
|
+
port: this.port,
|
|
219
|
+
path: this.readyPath,
|
|
220
|
+
daemonPort: this.daemonPort,
|
|
221
|
+
});
|
|
222
|
+
this.state = "ready";
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async stop() {
|
|
227
|
+
this.state = "stopped";
|
|
228
|
+
const child = this.child;
|
|
229
|
+
if (!child) return;
|
|
230
|
+
await new Promise((resolve) => {
|
|
231
|
+
child.once("exit", resolve);
|
|
232
|
+
try {
|
|
233
|
+
if (process.platform !== "win32") process.kill(-child.pid, "SIGTERM");
|
|
234
|
+
else child.kill("SIGTERM");
|
|
235
|
+
} catch { resolve(); }
|
|
236
|
+
setTimeout(() => {
|
|
237
|
+
try {
|
|
238
|
+
if (process.platform !== "win32") process.kill(-child.pid, "SIGKILL");
|
|
239
|
+
else child.kill("SIGKILL");
|
|
240
|
+
} catch { /* already gone */ }
|
|
241
|
+
}, 5000).unref();
|
|
242
|
+
});
|
|
243
|
+
this.child = null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async restart() {
|
|
247
|
+
await this.stop();
|
|
248
|
+
return this.start();
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proxy.js — per-pane "view port": a pass-through proxy the shell frames
|
|
3
|
+
* instead of the pane's own port, identical except that the two framing
|
|
4
|
+
* headers are deleted. Nothing else is parsed, buffered, or rewritten.
|
|
5
|
+
*
|
|
6
|
+
* Rules (see SECURITY.md):
|
|
7
|
+
* - Binds 127.0.0.1 only; peer and Host are checked like the daemon's gate.
|
|
8
|
+
* - `Sec-Fetch-Site: cross-site` is rejected, so remote pages can't frame a
|
|
9
|
+
* pane through it — the protection the stripped header was providing.
|
|
10
|
+
* - The target is fixed at construction (its own pane). No request-derived
|
|
11
|
+
* routing, so it can never be used to reach anything else.
|
|
12
|
+
* - What was removed is declared in `Sidebranch-Removed-Headers`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import net from "node:net";
|
|
17
|
+
import { isLoopbackAddress, isAllowedHostHeader } from "./security.js";
|
|
18
|
+
|
|
19
|
+
// Hop-by-hop headers are per-connection; forwarding them corrupts framing of
|
|
20
|
+
// the next hop's stream (double chunking, stale keep-alive promises).
|
|
21
|
+
const HOP_BY_HOP = ["connection", "keep-alive", "transfer-encoding", "te", "trailer", "proxy-authorization", "proxy-authenticate"];
|
|
22
|
+
|
|
23
|
+
/** Returns { headers, removed[] } — a copy with the framing headers gone. */
|
|
24
|
+
export function stripFramingHeaders(headers) {
|
|
25
|
+
const out = { ...headers };
|
|
26
|
+
const removed = [];
|
|
27
|
+
if (out["x-frame-options"] !== undefined) {
|
|
28
|
+
delete out["x-frame-options"];
|
|
29
|
+
removed.push("x-frame-options");
|
|
30
|
+
}
|
|
31
|
+
const csp = out["content-security-policy"];
|
|
32
|
+
if (csp !== undefined) {
|
|
33
|
+
const strip = (v) => {
|
|
34
|
+
const kept = String(v).split(";").filter((d) => !/^\s*frame-ancestors\s/i.test(d) && d.trim() !== "");
|
|
35
|
+
return kept.join(";").trim();
|
|
36
|
+
};
|
|
37
|
+
const values = Array.isArray(csp) ? csp : [csp];
|
|
38
|
+
const stripped = values.map(strip);
|
|
39
|
+
if (stripped.join() !== values.join()) {
|
|
40
|
+
removed.push("content-security-policy frame-ancestors");
|
|
41
|
+
const kept = stripped.filter((v) => v !== "");
|
|
42
|
+
if (kept.length === 0) delete out["content-security-policy"];
|
|
43
|
+
else out["content-security-policy"] = Array.isArray(csp) ? kept : kept[0];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { headers: out, removed };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class FrameProxy {
|
|
50
|
+
constructor({ port, targetPort }) {
|
|
51
|
+
this.port = port;
|
|
52
|
+
this.targetPort = targetPort;
|
|
53
|
+
this.server = null;
|
|
54
|
+
this.sockets = new Set();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
gate(req, res) {
|
|
58
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) { res.writeHead(403).end(); return false; }
|
|
59
|
+
if (!isAllowedHostHeader(req.headers.host)) { res.writeHead(403).end("Host not allowed\n"); return false; }
|
|
60
|
+
if (String(req.headers["sec-fetch-site"] || "").toLowerCase() === "cross-site") {
|
|
61
|
+
res.writeHead(403).end("Cross-site requests are not allowed\n");
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
handle(req, res) {
|
|
68
|
+
if (!this.gate(req, res)) return;
|
|
69
|
+
const headers = { ...req.headers };
|
|
70
|
+
for (const h of HOP_BY_HOP) delete headers[h];
|
|
71
|
+
const upstream = http.request({
|
|
72
|
+
host: "127.0.0.1",
|
|
73
|
+
port: this.targetPort,
|
|
74
|
+
method: req.method,
|
|
75
|
+
path: req.url,
|
|
76
|
+
headers,
|
|
77
|
+
// No keep-alive pool: pooled sockets outlive stop() and pin the event
|
|
78
|
+
// loop; on loopback a fresh connection per request costs nothing.
|
|
79
|
+
agent: false,
|
|
80
|
+
}, (up) => {
|
|
81
|
+
const { headers: outHeaders, removed } = stripFramingHeaders(up.headers);
|
|
82
|
+
for (const h of HOP_BY_HOP) delete outHeaders[h];
|
|
83
|
+
if (removed.length) outHeaders["sidebranch-removed-headers"] = removed.join(", ");
|
|
84
|
+
res.writeHead(up.statusCode, up.statusMessage, outHeaders);
|
|
85
|
+
up.pipe(res);
|
|
86
|
+
});
|
|
87
|
+
upstream.on("error", () => {
|
|
88
|
+
if (!res.headersSent) res.writeHead(502, { "Content-Type": "text/plain" });
|
|
89
|
+
res.end(`Pane server on :${this.targetPort} is not answering\n`);
|
|
90
|
+
});
|
|
91
|
+
req.pipe(upstream);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Raw splice for websockets (HMR). The gate applies here too — upgrades are
|
|
95
|
+
// where Host checks classically get forgotten.
|
|
96
|
+
upgrade(req, socket, head) {
|
|
97
|
+
const deny = (msg) => socket.end(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n${msg}\n`);
|
|
98
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) return deny("Forbidden");
|
|
99
|
+
if (!isAllowedHostHeader(req.headers.host)) return deny("Host not allowed");
|
|
100
|
+
if (String(req.headers["sec-fetch-site"] || "").toLowerCase() === "cross-site") return deny("Cross-site requests are not allowed");
|
|
101
|
+
|
|
102
|
+
const target = net.connect(this.targetPort, "127.0.0.1", () => {
|
|
103
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
104
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
105
|
+
target.write(lines.join("\r\n") + "\r\n\r\n");
|
|
106
|
+
if (head?.length) target.write(head);
|
|
107
|
+
socket.pipe(target).pipe(socket);
|
|
108
|
+
});
|
|
109
|
+
// Either side going away takes the other with it. 'end' is in the list
|
|
110
|
+
// because http.Server sockets allow half-open: a peer's FIN alone never
|
|
111
|
+
// produces 'close', and a half-closed websocket is a dead websocket.
|
|
112
|
+
const drop = () => { socket.destroy(); target.destroy(); };
|
|
113
|
+
for (const s of [socket, target]) { s.on("error", drop); s.on("close", drop); s.on("end", drop); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async start() {
|
|
117
|
+
this.server = http.createServer((req, res) => this.handle(req, res));
|
|
118
|
+
this.server.on("upgrade", (req, socket, head) => this.upgrade(req, socket, head));
|
|
119
|
+
// Long-lived SSE/HMR streams must not be reaped by the default 300s cap.
|
|
120
|
+
this.server.requestTimeout = 0;
|
|
121
|
+
this.server.on("connection", (s) => {
|
|
122
|
+
this.sockets.add(s);
|
|
123
|
+
s.on("close", () => this.sockets.delete(s));
|
|
124
|
+
});
|
|
125
|
+
await new Promise((resolve, reject) => {
|
|
126
|
+
this.server.once("error", reject);
|
|
127
|
+
this.server.listen(this.port, "127.0.0.1", resolve);
|
|
128
|
+
});
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async stop() {
|
|
133
|
+
for (const s of this.sockets) s.destroy();
|
|
134
|
+
await new Promise((r) => this.server?.close(r));
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/security.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* security.js — the trust boundary of sidebranch.
|
|
3
|
+
*
|
|
4
|
+
* Threat model (see SECURITY.md for the full write-up):
|
|
5
|
+
* 1. A malicious *remote* website loaded in the user's browser tries to
|
|
6
|
+
* reach the daemon (CSRF-style requests to http://localhost:49400,
|
|
7
|
+
* or DNS-rebinding where evil.com resolves to 127.0.0.1).
|
|
8
|
+
* 2. The widget <script> tag accidentally ships to production, where it
|
|
9
|
+
* runs on end users' machines pointed at *their* localhost.
|
|
10
|
+
* 3. Command injection through branch names or config values.
|
|
11
|
+
* 4. A non-loopback network peer connecting to the daemon.
|
|
12
|
+
*
|
|
13
|
+
* Defenses, layered (each is sufficient on its own for its attack):
|
|
14
|
+
* - The daemon binds 127.0.0.1 only, and additionally verifies the peer
|
|
15
|
+
* socket address of every request (defense in depth vs. proxies).
|
|
16
|
+
* - Every request's Host header must be a loopback host. This defeats
|
|
17
|
+
* DNS rebinding, where the socket is loopback but Host is attacker-owned.
|
|
18
|
+
* - Every state-changing or state-revealing API call requires a bearer
|
|
19
|
+
* token that is only ever embedded in assets served to loopback pages.
|
|
20
|
+
* - CORS is only granted to loopback origins; all other origins get no
|
|
21
|
+
* CORS headers at all, so their reads fail in the browser.
|
|
22
|
+
* - The widget self-disables unless the *page* it runs on is loopback,
|
|
23
|
+
* so a production deployment of the snippet is inert by construction.
|
|
24
|
+
* - All git/process invocations use execFile/spawn with argument arrays
|
|
25
|
+
* (never a shell), and ref names are validated before use.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import crypto from "node:crypto";
|
|
29
|
+
import net from "node:net";
|
|
30
|
+
|
|
31
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
32
|
+
|
|
33
|
+
/** True if an IP address literal is a loopback address. */
|
|
34
|
+
export function isLoopbackAddress(addr) {
|
|
35
|
+
if (!addr) return false;
|
|
36
|
+
// Node reports IPv4-mapped IPv6 like ::ffff:127.0.0.1
|
|
37
|
+
const v4 = addr.startsWith("::ffff:") ? addr.slice(7) : addr;
|
|
38
|
+
if (net.isIPv4(v4)) return v4.startsWith("127.");
|
|
39
|
+
if (net.isIPv6(addr)) return addr === "::1";
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True if a URL hostname (no port) refers to loopback. */
|
|
44
|
+
export function isLoopbackHostname(hostname) {
|
|
45
|
+
if (!hostname) return false;
|
|
46
|
+
const h = hostname.toLowerCase();
|
|
47
|
+
if (LOOPBACK_HOSTNAMES.has(h)) return true;
|
|
48
|
+
if (net.isIPv4(h)) return h.startsWith("127.");
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate a Host header value ("localhost:49400", "127.0.0.1:49400").
|
|
54
|
+
* Rejects anything that is not an explicit loopback host — this is the
|
|
55
|
+
* DNS-rebinding defense and must never be relaxed.
|
|
56
|
+
*/
|
|
57
|
+
export function isAllowedHostHeader(hostHeader) {
|
|
58
|
+
if (!hostHeader) return false;
|
|
59
|
+
try {
|
|
60
|
+
// URL parsing handles [::1]:49400 bracket syntax for us.
|
|
61
|
+
const { hostname } = new URL(`http://${hostHeader}`);
|
|
62
|
+
return isLoopbackHostname(hostname);
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate an Origin header. Only http(s) pages served from loopback may
|
|
70
|
+
* talk to the daemon. Requests with no Origin (curl, same-origin GETs)
|
|
71
|
+
* are allowed at this layer; the token layer still gates everything.
|
|
72
|
+
*/
|
|
73
|
+
export function isAllowedOrigin(origin) {
|
|
74
|
+
if (origin === undefined || origin === null || origin === "") return true;
|
|
75
|
+
if (origin === "null") return false; // sandboxed iframes / file:// pages
|
|
76
|
+
try {
|
|
77
|
+
const u = new URL(origin);
|
|
78
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
|
79
|
+
return isLoopbackHostname(u.hostname);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Generate a session token. Rotates on every daemon start. */
|
|
86
|
+
export function generateToken() {
|
|
87
|
+
return crypto.randomBytes(32).toString("hex");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Constant-time token comparison. */
|
|
91
|
+
export function tokenMatches(expected, presented) {
|
|
92
|
+
if (typeof presented !== "string" || typeof expected !== "string") return false;
|
|
93
|
+
const a = Buffer.from(expected);
|
|
94
|
+
const b = Buffer.from(presented);
|
|
95
|
+
if (a.length !== b.length) return false;
|
|
96
|
+
return crypto.timingSafeEqual(a, b);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Conservative branch/ref name validation, applied *in addition to*
|
|
101
|
+
* `git check-ref-format` (see gitops.js). Belt and suspenders: even if a
|
|
102
|
+
* name passes git's rules, we refuse anything that could read as an
|
|
103
|
+
* option (leading "-"), contain shell-significant bytes, or traverse paths.
|
|
104
|
+
*/
|
|
105
|
+
const REF_SEGMENT = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
106
|
+
export function isSafeRefName(name) {
|
|
107
|
+
if (typeof name !== "string") return false;
|
|
108
|
+
if (name.length === 0 || name.length > 250) return false;
|
|
109
|
+
if (name.startsWith("-")) return false;
|
|
110
|
+
if (name.includes("..") || name.includes("@{")) return false;
|
|
111
|
+
if (/[\s~^:?*\[\\\x00-\x1f\x7f'"`$;&|<>(){}!#%]/.test(name)) return false;
|
|
112
|
+
return name.split("/").every((seg) => seg.length > 0 && REF_SEGMENT.test(seg));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Validate a slug used for worktree directory names. */
|
|
116
|
+
export function isSafeSlug(s) {
|
|
117
|
+
return typeof s === "string" && /^[a-z0-9][a-z0-9-]{0,80}$/.test(s);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Validate a TCP port number from untrusted input. */
|
|
121
|
+
export function isValidPort(p) {
|
|
122
|
+
return Number.isInteger(p) && p >= 1 && p <= 65535;
|
|
123
|
+
}
|