git-watchtower 1.14.16 → 1.14.18
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 +47 -32
- package/package.json +1 -1
- package/src/git/commands.js +40 -0
- package/src/server/static.js +55 -0
package/bin/git-watchtower.js
CHANGED
|
@@ -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
|
-
|
|
2228
|
+
const candidate = path.join(STATIC_DIR, pathname);
|
|
2229
2229
|
|
|
2230
|
-
//
|
|
2231
|
-
//
|
|
2232
|
-
//
|
|
2233
|
-
|
|
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(
|
|
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 =
|
|
2239
|
+
realStaticDir = path.resolve(STATIC_DIR);
|
|
2250
2240
|
}
|
|
2251
|
-
|
|
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
|
-
|
|
2256
|
-
}
|
|
2246
|
+
};
|
|
2257
2247
|
|
|
2258
|
-
|
|
2259
|
-
|
|
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
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2286
|
+
if (!finalPath) { send404(); return; }
|
|
2287
|
+
|
|
2288
|
+
serveFile(res, finalPath, logPath);
|
|
2274
2289
|
});
|
|
2275
2290
|
}
|
|
2276
2291
|
|
package/package.json
CHANGED
package/src/git/commands.js
CHANGED
|
@@ -15,6 +15,43 @@ const FETCH_TIMEOUT = 60000;
|
|
|
15
15
|
// Short timeout for quick local operations (5 seconds)
|
|
16
16
|
const SHORT_TIMEOUT = 5000;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Environment overrides applied to every git child process.
|
|
20
|
+
*
|
|
21
|
+
* LANG=C / LC_ALL=C force git into the C locale so parseable summary
|
|
22
|
+
* lines — e.g. "X files changed, Y insertions(+), Z deletions(-)" —
|
|
23
|
+
* don't get localized into the user's language. Without this,
|
|
24
|
+
* parseDiffStats() returns (0, 0) on systems with a non-English LANG
|
|
25
|
+
* and certain git builds, silently zeroing sparklines and hiding
|
|
26
|
+
* activity from the user.
|
|
27
|
+
*
|
|
28
|
+
* GIT_TERMINAL_PROMPT=0 prevents git from blocking on a credential
|
|
29
|
+
* prompt when auth is needed — we never run interactively, so a prompt
|
|
30
|
+
* would just hang until the timeout fires.
|
|
31
|
+
*
|
|
32
|
+
* Spread `...process.env` first so callers can still override these
|
|
33
|
+
* per-call if needed, and so the child inherits everything else
|
|
34
|
+
* (critically PATH so `git` resolves on Windows).
|
|
35
|
+
*/
|
|
36
|
+
const GIT_ENV_OVERRIDES = {
|
|
37
|
+
LANG: 'C',
|
|
38
|
+
LC_ALL: 'C',
|
|
39
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the child-process env for a git call.
|
|
44
|
+
*
|
|
45
|
+
* Exported for tests. Accepts a base env (defaults to process.env) so
|
|
46
|
+
* tests can pin behaviour without relying on the runner's shell.
|
|
47
|
+
*
|
|
48
|
+
* @param {NodeJS.ProcessEnv} [base] - Base env; defaults to process.env.
|
|
49
|
+
* @returns {NodeJS.ProcessEnv}
|
|
50
|
+
*/
|
|
51
|
+
function buildGitEnv(base) {
|
|
52
|
+
return { ...(base || process.env), ...GIT_ENV_OVERRIDES };
|
|
53
|
+
}
|
|
54
|
+
|
|
18
55
|
/**
|
|
19
56
|
* Execute a git command safely using execFile (no shell).
|
|
20
57
|
* @param {string[]} args - Git arguments as an array (e.g. ['log', '--oneline'])
|
|
@@ -37,6 +74,7 @@ async function execGit(args, options = {}) {
|
|
|
37
74
|
const result = await new Promise((resolve, reject) => {
|
|
38
75
|
execFile('git', args, {
|
|
39
76
|
cwd,
|
|
77
|
+
env: buildGitEnv(),
|
|
40
78
|
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large outputs
|
|
41
79
|
timeout, // kill child process after timeout ms
|
|
42
80
|
killSignal: 'SIGTERM',
|
|
@@ -482,6 +520,8 @@ module.exports = {
|
|
|
482
520
|
deleteLocalBranch,
|
|
483
521
|
getAheadBehind,
|
|
484
522
|
getDiffShortstat,
|
|
523
|
+
buildGitEnv,
|
|
524
|
+
GIT_ENV_OVERRIDES,
|
|
485
525
|
DEFAULT_TIMEOUT,
|
|
486
526
|
FETCH_TIMEOUT,
|
|
487
527
|
};
|
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
|
};
|