-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin.js
More file actions
164 lines (146 loc) · 6.47 KB
/
Copy pathplugin.js
File metadata and controls
164 lines (146 loc) · 6.47 KB
1
2
3
4
5
6
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
// terminal — WebSocket shell as a #206 loader plugin.
//
// plugins: [{ module: 'terminal/plugin.js', prefix: '/terminal',
// config: { allowAgents: ['https://alice.example/profile/card#me'],
// token: 'optional-shared-secret',
// shell: '/bin/sh', cwd: '/srv/work' } }]
//
// Out-of-tree port of JSS src/terminal/index.js (AGPL-3.0-only). This is a
// remote shell over a WebSocket — the most dangerous of the ports — so the
// gate is *stricter* than core, not merely mirrored:
//
// - core boots the shell whenever --terminal is passed and only refuses a
// connection with no webId (`options.public` can even waive that). This
// port REFUSES TO ACTIVATE without an explicit allowlist or shared token:
// an open shell is never the default.
// - core spawns with `{ ...process.env }`, leaking every server secret
// into the child. This port spawns with a minimal, curated env.
// - connection auth is verified via api.auth.getAgent(request) (#584) —
// the same credential schemes the host itself accepts — against
// config.allowAgents, with a query-param token as the browser fallback.
//
// Attribution: adapted from JavaScriptSolidServer/src/terminal/index.js.
import { spawn } from 'node:child_process';
import { createHash, timingSafeEqual } from 'node:crypto';
// Wire protocol (matches core): raw text/binary chunks are shell stdio;
// JSON envelopes carry lifecycle signals.
const err = (message) => JSON.stringify({ type: 'error', message });
const exit = (code) => JSON.stringify({ type: 'exit', code: code ?? 1 });
const OPEN = 1; // ws.readyState OPEN
export async function activate(api) {
const wsPath = api.prefix || '/terminal';
const cfg = api.config || {};
// ---------------------------------------------------------------- gate
// Access control is mandatory. Two mechanisms, either satisfies boot:
// - allowAgents: verified-agent allowlist (WebID / did:nostr), the
// primary path — credentials proven via api.auth.getAgent.
// - token: a shared secret compared to ?token=, the browser fallback
// (browsers cannot set Authorization on a WebSocket handshake).
const allowAgents = Array.isArray(cfg.allowAgents) ? cfg.allowAgents.filter(Boolean) : null;
const token = typeof cfg.token === 'string' && cfg.token ? cfg.token : null;
// Pre-hash the expected token so the compare is constant-time over
// fixed-width digests — no early-exit that would leak the token length.
const tokenDigest = token ? createHash('sha256').update(token).digest() : null;
if ((!allowAgents || allowAgents.length === 0) && !token) {
throw new Error(
'terminal: refusing to boot an open shell — set config.allowAgents ' +
'(array of allowed agent ids) and/or config.token (shared secret)',
);
}
const allow = new Set(allowAgents || []);
const shellCmd = typeof cfg.shell === 'string' && cfg.shell ? cfg.shell : '/bin/sh';
const shellArgs = Array.isArray(cfg.args) ? cfg.args : [];
// Working dir: the plugin's private data dir unless overridden. Never the
// server's cwd.
const cwd = typeof cfg.cwd === 'string' && cfg.cwd ? cfg.cwd : api.storage.pluginDir();
// Curated env — never `process.env`. Secrets (token secrets, JWKS paths,
// API keys the operator set) must not reach a shell the client drives.
const env = {
PATH: cfg.PATH || '/usr/local/bin:/usr/bin:/bin',
HOME: cwd,
TERM: 'xterm-256color',
LANG: process.env.LANG || 'C.UTF-8',
...(cfg.env && typeof cfg.env === 'object' ? cfg.env : {}),
};
const shells = new Set();
// Verify the handshake before spawning anything. Returns an agent label
// string when authorized, or null.
async function authorize(request) {
// Browser fallback: a shared token in the query string. Constant-time
// over sha256 digests so neither the token nor its LENGTH leaks via an
// early-exit length check (the metrics/ pattern).
if (token) {
const qToken = request.query?.token;
if (typeof qToken === 'string') {
const presented = createHash('sha256').update(qToken).digest();
if (timingSafeEqual(presented, tokenDigest)) return 'token';
}
}
// Primary: a verified agent from a Bearer/DPoP/NIP-98/… credential.
// The `ws` npm client CAN send headers, so this path works for
// programmatic clients; browsers use the token fallback above.
if (allow.size) {
const agent = await api.auth.getAgent(request);
if (agent && allow.has(agent)) return agent;
}
return null;
}
await api.ws.route(wsPath, async (socket, request) => {
const agent = await authorize(request);
if (!agent) {
try {
if (socket.readyState === OPEN) socket.send(err('Authentication required'));
socket.close(1008, 'unauthorized');
} catch { /* already gone */ }
return;
}
const shell = spawn(shellCmd, shellArgs, {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe'],
});
shells.add(shell);
api.log.info(`terminal: shell for ${agent} (pid ${shell.pid})`);
const pipe = (stream) => stream.on('data', (data) => {
if (socket.readyState !== OPEN) return;
try { socket.send(data.toString().replace(/\r?\n/g, '\r\n')); } catch { /* closed */ }
});
pipe(shell.stdout);
pipe(shell.stderr);
shell.on('close', (code) => {
shells.delete(shell);
if (socket.readyState !== OPEN) return;
try { socket.send(exit(code)); socket.close(); } catch { /* closed */ }
});
shell.on('error', (e) => {
shells.delete(shell);
if (socket.readyState !== OPEN) return;
try { socket.send(err(e.message)); socket.close(); } catch { /* closed */ }
});
socket.on('message', (data) => {
if (!shell.stdin.writable) return;
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
try { shell.stdin.write(buf); } catch { /* stdin closed */ }
});
const kill = () => {
shells.delete(shell);
try { shell.kill('SIGKILL'); } catch { /* already dead */ }
};
socket.on('close', kill);
socket.on('error', kill);
});
api.log.info(
`terminal: shell websocket at ${wsPath} ` +
`(${allow.size ? `${allow.size} allowed agent(s)` : 'no allowlist'}` +
`${token ? ', token' : ''}, cwd ${cwd})`,
);
return {
deactivate() {
for (const proc of shells) {
try { proc.kill('SIGKILL'); } catch { /* already dead */ }
}
shells.clear();
},
};
}
export default activate;