@poolzin/pool-bot 2026.3.4 → 2026.3.6
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 +10 -0
- package/assets/pool-bot-icon-dark.png +0 -0
- package/assets/pool-bot-logo-1.png +0 -0
- package/assets/pool-bot-mascot.png +0 -0
- package/dist/agents/pi-embedded-runner/tool-result-truncation.js +62 -7
- package/dist/agents/poolbot-tools.js +12 -0
- package/dist/agents/session-write-lock.js +93 -8
- package/dist/agents/tools/pdf-native-providers.js +102 -0
- package/dist/agents/tools/pdf-tool.helpers.js +86 -0
- package/dist/agents/tools/pdf-tool.js +508 -0
- package/dist/build-info.json +3 -3
- package/dist/cron/normalize.js +3 -0
- package/dist/cron/service/jobs.js +48 -0
- package/dist/gateway/protocol/schema/cron.js +3 -0
- package/dist/gateway/server-channels.js +99 -14
- package/dist/gateway/server-cron.js +89 -0
- package/dist/gateway/server-health-probes.js +55 -0
- package/dist/gateway/server-http.js +5 -0
- package/dist/hooks/bundled/session-memory/handler.js +8 -2
- package/dist/infra/abort-signal.js +12 -0
- package/dist/infra/boundary-file-read.js +118 -0
- package/dist/infra/boundary-path.js +594 -0
- package/dist/infra/file-identity.js +12 -0
- package/dist/infra/fs-safe.js +377 -12
- package/dist/infra/hardlink-guards.js +30 -0
- package/dist/infra/json-utf8-bytes.js +8 -0
- package/dist/infra/net/fetch-guard.js +63 -13
- package/dist/infra/net/proxy-env.js +17 -0
- package/dist/infra/net/ssrf.js +74 -272
- package/dist/infra/path-alias-guards.js +21 -0
- package/dist/infra/path-guards.js +13 -1
- package/dist/infra/ports-probe.js +19 -0
- package/dist/infra/prototype-keys.js +4 -0
- package/dist/infra/restart-stale-pids.js +254 -0
- package/dist/infra/safe-open-sync.js +71 -0
- package/dist/infra/secure-random.js +7 -0
- package/dist/media/ffmpeg-limits.js +4 -0
- package/dist/media/input-files.js +6 -2
- package/dist/media/temp-files.js +12 -0
- package/dist/memory/embedding-chunk-limits.js +5 -2
- package/dist/memory/embeddings-ollama.js +91 -138
- package/dist/memory/embeddings-remote-fetch.js +11 -10
- package/dist/memory/embeddings.js +25 -9
- package/dist/memory/manager-embedding-ops.js +1 -1
- package/dist/memory/post-json.js +23 -0
- package/dist/memory/qmd-manager.js +272 -77
- package/dist/memory/remote-http.js +33 -0
- package/dist/plugin-sdk/windows-spawn.js +214 -0
- package/dist/shared/net/ip-test-fixtures.js +1 -0
- package/dist/shared/net/ip.js +303 -0
- package/dist/shared/net/ipv4.js +8 -11
- package/dist/shared/pid-alive.js +59 -2
- package/dist/test-helpers/ssrf.js +13 -0
- package/dist/tui/tui.js +9 -4
- package/dist/utils/fetch-timeout.js +12 -1
- package/docs/adr/003-feature-gap-analysis.md +112 -0
- package/package.json +10 -4
package/dist/infra/fs-safe.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { constants as fsConstants } from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
4
|
-
import {
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { logWarn } from "../logger.js";
|
|
8
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
9
|
+
import { expandHomePrefix } from "./home-dir.js";
|
|
10
|
+
import { assertNoPathAliasEscape } from "./path-alias-guards.js";
|
|
11
|
+
import { hasNodeErrorCode, isNotFoundPathError, isPathInside, isSymlinkOpenError, } from "./path-guards.js";
|
|
5
12
|
export class SafeOpenError extends Error {
|
|
6
13
|
code;
|
|
7
14
|
constructor(code, message, options) {
|
|
@@ -12,8 +19,37 @@ export class SafeOpenError extends Error {
|
|
|
12
19
|
}
|
|
13
20
|
const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants;
|
|
14
21
|
const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
22
|
+
const OPEN_WRITE_EXISTING_FLAGS = fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
23
|
+
const OPEN_WRITE_CREATE_FLAGS = fsConstants.O_WRONLY |
|
|
24
|
+
fsConstants.O_CREAT |
|
|
25
|
+
fsConstants.O_EXCL |
|
|
26
|
+
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
|
15
27
|
const ensureTrailingSep = (value) => (value.endsWith(path.sep) ? value : value + path.sep);
|
|
16
|
-
async function
|
|
28
|
+
async function expandRelativePathWithHome(relativePath) {
|
|
29
|
+
let home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
30
|
+
try {
|
|
31
|
+
home = await fs.realpath(home);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// If the home dir cannot be canonicalized, keep lexical expansion behavior.
|
|
35
|
+
}
|
|
36
|
+
return expandHomePrefix(relativePath, { home });
|
|
37
|
+
}
|
|
38
|
+
async function openVerifiedLocalFile(filePath, options) {
|
|
39
|
+
// Reject directories before opening so we never surface EISDIR to callers (e.g. tool
|
|
40
|
+
// results that get sent to messaging channels). See openclaw/openclaw#31186.
|
|
41
|
+
try {
|
|
42
|
+
const preStat = await fs.lstat(filePath);
|
|
43
|
+
if (preStat.isDirectory()) {
|
|
44
|
+
throw new SafeOpenError("not-file", "not a file");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof SafeOpenError) {
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
// ENOENT and other lstat errors: fall through and let fs.open handle.
|
|
52
|
+
}
|
|
17
53
|
let handle;
|
|
18
54
|
try {
|
|
19
55
|
handle = await fs.open(filePath, OPEN_READ_FLAGS);
|
|
@@ -25,6 +61,10 @@ async function openVerifiedLocalFile(filePath) {
|
|
|
25
61
|
if (isSymlinkOpenError(err)) {
|
|
26
62
|
throw new SafeOpenError("symlink", "symlink open blocked", { cause: err });
|
|
27
63
|
}
|
|
64
|
+
// Defensive: if open still throws EISDIR (e.g. race), sanitize so it never leaks.
|
|
65
|
+
if (hasNodeErrorCode(err, "EISDIR")) {
|
|
66
|
+
throw new SafeOpenError("not-file", "not a file");
|
|
67
|
+
}
|
|
28
68
|
throw err;
|
|
29
69
|
}
|
|
30
70
|
try {
|
|
@@ -35,12 +75,18 @@ async function openVerifiedLocalFile(filePath) {
|
|
|
35
75
|
if (!stat.isFile()) {
|
|
36
76
|
throw new SafeOpenError("not-file", "not a file");
|
|
37
77
|
}
|
|
38
|
-
if (
|
|
78
|
+
if (options?.rejectHardlinks && stat.nlink > 1) {
|
|
79
|
+
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
|
80
|
+
}
|
|
81
|
+
if (!sameFileIdentity(stat, lstat)) {
|
|
39
82
|
throw new SafeOpenError("path-mismatch", "path changed during read");
|
|
40
83
|
}
|
|
41
84
|
const realPath = await fs.realpath(filePath);
|
|
42
85
|
const realStat = await fs.stat(realPath);
|
|
43
|
-
if (
|
|
86
|
+
if (options?.rejectHardlinks && realStat.nlink > 1) {
|
|
87
|
+
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
|
88
|
+
}
|
|
89
|
+
if (!sameFileIdentity(stat, realStat)) {
|
|
44
90
|
throw new SafeOpenError("path-mismatch", "path mismatch");
|
|
45
91
|
}
|
|
46
92
|
return { handle, realPath, stat };
|
|
@@ -56,7 +102,7 @@ async function openVerifiedLocalFile(filePath) {
|
|
|
56
102
|
throw err;
|
|
57
103
|
}
|
|
58
104
|
}
|
|
59
|
-
|
|
105
|
+
async function resolvePathWithinRoot(params) {
|
|
60
106
|
let rootReal;
|
|
61
107
|
try {
|
|
62
108
|
rootReal = await fs.realpath(params.rootDir);
|
|
@@ -68,10 +114,15 @@ export async function openFileWithinRoot(params) {
|
|
|
68
114
|
throw err;
|
|
69
115
|
}
|
|
70
116
|
const rootWithSep = ensureTrailingSep(rootReal);
|
|
71
|
-
const
|
|
117
|
+
const expanded = await expandRelativePathWithHome(params.relativePath);
|
|
118
|
+
const resolved = path.resolve(rootWithSep, expanded);
|
|
72
119
|
if (!isPathInside(rootWithSep, resolved)) {
|
|
73
|
-
throw new SafeOpenError("
|
|
120
|
+
throw new SafeOpenError("outside-workspace", "file is outside workspace root");
|
|
74
121
|
}
|
|
122
|
+
return { rootReal, rootWithSep, resolved };
|
|
123
|
+
}
|
|
124
|
+
export async function openFileWithinRoot(params) {
|
|
125
|
+
const { rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
|
75
126
|
let opened;
|
|
76
127
|
try {
|
|
77
128
|
opened = await openVerifiedLocalFile(resolved);
|
|
@@ -87,22 +138,336 @@ export async function openFileWithinRoot(params) {
|
|
|
87
138
|
}
|
|
88
139
|
throw err;
|
|
89
140
|
}
|
|
141
|
+
if (params.rejectHardlinks !== false && opened.stat.nlink > 1) {
|
|
142
|
+
await opened.handle.close().catch(() => { });
|
|
143
|
+
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
|
144
|
+
}
|
|
90
145
|
if (!isPathInside(rootWithSep, opened.realPath)) {
|
|
91
146
|
await opened.handle.close().catch(() => { });
|
|
92
|
-
throw new SafeOpenError("
|
|
147
|
+
throw new SafeOpenError("outside-workspace", "file is outside workspace root");
|
|
93
148
|
}
|
|
94
149
|
return opened;
|
|
95
150
|
}
|
|
151
|
+
export async function readFileWithinRoot(params) {
|
|
152
|
+
const opened = await openFileWithinRoot({
|
|
153
|
+
rootDir: params.rootDir,
|
|
154
|
+
relativePath: params.relativePath,
|
|
155
|
+
rejectHardlinks: params.rejectHardlinks,
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
await opened.handle.close().catch(() => { });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export async function readPathWithinRoot(params) {
|
|
165
|
+
const rootDir = path.resolve(params.rootDir);
|
|
166
|
+
const candidatePath = path.isAbsolute(params.filePath)
|
|
167
|
+
? path.resolve(params.filePath)
|
|
168
|
+
: path.resolve(rootDir, params.filePath);
|
|
169
|
+
const relativePath = path.relative(rootDir, candidatePath);
|
|
170
|
+
return await readFileWithinRoot({
|
|
171
|
+
rootDir,
|
|
172
|
+
relativePath,
|
|
173
|
+
rejectHardlinks: params.rejectHardlinks,
|
|
174
|
+
maxBytes: params.maxBytes,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
export function createRootScopedReadFile(params) {
|
|
178
|
+
const rootDir = path.resolve(params.rootDir);
|
|
179
|
+
return async (filePath) => {
|
|
180
|
+
const safeRead = await readPathWithinRoot({
|
|
181
|
+
rootDir,
|
|
182
|
+
filePath,
|
|
183
|
+
rejectHardlinks: params.rejectHardlinks,
|
|
184
|
+
maxBytes: params.maxBytes,
|
|
185
|
+
});
|
|
186
|
+
return safeRead.buffer;
|
|
187
|
+
};
|
|
188
|
+
}
|
|
96
189
|
export async function readLocalFileSafely(params) {
|
|
97
190
|
const opened = await openVerifiedLocalFile(params.filePath);
|
|
98
191
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
192
|
+
return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes });
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await opened.handle.close().catch(() => { });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function readOpenedFileSafely(params) {
|
|
199
|
+
if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) {
|
|
200
|
+
throw new SafeOpenError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`);
|
|
201
|
+
}
|
|
202
|
+
const buffer = await params.opened.handle.readFile();
|
|
203
|
+
return {
|
|
204
|
+
buffer,
|
|
205
|
+
realPath: params.opened.realPath,
|
|
206
|
+
stat: params.opened.stat,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function emitWriteBoundaryWarning(reason) {
|
|
210
|
+
logWarn(`security: fs-safe write boundary warning (${reason})`);
|
|
211
|
+
}
|
|
212
|
+
function buildAtomicWriteTempPath(targetPath) {
|
|
213
|
+
const dir = path.dirname(targetPath);
|
|
214
|
+
const base = path.basename(targetPath);
|
|
215
|
+
return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`);
|
|
216
|
+
}
|
|
217
|
+
async function writeTempFileForAtomicReplace(params) {
|
|
218
|
+
const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode);
|
|
219
|
+
try {
|
|
220
|
+
if (typeof params.data === "string") {
|
|
221
|
+
await tempHandle.writeFile(params.data, params.encoding ?? "utf8");
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
await tempHandle.writeFile(params.data);
|
|
225
|
+
}
|
|
226
|
+
return await tempHandle.stat();
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
await tempHandle.close().catch(() => { });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function verifyAtomicWriteResult(params) {
|
|
233
|
+
const rootReal = await fs.realpath(params.rootDir);
|
|
234
|
+
const rootWithSep = ensureTrailingSep(rootReal);
|
|
235
|
+
const opened = await openVerifiedLocalFile(params.targetPath, { rejectHardlinks: true });
|
|
236
|
+
try {
|
|
237
|
+
if (!sameFileIdentity(opened.stat, params.expectedStat)) {
|
|
238
|
+
throw new SafeOpenError("path-mismatch", "path changed during write");
|
|
239
|
+
}
|
|
240
|
+
if (!isPathInside(rootWithSep, opened.realPath)) {
|
|
241
|
+
throw new SafeOpenError("outside-workspace", "file is outside workspace root");
|
|
101
242
|
}
|
|
102
|
-
const buffer = await opened.handle.readFile();
|
|
103
|
-
return { buffer, realPath: opened.realPath, stat: opened.stat };
|
|
104
243
|
}
|
|
105
244
|
finally {
|
|
106
245
|
await opened.handle.close().catch(() => { });
|
|
107
246
|
}
|
|
108
247
|
}
|
|
248
|
+
export async function resolveOpenedFileRealPathForHandle(handle, ioPath) {
|
|
249
|
+
try {
|
|
250
|
+
return await fs.realpath(ioPath);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
if (!isNotFoundPathError(err)) {
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const fdCandidates = process.platform === "linux"
|
|
258
|
+
? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
|
|
259
|
+
: process.platform === "win32"
|
|
260
|
+
? []
|
|
261
|
+
: [`/dev/fd/${handle.fd}`];
|
|
262
|
+
for (const fdPath of fdCandidates) {
|
|
263
|
+
try {
|
|
264
|
+
return await fs.realpath(fdPath);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// try next fd path
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
throw new SafeOpenError("path-mismatch", "unable to resolve opened file path");
|
|
271
|
+
}
|
|
272
|
+
export async function openWritableFileWithinRoot(params) {
|
|
273
|
+
const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
|
274
|
+
try {
|
|
275
|
+
await assertNoPathAliasEscape({
|
|
276
|
+
absolutePath: resolved,
|
|
277
|
+
rootPath: rootReal,
|
|
278
|
+
boundaryLabel: "root",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
throw new SafeOpenError("invalid-path", "path alias escape blocked", { cause: err });
|
|
283
|
+
}
|
|
284
|
+
if (params.mkdir !== false) {
|
|
285
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
286
|
+
}
|
|
287
|
+
let ioPath = resolved;
|
|
288
|
+
try {
|
|
289
|
+
const resolvedRealPath = await fs.realpath(resolved);
|
|
290
|
+
if (!isPathInside(rootWithSep, resolvedRealPath)) {
|
|
291
|
+
throw new SafeOpenError("outside-workspace", "file is outside workspace root");
|
|
292
|
+
}
|
|
293
|
+
ioPath = resolvedRealPath;
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
if (err instanceof SafeOpenError) {
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
if (!isNotFoundPathError(err)) {
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const fileMode = params.mode ?? 0o600;
|
|
304
|
+
let handle;
|
|
305
|
+
let createdForWrite = false;
|
|
306
|
+
try {
|
|
307
|
+
try {
|
|
308
|
+
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, fileMode);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (!isNotFoundPathError(err)) {
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, fileMode);
|
|
315
|
+
createdForWrite = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
if (isNotFoundPathError(err)) {
|
|
320
|
+
throw new SafeOpenError("not-found", "file not found");
|
|
321
|
+
}
|
|
322
|
+
if (isSymlinkOpenError(err)) {
|
|
323
|
+
throw new SafeOpenError("invalid-path", "symlink open blocked", { cause: err });
|
|
324
|
+
}
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
let openedRealPath = null;
|
|
328
|
+
try {
|
|
329
|
+
const stat = await handle.stat();
|
|
330
|
+
if (!stat.isFile()) {
|
|
331
|
+
throw new SafeOpenError("invalid-path", "path is not a regular file under root");
|
|
332
|
+
}
|
|
333
|
+
if (stat.nlink > 1) {
|
|
334
|
+
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const lstat = await fs.lstat(ioPath);
|
|
338
|
+
if (lstat.isSymbolicLink() || !lstat.isFile()) {
|
|
339
|
+
throw new SafeOpenError("invalid-path", "path is not a regular file under root");
|
|
340
|
+
}
|
|
341
|
+
if (!sameFileIdentity(stat, lstat)) {
|
|
342
|
+
throw new SafeOpenError("path-mismatch", "path changed during write");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
if (!isNotFoundPathError(err)) {
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const realPath = await resolveOpenedFileRealPathForHandle(handle, ioPath);
|
|
351
|
+
openedRealPath = realPath;
|
|
352
|
+
const realStat = await fs.stat(realPath);
|
|
353
|
+
if (!sameFileIdentity(stat, realStat)) {
|
|
354
|
+
throw new SafeOpenError("path-mismatch", "path mismatch");
|
|
355
|
+
}
|
|
356
|
+
if (realStat.nlink > 1) {
|
|
357
|
+
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
|
358
|
+
}
|
|
359
|
+
if (!isPathInside(rootWithSep, realPath)) {
|
|
360
|
+
throw new SafeOpenError("outside-workspace", "file is outside workspace root");
|
|
361
|
+
}
|
|
362
|
+
// Truncate only after boundary and identity checks complete. This avoids
|
|
363
|
+
// irreversible side effects if a symlink target changes before validation.
|
|
364
|
+
if (params.truncateExisting !== false && !createdForWrite) {
|
|
365
|
+
await handle.truncate(0);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
handle,
|
|
369
|
+
createdForWrite,
|
|
370
|
+
openedRealPath: realPath,
|
|
371
|
+
openedStat: stat,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
const cleanupCreatedPath = createdForWrite && err instanceof SafeOpenError;
|
|
376
|
+
const cleanupPath = openedRealPath ?? ioPath;
|
|
377
|
+
await handle.close().catch(() => { });
|
|
378
|
+
if (cleanupCreatedPath) {
|
|
379
|
+
await fs.rm(cleanupPath, { force: true }).catch(() => { });
|
|
380
|
+
}
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
export async function writeFileWithinRoot(params) {
|
|
385
|
+
const target = await openWritableFileWithinRoot({
|
|
386
|
+
rootDir: params.rootDir,
|
|
387
|
+
relativePath: params.relativePath,
|
|
388
|
+
mkdir: params.mkdir,
|
|
389
|
+
truncateExisting: false,
|
|
390
|
+
});
|
|
391
|
+
const destinationPath = target.openedRealPath;
|
|
392
|
+
const targetMode = target.openedStat.mode & 0o777;
|
|
393
|
+
await target.handle.close().catch(() => { });
|
|
394
|
+
let tempPath = null;
|
|
395
|
+
try {
|
|
396
|
+
tempPath = buildAtomicWriteTempPath(destinationPath);
|
|
397
|
+
const writtenStat = await writeTempFileForAtomicReplace({
|
|
398
|
+
tempPath,
|
|
399
|
+
data: params.data,
|
|
400
|
+
encoding: params.encoding,
|
|
401
|
+
mode: targetMode || 0o600,
|
|
402
|
+
});
|
|
403
|
+
await fs.rename(tempPath, destinationPath);
|
|
404
|
+
tempPath = null;
|
|
405
|
+
try {
|
|
406
|
+
await verifyAtomicWriteResult({
|
|
407
|
+
rootDir: params.rootDir,
|
|
408
|
+
targetPath: destinationPath,
|
|
409
|
+
expectedStat: writtenStat,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`);
|
|
414
|
+
throw err;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
if (tempPath) {
|
|
419
|
+
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
export async function copyFileWithinRoot(params) {
|
|
424
|
+
const source = await openVerifiedLocalFile(params.sourcePath, {
|
|
425
|
+
rejectHardlinks: params.rejectSourceHardlinks,
|
|
426
|
+
});
|
|
427
|
+
if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) {
|
|
428
|
+
await source.handle.close().catch(() => { });
|
|
429
|
+
throw new SafeOpenError("too-large", `file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`);
|
|
430
|
+
}
|
|
431
|
+
let target = null;
|
|
432
|
+
let sourceClosedByStream = false;
|
|
433
|
+
let targetClosedByStream = false;
|
|
434
|
+
try {
|
|
435
|
+
target = await openWritableFileWithinRoot({
|
|
436
|
+
rootDir: params.rootDir,
|
|
437
|
+
relativePath: params.relativePath,
|
|
438
|
+
mkdir: params.mkdir,
|
|
439
|
+
});
|
|
440
|
+
const sourceStream = source.handle.createReadStream();
|
|
441
|
+
const targetStream = target.handle.createWriteStream();
|
|
442
|
+
sourceStream.once("close", () => {
|
|
443
|
+
sourceClosedByStream = true;
|
|
444
|
+
});
|
|
445
|
+
targetStream.once("close", () => {
|
|
446
|
+
targetClosedByStream = true;
|
|
447
|
+
});
|
|
448
|
+
await pipeline(sourceStream, targetStream);
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
if (target?.createdForWrite) {
|
|
452
|
+
await fs.rm(target.openedRealPath, { force: true }).catch(() => { });
|
|
453
|
+
}
|
|
454
|
+
throw err;
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
if (!sourceClosedByStream) {
|
|
458
|
+
await source.handle.close().catch(() => { });
|
|
459
|
+
}
|
|
460
|
+
if (target && !targetClosedByStream) {
|
|
461
|
+
await target.handle.close().catch(() => { });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
export async function writeFileFromPathWithinRoot(params) {
|
|
466
|
+
await copyFileWithinRoot({
|
|
467
|
+
sourcePath: params.sourcePath,
|
|
468
|
+
rootDir: params.rootDir,
|
|
469
|
+
relativePath: params.relativePath,
|
|
470
|
+
mkdir: params.mkdir,
|
|
471
|
+
rejectSourceHardlinks: true,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { isNotFoundPathError } from "./path-guards.js";
|
|
4
|
+
export async function assertNoHardlinkedFinalPath(params) {
|
|
5
|
+
if (params.allowFinalHardlinkForUnlink) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
let stat;
|
|
9
|
+
try {
|
|
10
|
+
stat = await fs.stat(params.filePath);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
if (isNotFoundPathError(err)) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
if (!stat.isFile()) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (stat.nlink > 1) {
|
|
22
|
+
throw new Error(`Hardlinked path is not allowed under ${params.boundaryLabel} (${shortPath(params.root)}): ${shortPath(params.filePath)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function shortPath(value) {
|
|
26
|
+
if (value.startsWith(os.homedir())) {
|
|
27
|
+
return `~${value.slice(os.homedir().length)}`;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
@@ -1,8 +1,47 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { EnvHttpProxyAgent } from "undici";
|
|
2
|
+
import { logWarn } from "../../logger.js";
|
|
3
|
+
import { bindAbortRelay } from "../../utils/fetch-timeout.js";
|
|
4
|
+
import { hasProxyEnvConfigured } from "./proxy-env.js";
|
|
5
|
+
import { closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, SsrFBlockedError, } from "./ssrf.js";
|
|
6
|
+
export const GUARDED_FETCH_MODE = {
|
|
7
|
+
STRICT: "strict",
|
|
8
|
+
TRUSTED_ENV_PROXY: "trusted_env_proxy",
|
|
9
|
+
};
|
|
2
10
|
const DEFAULT_MAX_REDIRECTS = 3;
|
|
11
|
+
const CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS = [
|
|
12
|
+
"authorization",
|
|
13
|
+
"proxy-authorization",
|
|
14
|
+
"cookie",
|
|
15
|
+
"cookie2",
|
|
16
|
+
];
|
|
17
|
+
export function withStrictGuardedFetchMode(params) {
|
|
18
|
+
return { ...params, mode: GUARDED_FETCH_MODE.STRICT };
|
|
19
|
+
}
|
|
20
|
+
export function withTrustedEnvProxyGuardedFetchMode(params) {
|
|
21
|
+
return { ...params, mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY };
|
|
22
|
+
}
|
|
23
|
+
function resolveGuardedFetchMode(params) {
|
|
24
|
+
if (params.mode) {
|
|
25
|
+
return params.mode;
|
|
26
|
+
}
|
|
27
|
+
if (params.proxy === "env" && params.dangerouslyAllowEnvProxyWithoutPinnedDns === true) {
|
|
28
|
+
return GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY;
|
|
29
|
+
}
|
|
30
|
+
return GUARDED_FETCH_MODE.STRICT;
|
|
31
|
+
}
|
|
3
32
|
function isRedirectStatus(status) {
|
|
4
33
|
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
5
34
|
}
|
|
35
|
+
function stripSensitiveHeadersForCrossOriginRedirect(init) {
|
|
36
|
+
if (!init?.headers) {
|
|
37
|
+
return init;
|
|
38
|
+
}
|
|
39
|
+
const headers = new Headers(init.headers);
|
|
40
|
+
for (const header of CROSS_ORIGIN_REDIRECT_SENSITIVE_HEADERS) {
|
|
41
|
+
headers.delete(header);
|
|
42
|
+
}
|
|
43
|
+
return { ...init, headers };
|
|
44
|
+
}
|
|
6
45
|
function buildAbortSignal(params) {
|
|
7
46
|
const { timeoutMs, signal } = params;
|
|
8
47
|
if (!timeoutMs && !signal) {
|
|
@@ -12,8 +51,8 @@ function buildAbortSignal(params) {
|
|
|
12
51
|
return { signal, cleanup: () => { } };
|
|
13
52
|
}
|
|
14
53
|
const controller = new AbortController();
|
|
15
|
-
const timeoutId = setTimeout(
|
|
16
|
-
const onAbort = (
|
|
54
|
+
const timeoutId = setTimeout(controller.abort.bind(controller), timeoutMs);
|
|
55
|
+
const onAbort = bindAbortRelay(controller);
|
|
17
56
|
if (signal) {
|
|
18
57
|
if (signal.aborted) {
|
|
19
58
|
controller.abort();
|
|
@@ -38,6 +77,7 @@ export async function fetchWithSsrFGuard(params) {
|
|
|
38
77
|
const maxRedirects = typeof params.maxRedirects === "number" && Number.isFinite(params.maxRedirects)
|
|
39
78
|
? Math.max(0, Math.floor(params.maxRedirects))
|
|
40
79
|
: DEFAULT_MAX_REDIRECTS;
|
|
80
|
+
const mode = resolveGuardedFetchMode(params);
|
|
41
81
|
const { signal, cleanup } = buildAbortSignal({
|
|
42
82
|
timeoutMs: params.timeoutMs,
|
|
43
83
|
signal: params.signal,
|
|
@@ -53,6 +93,7 @@ export async function fetchWithSsrFGuard(params) {
|
|
|
53
93
|
};
|
|
54
94
|
const visited = new Set();
|
|
55
95
|
let currentUrl = params.url;
|
|
96
|
+
let currentInit = params.init ? { ...params.init } : undefined;
|
|
56
97
|
let redirectCount = 0;
|
|
57
98
|
while (true) {
|
|
58
99
|
let parsedUrl;
|
|
@@ -69,18 +110,19 @@ export async function fetchWithSsrFGuard(params) {
|
|
|
69
110
|
}
|
|
70
111
|
let dispatcher = null;
|
|
71
112
|
try {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
113
|
+
const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
|
114
|
+
lookupFn: params.lookupFn,
|
|
115
|
+
policy: params.policy,
|
|
116
|
+
});
|
|
117
|
+
const canUseTrustedEnvProxy = mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && hasProxyEnvConfigured();
|
|
118
|
+
if (canUseTrustedEnvProxy) {
|
|
119
|
+
dispatcher = new EnvHttpProxyAgent();
|
|
120
|
+
}
|
|
121
|
+
else if (params.pinDns !== false) {
|
|
80
122
|
dispatcher = createPinnedDispatcher(pinned);
|
|
81
123
|
}
|
|
82
124
|
const init = {
|
|
83
|
-
...(
|
|
125
|
+
...(currentInit ? { ...currentInit } : {}),
|
|
84
126
|
redirect: "manual",
|
|
85
127
|
...(dispatcher ? { dispatcher } : {}),
|
|
86
128
|
...(signal ? { signal } : {}),
|
|
@@ -97,11 +139,15 @@ export async function fetchWithSsrFGuard(params) {
|
|
|
97
139
|
await release(dispatcher);
|
|
98
140
|
throw new Error(`Too many redirects (limit: ${maxRedirects})`);
|
|
99
141
|
}
|
|
100
|
-
const
|
|
142
|
+
const nextParsedUrl = new URL(location, parsedUrl);
|
|
143
|
+
const nextUrl = nextParsedUrl.toString();
|
|
101
144
|
if (visited.has(nextUrl)) {
|
|
102
145
|
await release(dispatcher);
|
|
103
146
|
throw new Error("Redirect loop detected");
|
|
104
147
|
}
|
|
148
|
+
if (nextParsedUrl.origin !== parsedUrl.origin) {
|
|
149
|
+
currentInit = stripSensitiveHeadersForCrossOriginRedirect(currentInit);
|
|
150
|
+
}
|
|
105
151
|
visited.add(nextUrl);
|
|
106
152
|
void response.body?.cancel();
|
|
107
153
|
await closeDispatcher(dispatcher);
|
|
@@ -115,6 +161,10 @@ export async function fetchWithSsrFGuard(params) {
|
|
|
115
161
|
};
|
|
116
162
|
}
|
|
117
163
|
catch (err) {
|
|
164
|
+
if (err instanceof SsrFBlockedError) {
|
|
165
|
+
const context = params.auditContext ?? "url-fetch";
|
|
166
|
+
logWarn(`security: blocked URL fetch (${context}) target=${parsedUrl.origin}${parsedUrl.pathname} reason=${err.message}`);
|
|
167
|
+
}
|
|
118
168
|
await release(dispatcher);
|
|
119
169
|
throw err;
|
|
120
170
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const PROXY_ENV_KEYS = [
|
|
2
|
+
"HTTP_PROXY",
|
|
3
|
+
"HTTPS_PROXY",
|
|
4
|
+
"ALL_PROXY",
|
|
5
|
+
"http_proxy",
|
|
6
|
+
"https_proxy",
|
|
7
|
+
"all_proxy",
|
|
8
|
+
];
|
|
9
|
+
export function hasProxyEnvConfigured(env = process.env) {
|
|
10
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
11
|
+
const value = env[key];
|
|
12
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|