git-watchtower 1.14.16 → 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.
@@ -842,7 +842,7 @@ const { parseDiffStats, stash: gitStash, stashPop: gitStashPop } = require('../s
842
842
 
843
843
  // Server process command parsing and static server utilities
844
844
  const { parseCommand } = require('../src/server/process');
845
- const { getMimeType, injectLiveReload } = require('../src/server/static');
845
+ const { getMimeType, injectLiveReload, resolveStaticPath } = require('../src/server/static');
846
846
 
847
847
  // State (non-store globals)
848
848
  let previousBranchStates = new Map(); // branch name -> commit hash
@@ -2225,52 +2225,67 @@ function createStaticServer() {
2225
2225
  }
2226
2226
 
2227
2227
  pathname = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
2228
- let filePath = path.join(STATIC_DIR, pathname);
2228
+ const candidate = path.join(STATIC_DIR, pathname);
2229
2229
 
2230
- // Security: ensure resolved path stays within STATIC_DIR to prevent path traversal.
2231
- // Use realpath to follow symlinks without this, a symlink inside STATIC_DIR
2232
- // pointing outside would bypass the startsWith check.
2233
- const resolvedStaticDir = path.resolve(STATIC_DIR);
2234
- let resolvedPath = path.resolve(filePath);
2235
- try {
2236
- resolvedPath = fs.realpathSync(resolvedPath);
2237
- } catch {
2238
- // File doesn't exist — path.resolve is sufficient since there's no symlink to follow.
2239
- }
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.
2240
2234
  let realStaticDir;
2241
2235
  try {
2242
- realStaticDir = fs.realpathSync(resolvedStaticDir);
2236
+ realStaticDir = fs.realpathSync(path.resolve(STATIC_DIR));
2243
2237
  } catch (e) {
2244
- // STATIC_DIR comes from our own package layout, so a realpath failure
2245
- // means the install is broken (missing dir, permissions, etc.) — worth
2246
- // diagnosing. Fall back to the unresolved path so the request still
2247
- // gets its 403 rather than crashing.
2248
2238
  telemetry.captureError(e);
2249
- realStaticDir = resolvedStaticDir;
2239
+ realStaticDir = path.resolve(STATIC_DIR);
2250
2240
  }
2251
- if (!resolvedPath.startsWith(realStaticDir + path.sep) && resolvedPath !== realStaticDir) {
2241
+
2242
+ const send403 = () => {
2252
2243
  res.writeHead(403, { 'Content-Type': 'text/html' });
2253
2244
  res.end('<h1>403 Forbidden</h1>');
2254
2245
  addServerLog(`GET ${logPath} → 403 (path traversal blocked)`, true);
2255
- return;
2256
- }
2246
+ };
2257
2247
 
2258
- if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
2259
- filePath = path.join(filePath, 'index.html');
2260
- }
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
+ };
2261
2253
 
2262
- if (!fs.existsSync(filePath)) {
2263
- if (fs.existsSync(filePath + '.html')) {
2264
- 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)
2265
2276
  } else {
2266
- res.writeHead(404, { 'Content-Type': 'text/html' });
2267
- res.end('<h1>404 Not Found</h1>');
2268
- addServerLog(`GET ${logPath} → 404`, true);
2269
- return;
2277
+ finalPath = initial.path;
2270
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;
2271
2284
  }
2272
2285
 
2273
- serveFile(res, filePath, logPath);
2286
+ if (!finalPath) { send404(); return; }
2287
+
2288
+ serveFile(res, finalPath, logPath);
2274
2289
  });
2275
2290
  }
2276
2291
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-watchtower",
3
- "version": "1.14.16",
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
  };