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.
@@ -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
- let filePath = path.join(STATIC_DIR, pathname);
2228
+ const candidate = path.join(STATIC_DIR, pathname);
2228
2229
 
2229
- // Security: ensure resolved path stays within STATIC_DIR to prevent path traversal.
2230
- // Use realpath to follow symlinks without this, a symlink inside STATIC_DIR
2231
- // pointing outside would bypass the startsWith check.
2232
- const resolvedStaticDir = path.resolve(STATIC_DIR);
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(resolvedStaticDir);
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 = resolvedStaticDir;
2239
+ realStaticDir = path.resolve(STATIC_DIR);
2249
2240
  }
2250
- if (!resolvedPath.startsWith(realStaticDir + path.sep) && resolvedPath !== realStaticDir) {
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
- return;
2255
- }
2246
+ };
2256
2247
 
2257
- if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
2258
- filePath = path.join(filePath, 'index.html');
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
- if (!fs.existsSync(filePath)) {
2262
- if (fs.existsSync(filePath + '.html')) {
2263
- filePath = filePath + '.html';
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
- res.writeHead(404, { 'Content-Type': 'text/html' });
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
- serveFile(res, filePath, logPath);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.14.15",
3
+ "version": "1.14.17",
4
4
  "description": "Terminal-based Git branch monitor with activity sparklines and optional dev server with live reload",
5
5
  "main": "bin/git-watchtower.js",
6
6
  "bin": {
@@ -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
+ };