git-watchtower 1.14.15 → 1.14.17
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/bin/git-watchtower.js +63 -32
- package/package.json +1 -1
- package/src/server/static.js +55 -0
- package/src/utils/fs-watch.js +84 -0
package/bin/git-watchtower.js
CHANGED
|
@@ -104,6 +104,7 @@ const { WebDashboardServer } = require('../src/server/web');
|
|
|
104
104
|
const { Coordinator, Worker, generateProjectId, getActiveCoordinator, tryAcquireLock, finalizeLock, removeLock, removeSocket, isProcessAlive } = require('../src/server/coordinator');
|
|
105
105
|
const monitorLock = require('../src/utils/monitor-lock');
|
|
106
106
|
const { createPipeErrorHandler } = require('../src/utils/pipe-error');
|
|
107
|
+
const { getRecursiveWatchSupport } = require('../src/utils/fs-watch');
|
|
107
108
|
|
|
108
109
|
const PROJECT_ROOT = process.cwd();
|
|
109
110
|
|
|
@@ -841,7 +842,7 @@ const { parseDiffStats, stash: gitStash, stashPop: gitStashPop } = require('../s
|
|
|
841
842
|
|
|
842
843
|
// Server process command parsing and static server utilities
|
|
843
844
|
const { parseCommand } = require('../src/server/process');
|
|
844
|
-
const { getMimeType, injectLiveReload } = require('../src/server/static');
|
|
845
|
+
const { getMimeType, injectLiveReload, resolveStaticPath } = require('../src/server/static');
|
|
845
846
|
|
|
846
847
|
// State (non-store globals)
|
|
847
848
|
let previousBranchStates = new Map(); // branch name -> commit hash
|
|
@@ -2224,52 +2225,67 @@ function createStaticServer() {
|
|
|
2224
2225
|
}
|
|
2225
2226
|
|
|
2226
2227
|
pathname = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
|
|
2227
|
-
|
|
2228
|
+
const candidate = path.join(STATIC_DIR, pathname);
|
|
2228
2229
|
|
|
2229
|
-
//
|
|
2230
|
-
//
|
|
2231
|
-
//
|
|
2232
|
-
|
|
2233
|
-
let resolvedPath = path.resolve(filePath);
|
|
2234
|
-
try {
|
|
2235
|
-
resolvedPath = fs.realpathSync(resolvedPath);
|
|
2236
|
-
} catch {
|
|
2237
|
-
// File doesn't exist — path.resolve is sufficient since there's no symlink to follow.
|
|
2238
|
-
}
|
|
2230
|
+
// Realpath the static root once per request. A failure here means the
|
|
2231
|
+
// install is broken (missing dir, bad permissions) — fall back to the
|
|
2232
|
+
// resolved-but-not-realpath'd form so the request still gets a 403
|
|
2233
|
+
// from resolveStaticPath rather than crashing.
|
|
2239
2234
|
let realStaticDir;
|
|
2240
2235
|
try {
|
|
2241
|
-
realStaticDir = fs.realpathSync(
|
|
2236
|
+
realStaticDir = fs.realpathSync(path.resolve(STATIC_DIR));
|
|
2242
2237
|
} catch (e) {
|
|
2243
|
-
// STATIC_DIR comes from our own package layout, so a realpath failure
|
|
2244
|
-
// means the install is broken (missing dir, permissions, etc.) — worth
|
|
2245
|
-
// diagnosing. Fall back to the unresolved path so the request still
|
|
2246
|
-
// gets its 403 rather than crashing.
|
|
2247
2238
|
telemetry.captureError(e);
|
|
2248
|
-
realStaticDir =
|
|
2239
|
+
realStaticDir = path.resolve(STATIC_DIR);
|
|
2249
2240
|
}
|
|
2250
|
-
|
|
2241
|
+
|
|
2242
|
+
const send403 = () => {
|
|
2251
2243
|
res.writeHead(403, { 'Content-Type': 'text/html' });
|
|
2252
2244
|
res.end('<h1>403 Forbidden</h1>');
|
|
2253
2245
|
addServerLog(`GET ${logPath} → 403 (path traversal blocked)`, true);
|
|
2254
|
-
|
|
2255
|
-
}
|
|
2246
|
+
};
|
|
2256
2247
|
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2248
|
+
const send404 = () => {
|
|
2249
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
2250
|
+
res.end('<h1>404 Not Found</h1>');
|
|
2251
|
+
addServerLog(`GET ${logPath} → 404`, true);
|
|
2252
|
+
};
|
|
2260
2253
|
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2254
|
+
// All downstream reads go through `finalPath` — the realpath-resolved
|
|
2255
|
+
// target from resolveStaticPath(). Using the pre-realpath candidate
|
|
2256
|
+
// opens a TOCTOU window where a symlink inside STATIC_DIR could be
|
|
2257
|
+
// swapped between the containment check and the fs.readFile to point
|
|
2258
|
+
// outside the root.
|
|
2259
|
+
let finalPath = null;
|
|
2260
|
+
|
|
2261
|
+
const initial = resolveStaticPath(candidate, realStaticDir);
|
|
2262
|
+
if (initial.status === 'forbidden') { send403(); return; }
|
|
2263
|
+
if (initial.status === 'ok') {
|
|
2264
|
+
// Directory requests resolve the index.html *inside the realpath'd*
|
|
2265
|
+
// directory. Without this second realpath+check, a symlinked dir
|
|
2266
|
+
// whose target pointed outside would serve its attacker-controlled
|
|
2267
|
+
// index.html through our root check.
|
|
2268
|
+
if (fs.statSync(initial.path).isDirectory()) {
|
|
2269
|
+
const indexResult = resolveStaticPath(
|
|
2270
|
+
path.join(initial.path, 'index.html'),
|
|
2271
|
+
realStaticDir,
|
|
2272
|
+
);
|
|
2273
|
+
if (indexResult.status === 'forbidden') { send403(); return; }
|
|
2274
|
+
if (indexResult.status === 'ok') finalPath = indexResult.path;
|
|
2275
|
+
// 'missing' → fall through to 404 (no .html fallback for dirs)
|
|
2264
2276
|
} else {
|
|
2265
|
-
|
|
2266
|
-
res.end('<h1>404 Not Found</h1>');
|
|
2267
|
-
addServerLog(`GET ${logPath} → 404`, true);
|
|
2268
|
-
return;
|
|
2277
|
+
finalPath = initial.path;
|
|
2269
2278
|
}
|
|
2279
|
+
} else {
|
|
2280
|
+
// 'missing' — try the `foo` → `foo.html` convenience fallback.
|
|
2281
|
+
const htmlFallback = resolveStaticPath(candidate + '.html', realStaticDir);
|
|
2282
|
+
if (htmlFallback.status === 'forbidden') { send403(); return; }
|
|
2283
|
+
if (htmlFallback.status === 'ok') finalPath = htmlFallback.path;
|
|
2270
2284
|
}
|
|
2271
2285
|
|
|
2272
|
-
|
|
2286
|
+
if (!finalPath) { send404(); return; }
|
|
2287
|
+
|
|
2288
|
+
serveFile(res, finalPath, logPath);
|
|
2273
2289
|
});
|
|
2274
2290
|
}
|
|
2275
2291
|
|
|
@@ -2290,6 +2306,21 @@ function setupFileWatcher() {
|
|
|
2290
2306
|
addLog(`Loaded ${ignorePatterns.length} ignore patterns from .gitignore`, 'info');
|
|
2291
2307
|
}
|
|
2292
2308
|
|
|
2309
|
+
// Before calling fs.watch, surface any known incompatibility explicitly —
|
|
2310
|
+
// the generic catch below would otherwise report a confusing
|
|
2311
|
+
// "Could not set up file watcher: ..." for the well-known case of a
|
|
2312
|
+
// forced install on Node <20 Linux, where recursive watching is
|
|
2313
|
+
// unreliable. A clear message points the user at the real fix
|
|
2314
|
+
// (upgrade Node) instead of making them debug a live-reload that
|
|
2315
|
+
// appears to work but silently ignores subdirectory edits.
|
|
2316
|
+
const support = getRecursiveWatchSupport();
|
|
2317
|
+
if (!support.supported) {
|
|
2318
|
+
addLog(
|
|
2319
|
+
`Live reload may miss nested file changes: ${support.reason}`,
|
|
2320
|
+
'error',
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2293
2324
|
try {
|
|
2294
2325
|
fileWatcher = fs.watch(STATIC_DIR, { recursive: true }, (eventType, filename) => {
|
|
2295
2326
|
if (!filename) return;
|
package/package.json
CHANGED
package/src/server/static.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* @module server/static
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* MIME type mapping by file extension.
|
|
8
11
|
*/
|
|
@@ -63,9 +66,61 @@ function injectLiveReload(html) {
|
|
|
63
66
|
return html;
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a static-server request candidate to a safe, realpath'd path
|
|
71
|
+
* inside the static root.
|
|
72
|
+
*
|
|
73
|
+
* The critical property: the returned `path` is the realpath-resolved
|
|
74
|
+
* target, so all downstream file reads operate on the same bytes the
|
|
75
|
+
* containment check approved. Previously the server would realpath the
|
|
76
|
+
* candidate for the 403 check but then read the pre-realpath path — a
|
|
77
|
+
* TOCTOU window where a symlink inside the static dir could be swapped
|
|
78
|
+
* between check and read to point outside the root.
|
|
79
|
+
*
|
|
80
|
+
* Contract:
|
|
81
|
+
* - 'ok' → the candidate exists inside `realStaticDir`; serve `path`.
|
|
82
|
+
* - 'missing' → the candidate resolves (or would resolve) inside the
|
|
83
|
+
* root but doesn't exist. Callers can try an extension
|
|
84
|
+
* fallback (e.g. `.html`) or return 404.
|
|
85
|
+
* - 'forbidden' → the candidate resolves outside the root, via symlink
|
|
86
|
+
* or via path traversal like `../../etc/passwd`. Return
|
|
87
|
+
* 403; do not attempt fallbacks, since the attacker
|
|
88
|
+
* controls the request.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} candidate - Unresolved absolute path (e.g.
|
|
91
|
+
* `path.join(STATIC_DIR, pathname)`).
|
|
92
|
+
* @param {string} realStaticDir - The realpath'd absolute path of the
|
|
93
|
+
* static root. Callers should cache this per-request.
|
|
94
|
+
* @returns {{ status: 'ok', path: string } | { status: 'missing' } | { status: 'forbidden' }}
|
|
95
|
+
*/
|
|
96
|
+
function resolveStaticPath(candidate, realStaticDir) {
|
|
97
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
98
|
+
let realPath;
|
|
99
|
+
let exists;
|
|
100
|
+
try {
|
|
101
|
+
realPath = fs.realpathSync(resolvedCandidate);
|
|
102
|
+
exists = true;
|
|
103
|
+
} catch (_) {
|
|
104
|
+
// Doesn't exist. Fall back to the normalized form so path-traversal
|
|
105
|
+
// attempts against non-existent files (`../../etc/passwd`) still get
|
|
106
|
+
// rejected with 403 instead of silently 404'ing.
|
|
107
|
+
realPath = resolvedCandidate;
|
|
108
|
+
exists = false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (realPath !== realStaticDir && !realPath.startsWith(realStaticDir + path.sep)) {
|
|
112
|
+
return { status: 'forbidden' };
|
|
113
|
+
}
|
|
114
|
+
if (!exists) {
|
|
115
|
+
return { status: 'missing' };
|
|
116
|
+
}
|
|
117
|
+
return { status: 'ok', path: realPath };
|
|
118
|
+
}
|
|
119
|
+
|
|
66
120
|
module.exports = {
|
|
67
121
|
MIME_TYPES,
|
|
68
122
|
getMimeType,
|
|
69
123
|
LIVE_RELOAD_SCRIPT,
|
|
70
124
|
injectLiveReload,
|
|
125
|
+
resolveStaticPath,
|
|
71
126
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs.watch recursive-option support detection.
|
|
3
|
+
*
|
|
4
|
+
* `fs.watch(..., { recursive: true })` was not reliable on Linux before
|
|
5
|
+
* Node 20 — depending on the point release it either threw
|
|
6
|
+
* ERR_FEATURE_UNAVAILABLE_ON_PLATFORM or silently ignored the flag and
|
|
7
|
+
* watched only the top-level directory, leaving subdirectory changes
|
|
8
|
+
* undetected. macOS and Windows have supported recursive watching for
|
|
9
|
+
* much longer.
|
|
10
|
+
*
|
|
11
|
+
* git-watchtower declares engines.node >=20 in package.json, but users
|
|
12
|
+
* can bypass that with `npm install --force`. When they do, the static-
|
|
13
|
+
* server live-reload watcher needs to warn clearly rather than appear
|
|
14
|
+
* to work but silently miss edits in nested directories.
|
|
15
|
+
*
|
|
16
|
+
* @module utils/fs-watch
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse `process.version` ("v20.11.1") into a numeric major version.
|
|
23
|
+
* Exported so tests can drive edge cases (malformed strings, pre-releases).
|
|
24
|
+
*
|
|
25
|
+
* @param {string} versionString
|
|
26
|
+
* @returns {number} major version, or NaN if unparseable
|
|
27
|
+
*/
|
|
28
|
+
function parseMajor(versionString) {
|
|
29
|
+
if (typeof versionString !== 'string') return NaN;
|
|
30
|
+
const match = /^v?(\d+)\./.exec(versionString);
|
|
31
|
+
if (!match) return NaN;
|
|
32
|
+
return parseInt(match[1], 10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether fs.watch recursive mode is reliably supported on the
|
|
37
|
+
* current Node/platform combination.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} [env] - Injected for tests.
|
|
40
|
+
* @param {string} [env.version] - e.g. process.version
|
|
41
|
+
* @param {string} [env.platform] - e.g. process.platform
|
|
42
|
+
* @returns {{ supported: boolean, reason: string | null }}
|
|
43
|
+
* reason is a short, user-facing explanation when !supported.
|
|
44
|
+
*/
|
|
45
|
+
function getRecursiveWatchSupport(env = {}) {
|
|
46
|
+
const version = env.version !== undefined ? env.version : process.version;
|
|
47
|
+
const platform = env.platform !== undefined ? env.platform : process.platform;
|
|
48
|
+
|
|
49
|
+
// macOS and Windows have supported recursive watching since well before
|
|
50
|
+
// any Node version we care about.
|
|
51
|
+
if (platform === 'darwin' || platform === 'win32') {
|
|
52
|
+
return { supported: true, reason: null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (platform === 'linux') {
|
|
56
|
+
const major = parseMajor(version);
|
|
57
|
+
if (Number.isNaN(major)) {
|
|
58
|
+
return {
|
|
59
|
+
supported: false,
|
|
60
|
+
reason: `could not parse Node version "${version}"`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (major < 20) {
|
|
64
|
+
return {
|
|
65
|
+
supported: false,
|
|
66
|
+
reason:
|
|
67
|
+
`Node ${version} on Linux does not reliably support fs.watch({ recursive: true }); ` +
|
|
68
|
+
'upgrade to Node >=20 (see package.json engines).',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { supported: true, reason: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Unknown platform (AIX, FreeBSD, etc.). Don't claim support we can't verify.
|
|
75
|
+
return {
|
|
76
|
+
supported: false,
|
|
77
|
+
reason: `recursive fs.watch support is not verified on platform "${platform}"`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
parseMajor,
|
|
83
|
+
getRecursiveWatchSupport,
|
|
84
|
+
};
|