aegis-bridge 0.1.0
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/LICENSE +21 -0
- package/README.md +404 -0
- package/dashboard/dist/assets/index-BoZwGLAx.css +32 -0
- package/dashboard/dist/assets/index-C61BkKH-.js +312 -0
- package/dashboard/dist/assets/index-C61BkKH-.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/api-contracts.d.ts +229 -0
- package/dist/api-contracts.js +7 -0
- package/dist/api-contracts.typecheck.d.ts +14 -0
- package/dist/api-contracts.typecheck.js +1 -0
- package/dist/api-error-envelope.d.ts +15 -0
- package/dist/api-error-envelope.js +80 -0
- package/dist/auth.d.ts +87 -0
- package/dist/auth.js +276 -0
- package/dist/channels/index.d.ts +8 -0
- package/dist/channels/index.js +8 -0
- package/dist/channels/manager.d.ts +47 -0
- package/dist/channels/manager.js +115 -0
- package/dist/channels/telegram-style.d.ts +118 -0
- package/dist/channels/telegram-style.js +202 -0
- package/dist/channels/telegram.d.ts +91 -0
- package/dist/channels/telegram.js +1518 -0
- package/dist/channels/types.d.ts +77 -0
- package/dist/channels/types.js +8 -0
- package/dist/channels/webhook.d.ts +60 -0
- package/dist/channels/webhook.js +216 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +252 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +214 -0
- package/dist/consensus.d.ts +16 -0
- package/dist/consensus.js +19 -0
- package/dist/continuation-pointer.d.ts +11 -0
- package/dist/continuation-pointer.js +65 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +95 -0
- package/dist/error-categories.d.ts +39 -0
- package/dist/error-categories.js +73 -0
- package/dist/events.d.ts +133 -0
- package/dist/events.js +389 -0
- package/dist/fault-injection.d.ts +29 -0
- package/dist/fault-injection.js +115 -0
- package/dist/file-utils.d.ts +2 -0
- package/dist/file-utils.js +37 -0
- package/dist/handshake.d.ts +60 -0
- package/dist/handshake.js +124 -0
- package/dist/hook-settings.d.ts +80 -0
- package/dist/hook-settings.js +272 -0
- package/dist/hook.d.ts +19 -0
- package/dist/hook.js +231 -0
- package/dist/hooks.d.ts +32 -0
- package/dist/hooks.js +364 -0
- package/dist/jsonl-watcher.d.ts +59 -0
- package/dist/jsonl-watcher.js +166 -0
- package/dist/logger.d.ts +35 -0
- package/dist/logger.js +65 -0
- package/dist/mcp-server.d.ts +123 -0
- package/dist/mcp-server.js +869 -0
- package/dist/memory-bridge.d.ts +27 -0
- package/dist/memory-bridge.js +137 -0
- package/dist/memory-routes.d.ts +3 -0
- package/dist/memory-routes.js +100 -0
- package/dist/metrics.d.ts +126 -0
- package/dist/metrics.js +286 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/monitor.d.ts +103 -0
- package/dist/monitor.js +820 -0
- package/dist/path-utils.d.ts +11 -0
- package/dist/path-utils.js +21 -0
- package/dist/permission-evaluator.d.ts +10 -0
- package/dist/permission-evaluator.js +48 -0
- package/dist/permission-guard.d.ts +51 -0
- package/dist/permission-guard.js +196 -0
- package/dist/permission-request-manager.d.ts +12 -0
- package/dist/permission-request-manager.js +36 -0
- package/dist/permission-routes.d.ts +7 -0
- package/dist/permission-routes.js +28 -0
- package/dist/pipeline.d.ts +97 -0
- package/dist/pipeline.js +291 -0
- package/dist/process-utils.d.ts +4 -0
- package/dist/process-utils.js +73 -0
- package/dist/question-manager.d.ts +54 -0
- package/dist/question-manager.js +80 -0
- package/dist/retry.d.ts +11 -0
- package/dist/retry.js +34 -0
- package/dist/safe-json.d.ts +12 -0
- package/dist/safe-json.js +22 -0
- package/dist/screenshot.d.ts +28 -0
- package/dist/screenshot.js +60 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +1973 -0
- package/dist/session-cleanup.d.ts +18 -0
- package/dist/session-cleanup.js +11 -0
- package/dist/session.d.ts +379 -0
- package/dist/session.js +1568 -0
- package/dist/shutdown-utils.d.ts +5 -0
- package/dist/shutdown-utils.js +24 -0
- package/dist/signal-cleanup-helper.d.ts +48 -0
- package/dist/signal-cleanup-helper.js +117 -0
- package/dist/sse-limiter.d.ts +47 -0
- package/dist/sse-limiter.js +61 -0
- package/dist/sse-writer.d.ts +31 -0
- package/dist/sse-writer.js +94 -0
- package/dist/ssrf.d.ts +102 -0
- package/dist/ssrf.js +267 -0
- package/dist/startup.d.ts +6 -0
- package/dist/startup.js +162 -0
- package/dist/suppress.d.ts +33 -0
- package/dist/suppress.js +79 -0
- package/dist/swarm-monitor.d.ts +117 -0
- package/dist/swarm-monitor.js +300 -0
- package/dist/template-store.d.ts +45 -0
- package/dist/template-store.js +142 -0
- package/dist/terminal-parser.d.ts +16 -0
- package/dist/terminal-parser.js +346 -0
- package/dist/tmux-capture-cache.d.ts +18 -0
- package/dist/tmux-capture-cache.js +34 -0
- package/dist/tmux.d.ts +183 -0
- package/dist/tmux.js +906 -0
- package/dist/tool-registry.d.ts +40 -0
- package/dist/tool-registry.js +83 -0
- package/dist/transcript.d.ts +63 -0
- package/dist/transcript.js +284 -0
- package/dist/utils/circular-buffer.d.ts +11 -0
- package/dist/utils/circular-buffer.js +37 -0
- package/dist/utils/redact-headers.d.ts +13 -0
- package/dist/utils/redact-headers.js +54 -0
- package/dist/validation.d.ts +406 -0
- package/dist/validation.js +415 -0
- package/dist/verification.d.ts +2 -0
- package/dist/verification.js +72 -0
- package/dist/worktree-lookup.d.ts +24 -0
- package/dist/worktree-lookup.js +71 -0
- package/dist/ws-terminal.d.ts +32 -0
- package/dist/ws-terminal.js +348 -0
- package/package.json +83 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shutdown-utils.ts — reusable shutdown helpers for server signal handling.
|
|
3
|
+
*/
|
|
4
|
+
export declare function parseShutdownTimeoutMs(rawValue: string | undefined, fallbackMs?: number): number;
|
|
5
|
+
export declare function isWindowsShutdownMessage(message: unknown): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shutdown-utils.ts — reusable shutdown helpers for server signal handling.
|
|
3
|
+
*/
|
|
4
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
5
|
+
export function parseShutdownTimeoutMs(rawValue, fallbackMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
|
|
6
|
+
const parsed = Number(rawValue);
|
|
7
|
+
if (!Number.isFinite(parsed) || parsed < 1_000)
|
|
8
|
+
return fallbackMs;
|
|
9
|
+
return Math.floor(parsed);
|
|
10
|
+
}
|
|
11
|
+
export function isWindowsShutdownMessage(message) {
|
|
12
|
+
if (typeof message === 'string') {
|
|
13
|
+
const normalized = message.trim().toLowerCase();
|
|
14
|
+
return normalized === 'shutdown' || normalized === 'graceful-shutdown';
|
|
15
|
+
}
|
|
16
|
+
if (typeof message === 'object' && message !== null && 'type' in message) {
|
|
17
|
+
const typeValue = message.type;
|
|
18
|
+
if (typeof typeValue === 'string') {
|
|
19
|
+
const normalized = typeValue.trim().toLowerCase();
|
|
20
|
+
return normalized === 'shutdown' || normalized === 'graceful-shutdown';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* signal-cleanup-helper.ts — Signal handler cleanup logic for Issue #569.
|
|
3
|
+
*
|
|
4
|
+
* Provides killAllSessions() and createSignalHandler() for graceful shutdown.
|
|
5
|
+
* Separated from server.ts for testability.
|
|
6
|
+
*/
|
|
7
|
+
import type { SessionManager } from './session.js';
|
|
8
|
+
import type { TmuxManager } from './tmux.js';
|
|
9
|
+
/** Result of killAllSessions operation. */
|
|
10
|
+
export interface KillAllResult {
|
|
11
|
+
/** Number of sessions successfully killed. */
|
|
12
|
+
killed: number;
|
|
13
|
+
/** Number of sessions that failed to kill. */
|
|
14
|
+
errors: number;
|
|
15
|
+
}
|
|
16
|
+
/** Result of killAllSessionsWithTimeout operation. */
|
|
17
|
+
export interface KillAllWithTimeoutResult extends KillAllResult {
|
|
18
|
+
/** Whether any session kill timed out. */
|
|
19
|
+
timedOut: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Kill all active CC sessions and the tmux session.
|
|
23
|
+
* Best-effort: continues even if individual session kills fail.
|
|
24
|
+
*
|
|
25
|
+
* @param sessions - SessionManager instance
|
|
26
|
+
* @param tmux - TmuxManager instance
|
|
27
|
+
* @returns Number of sessions killed and errors encountered
|
|
28
|
+
*/
|
|
29
|
+
export declare function killAllSessions(sessions: SessionManager, tmux: TmuxManager): Promise<KillAllResult>;
|
|
30
|
+
/**
|
|
31
|
+
* Kill all sessions with per-session timeout protection.
|
|
32
|
+
* If a session kill hangs beyond the timeout, it is skipped.
|
|
33
|
+
*
|
|
34
|
+
* @param sessions - SessionManager instance
|
|
35
|
+
* @param tmux - TmuxManager instance
|
|
36
|
+
* @param perSessionTimeoutMs - Maximum time to wait per session kill (default 5000ms)
|
|
37
|
+
* @returns Result including timeout status
|
|
38
|
+
*/
|
|
39
|
+
export declare function killAllSessionsWithTimeout(sessions: SessionManager, tmux: TmuxManager, perSessionTimeoutMs?: number): Promise<KillAllWithTimeoutResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Create a signal handler that kills all sessions on SIGTERM/SIGINT.
|
|
42
|
+
* Includes reentrance guard to prevent double cleanup on rapid signals.
|
|
43
|
+
*
|
|
44
|
+
* @param sessions - SessionManager instance
|
|
45
|
+
* @param tmux - TmuxManager instance
|
|
46
|
+
* @returns Signal handler function
|
|
47
|
+
*/
|
|
48
|
+
export declare function createSignalHandler(sessions: SessionManager, tmux: TmuxManager): (signal: string) => void;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* signal-cleanup-helper.ts — Signal handler cleanup logic for Issue #569.
|
|
3
|
+
*
|
|
4
|
+
* Provides killAllSessions() and createSignalHandler() for graceful shutdown.
|
|
5
|
+
* Separated from server.ts for testability.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Kill all active CC sessions and the tmux session.
|
|
9
|
+
* Best-effort: continues even if individual session kills fail.
|
|
10
|
+
*
|
|
11
|
+
* @param sessions - SessionManager instance
|
|
12
|
+
* @param tmux - TmuxManager instance
|
|
13
|
+
* @returns Number of sessions killed and errors encountered
|
|
14
|
+
*/
|
|
15
|
+
export async function killAllSessions(sessions, tmux) {
|
|
16
|
+
const allSessions = sessions.listSessions();
|
|
17
|
+
let killed = 0;
|
|
18
|
+
let errors = 0;
|
|
19
|
+
// Kill each session individually (restores settings, cleans up temp files)
|
|
20
|
+
for (const session of allSessions) {
|
|
21
|
+
try {
|
|
22
|
+
await sessions.killSession(session.id);
|
|
23
|
+
killed++;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
errors++;
|
|
27
|
+
console.error(`Signal cleanup: failed to kill session ${session.windowName} (${session.id.slice(0, 8)}): ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Final fallback: kill the entire tmux session to ensure nothing is left
|
|
31
|
+
try {
|
|
32
|
+
await tmux.killSession();
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
console.error(`Signal cleanup: failed to kill tmux session: ${e.message}`);
|
|
36
|
+
}
|
|
37
|
+
console.log(`Signal cleanup: killed ${killed} sessions (${errors} errors)`);
|
|
38
|
+
return { killed, errors };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Kill all sessions with per-session timeout protection.
|
|
42
|
+
* If a session kill hangs beyond the timeout, it is skipped.
|
|
43
|
+
*
|
|
44
|
+
* @param sessions - SessionManager instance
|
|
45
|
+
* @param tmux - TmuxManager instance
|
|
46
|
+
* @param perSessionTimeoutMs - Maximum time to wait per session kill (default 5000ms)
|
|
47
|
+
* @returns Result including timeout status
|
|
48
|
+
*/
|
|
49
|
+
export async function killAllSessionsWithTimeout(sessions, tmux, perSessionTimeoutMs = 5_000) {
|
|
50
|
+
const allSessions = sessions.listSessions();
|
|
51
|
+
let killed = 0;
|
|
52
|
+
let errors = 0;
|
|
53
|
+
let timedOut = false;
|
|
54
|
+
for (const session of allSessions) {
|
|
55
|
+
try {
|
|
56
|
+
await withTimeout(sessions.killSession(session.id), perSessionTimeoutMs, `Session kill timeout for ${session.windowName}`);
|
|
57
|
+
killed++;
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (e instanceof TimeoutError) {
|
|
61
|
+
timedOut = true;
|
|
62
|
+
console.error(`Signal cleanup: TIMED OUT killing session ${session.windowName}`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
console.error(`Signal cleanup: failed to kill session ${session.windowName}: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
errors++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Final fallback: kill entire tmux session
|
|
71
|
+
try {
|
|
72
|
+
await tmux.killSession();
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
console.error(`Signal cleanup: failed to kill tmux session: ${e.message}`);
|
|
76
|
+
}
|
|
77
|
+
console.log(`Signal cleanup: killed ${killed}/${allSessions.length} sessions (${errors} errors, ${timedOut ? 'some timed out' : 'no timeouts'})`);
|
|
78
|
+
return { killed, errors, timedOut };
|
|
79
|
+
}
|
|
80
|
+
/** Error thrown when an operation exceeds its timeout. */
|
|
81
|
+
class TimeoutError extends Error {
|
|
82
|
+
constructor(message) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'TimeoutError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Wrap a promise with a timeout. */
|
|
88
|
+
function withTimeout(promise, ms, message) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const timer = setTimeout(() => reject(new TimeoutError(message)), ms);
|
|
91
|
+
promise.then((val) => { clearTimeout(timer); resolve(val); }, (err) => { clearTimeout(timer); reject(err); });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Create a signal handler that kills all sessions on SIGTERM/SIGINT.
|
|
96
|
+
* Includes reentrance guard to prevent double cleanup on rapid signals.
|
|
97
|
+
*
|
|
98
|
+
* @param sessions - SessionManager instance
|
|
99
|
+
* @param tmux - TmuxManager instance
|
|
100
|
+
* @returns Signal handler function
|
|
101
|
+
*/
|
|
102
|
+
export function createSignalHandler(sessions, tmux) {
|
|
103
|
+
let shuttingDown = false;
|
|
104
|
+
return (signal) => {
|
|
105
|
+
if (shuttingDown)
|
|
106
|
+
return;
|
|
107
|
+
shuttingDown = true;
|
|
108
|
+
console.log(`${signal} received — cleaning up ${sessions.listSessions().length} active sessions...`);
|
|
109
|
+
void killAllSessions(sessions, tmux)
|
|
110
|
+
.then((result) => {
|
|
111
|
+
console.log(`${signal} cleanup complete: ${result.killed} sessions killed`);
|
|
112
|
+
})
|
|
113
|
+
.catch((e) => {
|
|
114
|
+
console.error(`${signal} cleanup error:`, e);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sse-limiter.ts — Connection limiter for SSE endpoints (Issue #300).
|
|
3
|
+
*
|
|
4
|
+
* Tracks active SSE connections per-IP and globally.
|
|
5
|
+
* Enforces configurable limits to prevent unbounded resource consumption.
|
|
6
|
+
*/
|
|
7
|
+
export interface SSELimiterConfig {
|
|
8
|
+
/** Maximum total concurrent SSE connections across all IPs. Default: 100 */
|
|
9
|
+
maxConnections?: number;
|
|
10
|
+
/** Maximum concurrent SSE connections per client IP. Default: 10 */
|
|
11
|
+
maxPerIp?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface AcquireResult {
|
|
14
|
+
allowed: true;
|
|
15
|
+
connectionId: string;
|
|
16
|
+
}
|
|
17
|
+
export interface AcquireDeniedResult {
|
|
18
|
+
allowed: false;
|
|
19
|
+
reason: 'per_ip_limit' | 'global_limit';
|
|
20
|
+
/** Current count for the limiting dimension */
|
|
21
|
+
current: number;
|
|
22
|
+
/** Configured limit */
|
|
23
|
+
limit: number;
|
|
24
|
+
}
|
|
25
|
+
export type AcquireResponse = AcquireResult | AcquireDeniedResult;
|
|
26
|
+
export declare class SSEConnectionLimiter {
|
|
27
|
+
private readonly maxConnections;
|
|
28
|
+
private readonly maxPerIp;
|
|
29
|
+
private readonly connections;
|
|
30
|
+
private readonly ipCounts;
|
|
31
|
+
private nextId;
|
|
32
|
+
constructor(config?: SSELimiterConfig);
|
|
33
|
+
/** Current total active connections. */
|
|
34
|
+
get activeCount(): number;
|
|
35
|
+
/** Active connections for a specific IP. */
|
|
36
|
+
activeCountForIp(ip: string): number;
|
|
37
|
+
/**
|
|
38
|
+
* Attempt to acquire a connection slot.
|
|
39
|
+
* Check per-IP limit first (more specific), then global limit.
|
|
40
|
+
*/
|
|
41
|
+
acquire(ip: string): AcquireResponse;
|
|
42
|
+
/**
|
|
43
|
+
* Release a connection slot.
|
|
44
|
+
* Safe to call with unknown or already-released IDs (no-op).
|
|
45
|
+
*/
|
|
46
|
+
release(connectionId: string): void;
|
|
47
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sse-limiter.ts — Connection limiter for SSE endpoints (Issue #300).
|
|
3
|
+
*
|
|
4
|
+
* Tracks active SSE connections per-IP and globally.
|
|
5
|
+
* Enforces configurable limits to prevent unbounded resource consumption.
|
|
6
|
+
*/
|
|
7
|
+
export class SSEConnectionLimiter {
|
|
8
|
+
maxConnections;
|
|
9
|
+
maxPerIp;
|
|
10
|
+
connections = new Map();
|
|
11
|
+
ipCounts = new Map();
|
|
12
|
+
nextId = 1;
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.maxConnections = config?.maxConnections ?? 100;
|
|
15
|
+
this.maxPerIp = config?.maxPerIp ?? 10;
|
|
16
|
+
}
|
|
17
|
+
/** Current total active connections. */
|
|
18
|
+
get activeCount() {
|
|
19
|
+
return this.connections.size;
|
|
20
|
+
}
|
|
21
|
+
/** Active connections for a specific IP. */
|
|
22
|
+
activeCountForIp(ip) {
|
|
23
|
+
return this.ipCounts.get(ip) ?? 0;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Attempt to acquire a connection slot.
|
|
27
|
+
* Check per-IP limit first (more specific), then global limit.
|
|
28
|
+
*/
|
|
29
|
+
acquire(ip) {
|
|
30
|
+
const currentPerIp = this.activeCountForIp(ip);
|
|
31
|
+
if (currentPerIp >= this.maxPerIp) {
|
|
32
|
+
return { allowed: false, reason: 'per_ip_limit', current: currentPerIp, limit: this.maxPerIp };
|
|
33
|
+
}
|
|
34
|
+
if (this.connections.size >= this.maxConnections) {
|
|
35
|
+
return { allowed: false, reason: 'global_limit', current: this.connections.size, limit: this.maxConnections };
|
|
36
|
+
}
|
|
37
|
+
const connectionId = `sse-${this.nextId++}`;
|
|
38
|
+
this.connections.set(connectionId, { ip });
|
|
39
|
+
this.ipCounts.set(ip, currentPerIp + 1);
|
|
40
|
+
return { allowed: true, connectionId };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Release a connection slot.
|
|
44
|
+
* Safe to call with unknown or already-released IDs (no-op).
|
|
45
|
+
*/
|
|
46
|
+
release(connectionId) {
|
|
47
|
+
const entry = this.connections.get(connectionId);
|
|
48
|
+
if (!entry)
|
|
49
|
+
return;
|
|
50
|
+
this.connections.delete(connectionId);
|
|
51
|
+
const count = this.ipCounts.get(entry.ip);
|
|
52
|
+
if (count !== undefined) {
|
|
53
|
+
if (count <= 1) {
|
|
54
|
+
this.ipCounts.delete(entry.ip);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
this.ipCounts.set(entry.ip, count - 1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sse-writer.ts — SSE write helper with back-pressure handling.
|
|
3
|
+
*
|
|
4
|
+
* Issue #302: Check reply.raw.write() return value and disconnect
|
|
5
|
+
* slow clients after consecutive failed writes.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerResponse, IncomingMessage } from 'node:http';
|
|
8
|
+
export declare class SSEWriter {
|
|
9
|
+
private readonly res;
|
|
10
|
+
private readonly onCleanup;
|
|
11
|
+
private consecutiveFailures;
|
|
12
|
+
private isDestroyed;
|
|
13
|
+
private heartbeatTimer;
|
|
14
|
+
private lastWrite;
|
|
15
|
+
/** Drop slow clients after this many consecutive write() calls returning false. */
|
|
16
|
+
private static readonly MAX_CONSECUTIVE_FAILURES;
|
|
17
|
+
constructor(res: ServerResponse, req: IncomingMessage, onCleanup: () => void);
|
|
18
|
+
/**
|
|
19
|
+
* Write SSE data to the response.
|
|
20
|
+
* Returns true if the write succeeded (or is within failure threshold).
|
|
21
|
+
* Returns false if the connection was destroyed due to back-pressure.
|
|
22
|
+
*/
|
|
23
|
+
write(data: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Start heartbeat + idle timeout loop.
|
|
26
|
+
* Returns a stop function to cancel the timer.
|
|
27
|
+
*/
|
|
28
|
+
startHeartbeat(intervalMs: number, idleTimeoutMs: number, buildEvent: () => string): () => void;
|
|
29
|
+
private destroy;
|
|
30
|
+
private cleanup;
|
|
31
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sse-writer.ts — SSE write helper with back-pressure handling.
|
|
3
|
+
*
|
|
4
|
+
* Issue #302: Check reply.raw.write() return value and disconnect
|
|
5
|
+
* slow clients after consecutive failed writes.
|
|
6
|
+
*/
|
|
7
|
+
export class SSEWriter {
|
|
8
|
+
res;
|
|
9
|
+
onCleanup;
|
|
10
|
+
consecutiveFailures = 0;
|
|
11
|
+
isDestroyed = false;
|
|
12
|
+
heartbeatTimer = null;
|
|
13
|
+
lastWrite = Date.now();
|
|
14
|
+
/** Drop slow clients after this many consecutive write() calls returning false. */
|
|
15
|
+
static MAX_CONSECUTIVE_FAILURES = 3;
|
|
16
|
+
constructor(res, req, onCleanup) {
|
|
17
|
+
this.res = res;
|
|
18
|
+
this.onCleanup = onCleanup;
|
|
19
|
+
req.on('close', () => this.cleanup());
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Write SSE data to the response.
|
|
23
|
+
* Returns true if the write succeeded (or is within failure threshold).
|
|
24
|
+
* Returns false if the connection was destroyed due to back-pressure.
|
|
25
|
+
*/
|
|
26
|
+
write(data) {
|
|
27
|
+
if (this.isDestroyed)
|
|
28
|
+
return false;
|
|
29
|
+
try {
|
|
30
|
+
const canContinue = this.res.write(data);
|
|
31
|
+
if (!canContinue) {
|
|
32
|
+
this.consecutiveFailures++;
|
|
33
|
+
if (this.consecutiveFailures >= SSEWriter.MAX_CONSECUTIVE_FAILURES) {
|
|
34
|
+
this.destroy();
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.consecutiveFailures = 0;
|
|
40
|
+
this.lastWrite = Date.now();
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch { /* write failed — destroy connection */
|
|
45
|
+
this.destroy();
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Start heartbeat + idle timeout loop.
|
|
51
|
+
* Returns a stop function to cancel the timer.
|
|
52
|
+
*/
|
|
53
|
+
startHeartbeat(intervalMs, idleTimeoutMs, buildEvent) {
|
|
54
|
+
this.heartbeatTimer = setInterval(() => {
|
|
55
|
+
if (this.isDestroyed) {
|
|
56
|
+
clearInterval(this.heartbeatTimer);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (Date.now() - this.lastWrite > idleTimeoutMs) {
|
|
60
|
+
this.destroy();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.write(buildEvent());
|
|
64
|
+
}, intervalMs);
|
|
65
|
+
return () => {
|
|
66
|
+
if (this.heartbeatTimer) {
|
|
67
|
+
clearInterval(this.heartbeatTimer);
|
|
68
|
+
this.heartbeatTimer = null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
destroy() {
|
|
73
|
+
if (this.isDestroyed)
|
|
74
|
+
return;
|
|
75
|
+
this.isDestroyed = true;
|
|
76
|
+
if (this.heartbeatTimer) {
|
|
77
|
+
clearInterval(this.heartbeatTimer);
|
|
78
|
+
this.heartbeatTimer = null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
this.res.end();
|
|
82
|
+
}
|
|
83
|
+
catch { /* already closed */ }
|
|
84
|
+
this.onCleanup();
|
|
85
|
+
}
|
|
86
|
+
cleanup() {
|
|
87
|
+
if (this.heartbeatTimer) {
|
|
88
|
+
clearInterval(this.heartbeatTimer);
|
|
89
|
+
this.heartbeatTimer = null;
|
|
90
|
+
}
|
|
91
|
+
this.isDestroyed = true;
|
|
92
|
+
this.onCleanup();
|
|
93
|
+
}
|
|
94
|
+
}
|
package/dist/ssrf.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if an IP address (v4 or v6) is private/internal.
|
|
3
|
+
*
|
|
4
|
+
* Rejects:
|
|
5
|
+
* - RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
6
|
+
* - Loopback: 127.0.0.0/8, ::1
|
|
7
|
+
* - Link-local: 169.254.0.0/16, fe80::/10
|
|
8
|
+
* - Current network: 0.0.0.0/8
|
|
9
|
+
* - Unspecified: ::
|
|
10
|
+
* - IPv6 unique-local: fc00::/7
|
|
11
|
+
* - IPv4-mapped IPv6: ::ffff:x.x.x.x (RFC 4291)
|
|
12
|
+
* - IPv4-compatible IPv6: ::x.x.x.x (deprecated)
|
|
13
|
+
* - CGNAT: 100.64.0.0/10 (RFC 6598)
|
|
14
|
+
* - Broadcast: 255.255.255.255
|
|
15
|
+
* - Multicast: 224.0.0.0/4 (RFC 5771)
|
|
16
|
+
* - Documentation: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 (RFC 5737)
|
|
17
|
+
* - Benchmarking: 198.18.0.0/15 (RFC 2544)
|
|
18
|
+
*/
|
|
19
|
+
export declare function isPrivateIP(ip: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Validate a URL for webhook configuration.
|
|
22
|
+
*
|
|
23
|
+
* Checks:
|
|
24
|
+
* 1. Valid URL format
|
|
25
|
+
* 2. HTTPS scheme required for external hosts
|
|
26
|
+
* 3. HTTP allowed only for localhost / 127.0.0.1
|
|
27
|
+
* 4. Rejects private/internal IP addresses (except 127.0.0.1 in dev mode)
|
|
28
|
+
* 5. Rejects *.local hostnames
|
|
29
|
+
*
|
|
30
|
+
* Returns null if valid, or an error string if invalid.
|
|
31
|
+
*/
|
|
32
|
+
export declare function validateWebhookUrl(rawUrl: string): string | null;
|
|
33
|
+
/** DNS lookup result shape (matches node:dns/promises.LookupAddress). */
|
|
34
|
+
export interface DnsLookupResult {
|
|
35
|
+
address: string;
|
|
36
|
+
family: number;
|
|
37
|
+
}
|
|
38
|
+
/** DNS lookup function type for dependency injection. Returns ALL addresses. */
|
|
39
|
+
export type DnsLookupFn = (hostname: string) => Promise<DnsLookupResult[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Result of DNS resolution with SSRF check.
|
|
42
|
+
* On success, includes the resolved IP address for TOCTOU-safe pinning.
|
|
43
|
+
*/
|
|
44
|
+
export interface DnsCheckResult {
|
|
45
|
+
error: string | null;
|
|
46
|
+
resolvedIp: string | null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a hostname via DNS and check if the resulting IP is private/internal.
|
|
50
|
+
*
|
|
51
|
+
* For literal IP addresses, checks directly without DNS resolution.
|
|
52
|
+
* Returns a DnsCheckResult with error string if unsafe, or the resolved IP on success.
|
|
53
|
+
*
|
|
54
|
+
* The resolved IP should be used with Chromium --host-resolver-rules to pin the
|
|
55
|
+
* address and prevent DNS rebinding (TOCTOU) attacks between validation and page.goto().
|
|
56
|
+
*
|
|
57
|
+
* @param hostname - Hostname or literal IP to check
|
|
58
|
+
* @param lookupFn - Optional DNS lookup function (for testing)
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveAndCheckIp(hostname: string, lookupFn?: DnsLookupFn): Promise<DnsCheckResult>;
|
|
61
|
+
/**
|
|
62
|
+
* Build Chromium --host-resolver-rules argument to pin a hostname to a specific IP.
|
|
63
|
+
*
|
|
64
|
+
* This prevents DNS rebinding (TOCTOU) attacks between SSRF validation and page.goto()
|
|
65
|
+
* by ensuring Chromium resolves the hostname to the same IP that was validated.
|
|
66
|
+
*
|
|
67
|
+
* @param hostname - The original hostname from the URL
|
|
68
|
+
* @param resolvedIp - The IP address that was validated as safe
|
|
69
|
+
* @returns The --host-resolver-rules argument string
|
|
70
|
+
*/
|
|
71
|
+
export declare function buildHostResolverRule(hostname: string, resolvedIp: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* Build a connection URL where the hostname is replaced by the resolved IP address.
|
|
74
|
+
*
|
|
75
|
+
* This prevents DNS rebinding (TOCTOU) attacks in HTTP clients (like Node fetch)
|
|
76
|
+
* by ensuring the connection goes to the validated IP, not a re-resolved address.
|
|
77
|
+
* The original hostname is returned separately so callers can set the Host header.
|
|
78
|
+
*
|
|
79
|
+
* For IPv6 addresses, wraps the IP in brackets per RFC 2732.
|
|
80
|
+
*
|
|
81
|
+
* @param originalUrl - The original URL (e.g. "https://example.com/path")
|
|
82
|
+
* @param resolvedIp - The validated IP address to connect to
|
|
83
|
+
* @returns Object with the connection URL and the original hostname for Host header
|
|
84
|
+
*/
|
|
85
|
+
export declare function buildConnectionUrl(originalUrl: string, resolvedIp: string): {
|
|
86
|
+
connectionUrl: string;
|
|
87
|
+
hostHeader: string;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Validate a URL for the screenshot endpoint to prevent SSRF attacks.
|
|
91
|
+
*
|
|
92
|
+
* Checks:
|
|
93
|
+
* 1. Valid URL format
|
|
94
|
+
* 2. http: or https: scheme only
|
|
95
|
+
* 3. Rejects private/internal IP addresses (literal)
|
|
96
|
+
* 4. Rejects localhost / *.local hostnames
|
|
97
|
+
*
|
|
98
|
+
* For full DNS-resolution protection, call resolveAndCheckIp() separately.
|
|
99
|
+
*
|
|
100
|
+
* Returns null if valid, or an error string if invalid.
|
|
101
|
+
*/
|
|
102
|
+
export declare function validateScreenshotUrl(rawUrl: string): string | null;
|