altd 0.0.3 → 0.0.5
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/dist/altd.js +193 -118
- package/dist/index.js +66 -4
- package/index.js +66 -4
- package/package.json +1 -1
- package/scripts/build.js +3 -1
- package/src/altd.js +193 -118
package/dist/altd.js
CHANGED
|
@@ -1,132 +1,207 @@
|
|
|
1
|
-
import {spawn} from
|
|
2
|
-
import Tail from
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import Tail from "nodejs-tail";
|
|
3
|
+
|
|
3
4
|
/**
|
|
4
|
-
|
|
5
|
+
* Safe, modern dispatcher:
|
|
6
|
+
* - command is mapped to an absolute executable path (not user-provided)
|
|
7
|
+
* - args are validated per-command
|
|
8
|
+
* - rate limited
|
|
9
|
+
* - process is sandboxed-ish (no shell, timeout, limited output)
|
|
5
10
|
*/
|
|
6
11
|
export default class AccessLogTailDispatcher {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} file access_log
|
|
14
|
+
* @param {object} commandRegistry { [commandName]: { execPath: string, buildArgs: (rawArgs:string[])=>string[] } }
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
*/
|
|
17
|
+
constructor(file, commandRegistry, opts = {}) {
|
|
18
|
+
this.file = file;
|
|
19
|
+
this.registry = commandRegistry;
|
|
20
|
+
|
|
21
|
+
this.spawnImpl = opts.spawnImpl ?? nodeSpawn;
|
|
22
|
+
this.tail = opts.tail
|
|
23
|
+
?? new Tail(file, {
|
|
24
|
+
alwaysStat: true,
|
|
25
|
+
ignoreInitial: true,
|
|
26
|
+
persistent: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Simple rate limit: max N executions per windowMs
|
|
30
|
+
this.windowMs = opts.windowMs ?? 1000;
|
|
31
|
+
this.maxPerWindow = opts.maxPerWindow ?? 5;
|
|
32
|
+
this._windowStart = Date.now();
|
|
33
|
+
this._countInWindow = 0;
|
|
34
|
+
|
|
35
|
+
// Process limits
|
|
36
|
+
this.timeoutMs = opts.timeoutMs ?? 10_000;
|
|
37
|
+
this.maxStdoutBytes = opts.maxStdoutBytes ?? 64 * 1024;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract request path from a typical access log line.
|
|
42
|
+
* More robust: parse "METHOD <url> HTTP/..."
|
|
43
|
+
* @param {string} line
|
|
44
|
+
* @returns {string} pathname like "/a/b"
|
|
45
|
+
*/
|
|
46
|
+
extractPath(line) {
|
|
47
|
+
if (typeof line !== "string" || line.length > 10_000) return "";
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
commands.shift();
|
|
54
|
-
return commands;
|
|
49
|
+
// Find something like: GET /foo/bar HTTP/1.1
|
|
50
|
+
const m = line.match(
|
|
51
|
+
/\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i,
|
|
52
|
+
);
|
|
53
|
+
if (!m) return "";
|
|
54
|
+
|
|
55
|
+
const rawTarget = m[2];
|
|
56
|
+
|
|
57
|
+
// rawTarget can be absolute URL or origin-form. Normalize via URL.
|
|
58
|
+
// If it's origin-form "/x", give it a dummy base.
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = rawTarget.startsWith("/")
|
|
62
|
+
? new URL(rawTarget, "http://localhost")
|
|
63
|
+
: new URL(rawTarget);
|
|
64
|
+
} catch {
|
|
65
|
+
return "";
|
|
55
66
|
}
|
|
56
67
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
return commandWithArgs;
|
|
68
|
+
// Only use pathname; ignore query/hash to reduce attack surface.
|
|
69
|
+
return url.pathname || "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* "/cmd/a/b" -> ["cmd","a","b"] (safe decode, size limits)
|
|
74
|
+
* @param {string} pathname
|
|
75
|
+
* @returns {string[]}
|
|
76
|
+
*/
|
|
77
|
+
parseCommand(pathname) {
|
|
78
|
+
if (typeof pathname !== "string" || pathname === "" || pathname.length > 2048) {
|
|
79
|
+
return [];
|
|
72
80
|
}
|
|
81
|
+
if (!pathname.startsWith("/")) return [];
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
});
|
|
87
|
-
proc.stdout.on('data', (data) => {
|
|
88
|
-
process.stdout.write(data.toString());
|
|
89
|
-
});
|
|
83
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
84
|
+
if (parts.length === 0) return [];
|
|
85
|
+
|
|
86
|
+
// Safe decode each segment; if decode fails, reject the whole request.
|
|
87
|
+
const decoded = [];
|
|
88
|
+
for (const p of parts) {
|
|
89
|
+
if (p.length > 256) return [];
|
|
90
|
+
try {
|
|
91
|
+
decoded.push(decodeURIComponent(p));
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
90
95
|
}
|
|
96
|
+
return decoded;
|
|
97
|
+
}
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Basic rate limit to avoid log-triggered fork bombs.
|
|
101
|
+
* @returns {boolean} allowed
|
|
102
|
+
*/
|
|
103
|
+
allowByRateLimit() {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (now - this._windowStart >= this.windowMs) {
|
|
106
|
+
this._windowStart = now;
|
|
107
|
+
this._countInWindow = 0;
|
|
99
108
|
}
|
|
109
|
+
this._countInWindow += 1;
|
|
110
|
+
return this._countInWindow <= this.maxPerWindow;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Validate + build exec + args using registry
|
|
115
|
+
* @param {string[]} parsed ["cmd", ...rawArgs]
|
|
116
|
+
* @returns {{execPath:string,args:string[]}|null}
|
|
117
|
+
*/
|
|
118
|
+
resolveExecution(parsed) {
|
|
119
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
const [cmd, ...rawArgs] = parsed;
|
|
100
122
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if ( typeof whitelist !== 'undefined') {
|
|
111
|
-
this.whitelist = whitelist;
|
|
112
|
-
}
|
|
113
|
-
if ( typeof this.spawn === 'undefined') {
|
|
114
|
-
this.spawn = spawn;
|
|
115
|
-
}
|
|
116
|
-
if ( typeof this.tail === 'undefined') {
|
|
117
|
-
this.tail = new Tail(this.file,
|
|
118
|
-
{alwaysStat: true, ignoreInitial: true, persistent: true});
|
|
119
|
-
}
|
|
120
|
-
this.tail.on('line', (line) => {
|
|
121
|
-
this.dispatch(
|
|
122
|
-
this.filterByWhitelist(
|
|
123
|
-
this.commandWithArgs(
|
|
124
|
-
this.path(line)),
|
|
125
|
-
this.whitelist));
|
|
126
|
-
});
|
|
127
|
-
this.tail.on('close', () => {
|
|
128
|
-
console.log('watching stopped');
|
|
129
|
-
});
|
|
130
|
-
this.tail.watch();
|
|
123
|
+
const entry = this.registry[cmd];
|
|
124
|
+
if (!entry) return null;
|
|
125
|
+
|
|
126
|
+
// Build args via per-command validator
|
|
127
|
+
let args;
|
|
128
|
+
try {
|
|
129
|
+
args = entry.buildArgs(rawArgs);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
131
132
|
}
|
|
133
|
+
if (!Array.isArray(args)) return null;
|
|
134
|
+
|
|
135
|
+
return { execPath: entry.execPath, args };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Spawn with limits
|
|
140
|
+
* @param {string} execPath
|
|
141
|
+
* @param {string[]} args
|
|
142
|
+
*/
|
|
143
|
+
spawnLimited(execPath, args) {
|
|
144
|
+
const proc = this.spawnImpl(execPath, args, {
|
|
145
|
+
shell: false,
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
148
|
+
env: {
|
|
149
|
+
// Minimal env is often safer; adjust as needed
|
|
150
|
+
PATH: process.env.PATH ?? "",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Timeout
|
|
155
|
+
const t = setTimeout(() => {
|
|
156
|
+
proc.kill("SIGKILL");
|
|
157
|
+
}, this.timeoutMs);
|
|
158
|
+
proc.on("exit", () => clearTimeout(t));
|
|
159
|
+
|
|
160
|
+
// Output limiting
|
|
161
|
+
let outBytes = 0;
|
|
162
|
+
proc.stdout.on("data", (buf) => {
|
|
163
|
+
outBytes += buf.length;
|
|
164
|
+
if (outBytes > this.maxStdoutBytes) {
|
|
165
|
+
proc.kill("SIGKILL");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
process.stdout.write(buf);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
proc.stderr.on("data", (buf) => {
|
|
172
|
+
process.stderr.write(buf);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
proc.on("error", (err) => {
|
|
176
|
+
console.error("[spawn error]", err);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Start watching
|
|
182
|
+
*/
|
|
183
|
+
run() {
|
|
184
|
+
this.tail.on("line", (line) => {
|
|
185
|
+
if (!this.allowByRateLimit()) return;
|
|
186
|
+
|
|
187
|
+
const pathname = this.extractPath(line);
|
|
188
|
+
const parsed = this.parseCommand(pathname);
|
|
189
|
+
const exec = this.resolveExecution(parsed);
|
|
190
|
+
if (!exec) return;
|
|
191
|
+
|
|
192
|
+
this.spawnLimited(exec.execPath, exec.args);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
this.tail.on("close", () => {
|
|
196
|
+
console.log("watching stopped");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
this.tail.watch();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
stop() {
|
|
203
|
+
try {
|
|
204
|
+
this.tail.unwatch?.();
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
132
207
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { accessSync, constants } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
2
5
|
import { Command } from 'commander';
|
|
3
|
-
import pkg from './package.json' assert { type: 'json' };
|
|
4
6
|
import AccessLogTailDispatcher from './altd.js';
|
|
5
7
|
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const pkg = require('../package.json');
|
|
10
|
+
|
|
6
11
|
const program = new Command();
|
|
7
12
|
|
|
13
|
+
const resolveExecPath = (command) => {
|
|
14
|
+
if (!command || typeof command !== 'string') return null;
|
|
15
|
+
if (isAbsolute(command)) {
|
|
16
|
+
try {
|
|
17
|
+
accessSync(command, constants.X_OK);
|
|
18
|
+
return command;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const searchPaths = (process.env.PATH ?? '').split(delimiter);
|
|
25
|
+
for (const dir of searchPaths) {
|
|
26
|
+
if (!dir) continue;
|
|
27
|
+
const candidate = join(dir, command);
|
|
28
|
+
try {
|
|
29
|
+
accessSync(candidate, constants.X_OK);
|
|
30
|
+
return candidate;
|
|
31
|
+
} catch {
|
|
32
|
+
// continue
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const buildRegistry = (whitelist) => {
|
|
39
|
+
const registry = {};
|
|
40
|
+
for (const command of whitelist) {
|
|
41
|
+
const execPath = resolveExecPath(command);
|
|
42
|
+
if (!execPath) {
|
|
43
|
+
console.warn(`[altd] skip command: ${command}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
registry[command] = {
|
|
47
|
+
execPath,
|
|
48
|
+
buildArgs: (rawArgs) => {
|
|
49
|
+
if (!Array.isArray(rawArgs) || rawArgs.length > 20) {
|
|
50
|
+
throw new Error('invalid args');
|
|
51
|
+
}
|
|
52
|
+
for (const arg of rawArgs) {
|
|
53
|
+
if (typeof arg !== 'string' || arg.length > 256) {
|
|
54
|
+
throw new Error('invalid args');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return rawArgs;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return registry;
|
|
62
|
+
};
|
|
63
|
+
|
|
8
64
|
program
|
|
9
65
|
.name('altd')
|
|
10
66
|
.version(pkg.version)
|
|
@@ -13,17 +69,23 @@ program
|
|
|
13
69
|
.option(
|
|
14
70
|
'-w, --whitelist <commands>',
|
|
15
71
|
'Add commands to whitelist',
|
|
16
|
-
(commands) => commands.split(',')
|
|
72
|
+
(commands) => commands.split(',').map((command) => command.trim()).filter(Boolean)
|
|
17
73
|
)
|
|
18
74
|
.parse(process.argv);
|
|
19
75
|
|
|
20
76
|
const fileValue = program.args[0];
|
|
21
77
|
const { whitelist } = program.opts();
|
|
22
78
|
|
|
23
|
-
if (!fileValue || !whitelist) {
|
|
79
|
+
if (!fileValue || !whitelist || whitelist.length === 0) {
|
|
24
80
|
console.log('altd <file> -w <commands...>');
|
|
25
81
|
process.exit(1);
|
|
26
82
|
}
|
|
27
83
|
|
|
28
|
-
const
|
|
84
|
+
const registry = buildRegistry(whitelist);
|
|
85
|
+
if (Object.keys(registry).length === 0) {
|
|
86
|
+
console.error('[altd] no valid commands to run');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const altd = new AccessLogTailDispatcher(fileValue, registry);
|
|
29
91
|
altd.run();
|
package/index.js
CHANGED
|
@@ -1,10 +1,66 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { accessSync, constants } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
2
5
|
import { Command } from 'commander';
|
|
3
|
-
import pkg from './package.json' assert { type: 'json' };
|
|
4
6
|
import AccessLogTailDispatcher from './src/altd.js';
|
|
5
7
|
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const pkg = require('./package.json');
|
|
10
|
+
|
|
6
11
|
const program = new Command();
|
|
7
12
|
|
|
13
|
+
const resolveExecPath = (command) => {
|
|
14
|
+
if (!command || typeof command !== 'string') return null;
|
|
15
|
+
if (isAbsolute(command)) {
|
|
16
|
+
try {
|
|
17
|
+
accessSync(command, constants.X_OK);
|
|
18
|
+
return command;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const searchPaths = (process.env.PATH ?? '').split(delimiter);
|
|
25
|
+
for (const dir of searchPaths) {
|
|
26
|
+
if (!dir) continue;
|
|
27
|
+
const candidate = join(dir, command);
|
|
28
|
+
try {
|
|
29
|
+
accessSync(candidate, constants.X_OK);
|
|
30
|
+
return candidate;
|
|
31
|
+
} catch {
|
|
32
|
+
// continue
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const buildRegistry = (whitelist) => {
|
|
39
|
+
const registry = {};
|
|
40
|
+
for (const command of whitelist) {
|
|
41
|
+
const execPath = resolveExecPath(command);
|
|
42
|
+
if (!execPath) {
|
|
43
|
+
console.warn(`[altd] skip command: ${command}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
registry[command] = {
|
|
47
|
+
execPath,
|
|
48
|
+
buildArgs: (rawArgs) => {
|
|
49
|
+
if (!Array.isArray(rawArgs) || rawArgs.length > 20) {
|
|
50
|
+
throw new Error('invalid args');
|
|
51
|
+
}
|
|
52
|
+
for (const arg of rawArgs) {
|
|
53
|
+
if (typeof arg !== 'string' || arg.length > 256) {
|
|
54
|
+
throw new Error('invalid args');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return rawArgs;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return registry;
|
|
62
|
+
};
|
|
63
|
+
|
|
8
64
|
program
|
|
9
65
|
.name('altd')
|
|
10
66
|
.version(pkg.version)
|
|
@@ -13,17 +69,23 @@ program
|
|
|
13
69
|
.option(
|
|
14
70
|
'-w, --whitelist <commands>',
|
|
15
71
|
'Add commands to whitelist',
|
|
16
|
-
(commands) => commands.split(',')
|
|
72
|
+
(commands) => commands.split(',').map((command) => command.trim()).filter(Boolean)
|
|
17
73
|
)
|
|
18
74
|
.parse(process.argv);
|
|
19
75
|
|
|
20
76
|
const fileValue = program.args[0];
|
|
21
77
|
const { whitelist } = program.opts();
|
|
22
78
|
|
|
23
|
-
if (!fileValue || !whitelist) {
|
|
79
|
+
if (!fileValue || !whitelist || whitelist.length === 0) {
|
|
24
80
|
console.log('altd <file> -w <commands...>');
|
|
25
81
|
process.exit(1);
|
|
26
82
|
}
|
|
27
83
|
|
|
28
|
-
const
|
|
84
|
+
const registry = buildRegistry(whitelist);
|
|
85
|
+
if (Object.keys(registry).length === 0) {
|
|
86
|
+
console.error('[altd] no valid commands to run');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const altd = new AccessLogTailDispatcher(fileValue, registry);
|
|
29
91
|
altd.run();
|
package/package.json
CHANGED
package/scripts/build.js
CHANGED
|
@@ -9,7 +9,9 @@ const distDir = join(projectRoot, 'dist');
|
|
|
9
9
|
await mkdir(distDir, { recursive: true });
|
|
10
10
|
|
|
11
11
|
const indexSource = await readFile(join(projectRoot, 'index.js'), 'utf8');
|
|
12
|
-
const indexOutput = indexSource
|
|
12
|
+
const indexOutput = indexSource
|
|
13
|
+
.replace('./src/altd.js', './altd.js')
|
|
14
|
+
.replace('./package.json', '../package.json');
|
|
13
15
|
|
|
14
16
|
await writeFile(join(distDir, 'index.js'), indexOutput);
|
|
15
17
|
await copyFile(join(projectRoot, 'src', 'altd.js'), join(distDir, 'altd.js'));
|
package/src/altd.js
CHANGED
|
@@ -1,132 +1,207 @@
|
|
|
1
|
-
import {spawn} from
|
|
2
|
-
import Tail from
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import Tail from "nodejs-tail";
|
|
3
|
+
|
|
3
4
|
/**
|
|
4
|
-
|
|
5
|
+
* Safe, modern dispatcher:
|
|
6
|
+
* - command is mapped to an absolute executable path (not user-provided)
|
|
7
|
+
* - args are validated per-command
|
|
8
|
+
* - rate limited
|
|
9
|
+
* - process is sandboxed-ish (no shell, timeout, limited output)
|
|
5
10
|
*/
|
|
6
11
|
export default class AccessLogTailDispatcher {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} file access_log
|
|
14
|
+
* @param {object} commandRegistry { [commandName]: { execPath: string, buildArgs: (rawArgs:string[])=>string[] } }
|
|
15
|
+
* @param {object} [opts]
|
|
16
|
+
*/
|
|
17
|
+
constructor(file, commandRegistry, opts = {}) {
|
|
18
|
+
this.file = file;
|
|
19
|
+
this.registry = commandRegistry;
|
|
20
|
+
|
|
21
|
+
this.spawnImpl = opts.spawnImpl ?? nodeSpawn;
|
|
22
|
+
this.tail = opts.tail
|
|
23
|
+
?? new Tail(file, {
|
|
24
|
+
alwaysStat: true,
|
|
25
|
+
ignoreInitial: true,
|
|
26
|
+
persistent: true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Simple rate limit: max N executions per windowMs
|
|
30
|
+
this.windowMs = opts.windowMs ?? 1000;
|
|
31
|
+
this.maxPerWindow = opts.maxPerWindow ?? 5;
|
|
32
|
+
this._windowStart = Date.now();
|
|
33
|
+
this._countInWindow = 0;
|
|
34
|
+
|
|
35
|
+
// Process limits
|
|
36
|
+
this.timeoutMs = opts.timeoutMs ?? 10_000;
|
|
37
|
+
this.maxStdoutBytes = opts.maxStdoutBytes ?? 64 * 1024;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract request path from a typical access log line.
|
|
42
|
+
* More robust: parse "METHOD <url> HTTP/..."
|
|
43
|
+
* @param {string} line
|
|
44
|
+
* @returns {string} pathname like "/a/b"
|
|
45
|
+
*/
|
|
46
|
+
extractPath(line) {
|
|
47
|
+
if (typeof line !== "string" || line.length > 10_000) return "";
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
commands.shift();
|
|
54
|
-
return commands;
|
|
49
|
+
// Find something like: GET /foo/bar HTTP/1.1
|
|
50
|
+
const m = line.match(
|
|
51
|
+
/\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i,
|
|
52
|
+
);
|
|
53
|
+
if (!m) return "";
|
|
54
|
+
|
|
55
|
+
const rawTarget = m[2];
|
|
56
|
+
|
|
57
|
+
// rawTarget can be absolute URL or origin-form. Normalize via URL.
|
|
58
|
+
// If it's origin-form "/x", give it a dummy base.
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = rawTarget.startsWith("/")
|
|
62
|
+
? new URL(rawTarget, "http://localhost")
|
|
63
|
+
: new URL(rawTarget);
|
|
64
|
+
} catch {
|
|
65
|
+
return "";
|
|
55
66
|
}
|
|
56
67
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
return commandWithArgs;
|
|
68
|
+
// Only use pathname; ignore query/hash to reduce attack surface.
|
|
69
|
+
return url.pathname || "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* "/cmd/a/b" -> ["cmd","a","b"] (safe decode, size limits)
|
|
74
|
+
* @param {string} pathname
|
|
75
|
+
* @returns {string[]}
|
|
76
|
+
*/
|
|
77
|
+
parseCommand(pathname) {
|
|
78
|
+
if (typeof pathname !== "string" || pathname === "" || pathname.length > 2048) {
|
|
79
|
+
return [];
|
|
72
80
|
}
|
|
81
|
+
if (!pathname.startsWith("/")) return [];
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
});
|
|
87
|
-
proc.stdout.on('data', (data) => {
|
|
88
|
-
process.stdout.write(data.toString());
|
|
89
|
-
});
|
|
83
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
84
|
+
if (parts.length === 0) return [];
|
|
85
|
+
|
|
86
|
+
// Safe decode each segment; if decode fails, reject the whole request.
|
|
87
|
+
const decoded = [];
|
|
88
|
+
for (const p of parts) {
|
|
89
|
+
if (p.length > 256) return [];
|
|
90
|
+
try {
|
|
91
|
+
decoded.push(decodeURIComponent(p));
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
90
95
|
}
|
|
96
|
+
return decoded;
|
|
97
|
+
}
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Basic rate limit to avoid log-triggered fork bombs.
|
|
101
|
+
* @returns {boolean} allowed
|
|
102
|
+
*/
|
|
103
|
+
allowByRateLimit() {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (now - this._windowStart >= this.windowMs) {
|
|
106
|
+
this._windowStart = now;
|
|
107
|
+
this._countInWindow = 0;
|
|
99
108
|
}
|
|
109
|
+
this._countInWindow += 1;
|
|
110
|
+
return this._countInWindow <= this.maxPerWindow;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Validate + build exec + args using registry
|
|
115
|
+
* @param {string[]} parsed ["cmd", ...rawArgs]
|
|
116
|
+
* @returns {{execPath:string,args:string[]}|null}
|
|
117
|
+
*/
|
|
118
|
+
resolveExecution(parsed) {
|
|
119
|
+
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
const [cmd, ...rawArgs] = parsed;
|
|
100
122
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if ( typeof whitelist !== 'undefined') {
|
|
111
|
-
this.whitelist = whitelist;
|
|
112
|
-
}
|
|
113
|
-
if ( typeof this.spawn === 'undefined') {
|
|
114
|
-
this.spawn = spawn;
|
|
115
|
-
}
|
|
116
|
-
if ( typeof this.tail === 'undefined') {
|
|
117
|
-
this.tail = new Tail(this.file,
|
|
118
|
-
{alwaysStat: true, ignoreInitial: true, persistent: true});
|
|
119
|
-
}
|
|
120
|
-
this.tail.on('line', (line) => {
|
|
121
|
-
this.dispatch(
|
|
122
|
-
this.filterByWhitelist(
|
|
123
|
-
this.commandWithArgs(
|
|
124
|
-
this.path(line)),
|
|
125
|
-
this.whitelist));
|
|
126
|
-
});
|
|
127
|
-
this.tail.on('close', () => {
|
|
128
|
-
console.log('watching stopped');
|
|
129
|
-
});
|
|
130
|
-
this.tail.watch();
|
|
123
|
+
const entry = this.registry[cmd];
|
|
124
|
+
if (!entry) return null;
|
|
125
|
+
|
|
126
|
+
// Build args via per-command validator
|
|
127
|
+
let args;
|
|
128
|
+
try {
|
|
129
|
+
args = entry.buildArgs(rawArgs);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
131
132
|
}
|
|
133
|
+
if (!Array.isArray(args)) return null;
|
|
134
|
+
|
|
135
|
+
return { execPath: entry.execPath, args };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Spawn with limits
|
|
140
|
+
* @param {string} execPath
|
|
141
|
+
* @param {string[]} args
|
|
142
|
+
*/
|
|
143
|
+
spawnLimited(execPath, args) {
|
|
144
|
+
const proc = this.spawnImpl(execPath, args, {
|
|
145
|
+
shell: false,
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
148
|
+
env: {
|
|
149
|
+
// Minimal env is often safer; adjust as needed
|
|
150
|
+
PATH: process.env.PATH ?? "",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Timeout
|
|
155
|
+
const t = setTimeout(() => {
|
|
156
|
+
proc.kill("SIGKILL");
|
|
157
|
+
}, this.timeoutMs);
|
|
158
|
+
proc.on("exit", () => clearTimeout(t));
|
|
159
|
+
|
|
160
|
+
// Output limiting
|
|
161
|
+
let outBytes = 0;
|
|
162
|
+
proc.stdout.on("data", (buf) => {
|
|
163
|
+
outBytes += buf.length;
|
|
164
|
+
if (outBytes > this.maxStdoutBytes) {
|
|
165
|
+
proc.kill("SIGKILL");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
process.stdout.write(buf);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
proc.stderr.on("data", (buf) => {
|
|
172
|
+
process.stderr.write(buf);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
proc.on("error", (err) => {
|
|
176
|
+
console.error("[spawn error]", err);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Start watching
|
|
182
|
+
*/
|
|
183
|
+
run() {
|
|
184
|
+
this.tail.on("line", (line) => {
|
|
185
|
+
if (!this.allowByRateLimit()) return;
|
|
186
|
+
|
|
187
|
+
const pathname = this.extractPath(line);
|
|
188
|
+
const parsed = this.parseCommand(pathname);
|
|
189
|
+
const exec = this.resolveExecution(parsed);
|
|
190
|
+
if (!exec) return;
|
|
191
|
+
|
|
192
|
+
this.spawnLimited(exec.execPath, exec.args);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
this.tail.on("close", () => {
|
|
196
|
+
console.log("watching stopped");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
this.tail.watch();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
stop() {
|
|
203
|
+
try {
|
|
204
|
+
this.tail.unwatch?.();
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
132
207
|
}
|