Skip to content

Commit 3f57754

Browse files
fix(security): harden error handling, add WS rate limiting, validate CLI args (#117)
Security hardening and code quality improvements from a 10-agent parallel audit. Sanitizes internal error messages leaked to API clients, adds WebSocket auth rate limiting (5 attempts/min/IP matching HTTP auth), validates CLI args (port range, empty password, log-level whitelist), adds response.ok checks to all frontend fetch calls, fixes server shutdown to close active WS connections, makes auth cleanup interval cancellable, uses proper HTTP status codes (201/204), removes dead code, and fixes doc inaccuracies. Closes #116. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b7f2eab commit 3f57754

20 files changed

Lines changed: 164 additions & 109 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ flowchart LR
109109
| `--port <port>` | Server port | `3456` |
110110
| `--host <addr>` | Bind address | `127.0.0.1` |
111111
| `--lan` | Bind to all interfaces (LAN access) | Off |
112+
| `--public` | Allow public tunnel access (no Microsoft login) | Off |
112113
| `-i, --interactive` | Interactive setup wizard | Off |
113114
| `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
114115

docs/api.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ All fields are optional. If `initialCommand` is provided, it will be sent to the
116116

117117
The `shell` field is validated against the list of detected shells (see `GET /api/shells`). The `cwd` field must be an absolute path to an existing directory.
118118

119-
**Response (200):**
119+
**Response (201):**
120120

121121
```json
122122
{
@@ -200,11 +200,7 @@ Scan a session's scrollback buffer for the last `localhost` or `127.0.0.1` URL a
200200

201201
Kill and remove a session.
202202

203-
**Response (200):**
204-
205-
```json
206-
{ "ok": true }
207-
```
203+
**Response (204):** No content.
208204

209205
**Response (404):**
210206

@@ -298,7 +294,7 @@ Upload an image file. The request body is the raw image data with the appropriat
298294

299295
- `Content-Type`: Must be an `image/*` type
300296

301-
**Response (200):**
297+
**Response (201):**
302298

303299
```json
304300
{ "id": "uuid", "url": "/uploads/uuid", "path": "/tmp/termbeam-uuid.png" }
@@ -338,7 +334,7 @@ Upload a file to a session's working directory. The request body is the raw file
338334
- `X-Filename`: Original filename (required)
339335
- `X-Target-Dir`: Override destination directory (optional, defaults to session cwd)
340336

341-
**Response (200):**
337+
**Response (201):**
342338

343339
```json
344340
{ "name": "script.sh", "path": "/home/user/project/script.sh", "size": 1024 }

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ description: All TermBeam CLI flags and options — ports, passwords, tunnels, s
1616
| `--no-tunnel` | Disable tunnel ||
1717
| `--persisted-tunnel` | Create a reusable devtunnel URL (stable across restarts) | Off |
1818
| `--public` | Allow public tunnel access (no Microsoft login required) | Off |
19-
| `--port <port>` | Server port | `3456` |
19+
| `--port <port>` | Server port (must be 1-65535) | `3456` |
2020
| `--host <addr>` | Bind address | `127.0.0.1` |
2121
| `--lan` | Bind to all interfaces (LAN access) | Off |
2222
| `-i, --interactive` | Interactive setup wizard — walks through password, port, access mode (tunnel type, visibility), and log level | Off |

docs/running-in-background.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ The wizard checks if PM2 is installed (and offers to install it globally if not)
5050
| 5. **Working directory** | Default terminal directory | Default: current directory |
5151
| 6. **Log level** | Logging verbosity | `info` (default), `debug`, `warn`, or `error` |
5252
| 7. **Boot auto-start** | Start on system boot? | Default: Yes — runs `pm2 startup` |
53+
| 8. **Confirm** | Review and proceed | Proceed or cancel |
5354

5455
If you choose **DevTunnel** access, a follow-up question asks whether the tunnel should be **private** (Microsoft login required) or **public** (anyone with the link). Choosing public with no password will auto-generate one for safety.
5556

docs/security.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ Before running TermBeam, verify:
147147
### Rate Limiting
148148

149149
- Login endpoint limited to **5 attempts per minute** per IP
150-
- Returns HTTP 429 when exceeded
150+
- WebSocket auth limited to **5 attempts per minute** per IP
151+
- Returns HTTP 429 (or WebSocket close) when exceeded
151152

152153
### HTTP Security Headers
153154

landing/src/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const qrLines = [
3131

3232
const infoLines = [
3333
'',
34-
' Beam your terminal to any device 📡 <span class="out-dim">v1.3.0</span>',
34+
' Beam your terminal to any device 📡 <span class="out-dim">v1.10.0</span>',
3535
'',
3636
' Shell: <span class="out-white">zsh</span>',
3737
' Session: <span class="out-white">termbeam</span>',

public/index.html

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -991,17 +991,22 @@ <h3>
991991
}
992992

993993
async function loadSessions() {
994-
const res = await fetch('/api/sessions');
995-
const sessions = await res.json();
994+
try {
995+
const res = await fetch('/api/sessions');
996+
if (!res.ok) {
997+
console.error(`Failed to load sessions: ${res.status}`);
998+
return;
999+
}
1000+
const sessions = await res.json();
9961001

997-
if (sessions.length === 0) {
998-
listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
999-
return;
1000-
}
1002+
if (sessions.length === 0) {
1003+
listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
1004+
return;
1005+
}
10011006

1002-
listEl.innerHTML = sessions
1003-
.map(
1004-
(s) => `
1007+
listEl.innerHTML = sessions
1008+
.map(
1009+
(s) => `
10051010
<div class="swipe-wrap" data-session-id="${esc(s.id)}">
10061011
<div class="swipe-delete">
10071012
<button data-delete-id="${esc(s.id)}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
@@ -1031,22 +1036,25 @@ <h3>
10311036
</div>
10321037
</div>
10331038
`,
1034-
)
1035-
.join('');
1039+
)
1040+
.join('');
10361041

1037-
// Attach swipe handlers and click handlers after rendering
1038-
listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
1039-
listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
1040-
btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
1041-
});
1042-
listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
1043-
card.addEventListener('click', () => {
1044-
location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
1042+
// Attach swipe handlers and click handlers after rendering
1043+
listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
1044+
listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
1045+
btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
10451046
});
1046-
});
1047-
listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
1048-
dot.style.background = dot.dataset.color || 'var(--success)';
1049-
});
1047+
listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
1048+
card.addEventListener('click', () => {
1049+
location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
1050+
});
1051+
});
1052+
listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
1053+
dot.style.background = dot.dataset.color || 'var(--success)';
1054+
});
1055+
} catch (err) {
1056+
console.error('Failed to load sessions:', err);
1057+
}
10501058
}
10511059

10521060
document.getElementById('new-session-btn').addEventListener('click', () => {
@@ -1085,13 +1093,21 @@ <h3>
10851093
if (initialCommand) body.initialCommand = initialCommand;
10861094
if (color) body.color = color;
10871095

1088-
const res = await fetch('/api/sessions', {
1089-
method: 'POST',
1090-
headers: { 'Content-Type': 'application/json' },
1091-
body: JSON.stringify(body),
1092-
});
1093-
const data = await res.json();
1094-
location.href = data.url;
1096+
try {
1097+
const res = await fetch('/api/sessions', {
1098+
method: 'POST',
1099+
headers: { 'Content-Type': 'application/json' },
1100+
body: JSON.stringify(body),
1101+
});
1102+
if (!res.ok) {
1103+
console.error(`Failed to create session: ${res.status}`);
1104+
return;
1105+
}
1106+
const data = await res.json();
1107+
location.href = data.url;
1108+
} catch (err) {
1109+
console.error('Failed to create session:', err);
1110+
}
10951111
});
10961112

10971113
// --- Shell detection ---
@@ -1101,6 +1117,7 @@ <h3>
11011117
const shellSelect = document.getElementById('sess-shell');
11021118
try {
11031119
const res = await fetch('/api/shells');
1120+
if (!res.ok) throw new Error(`Failed to load shells: ${res.status}`);
11041121
const data = await res.json();
11051122
if (data.cwd) {
11061123
document.getElementById('sess-cwd').placeholder = data.cwd;
@@ -1225,7 +1242,10 @@ <h3>
12251242
document.getElementById('browse-btn').addEventListener('click', async () => {
12261243
if (hubServerCwd === '/') {
12271244
try {
1228-
const data = await fetch('/api/shells').then((r) => r.json());
1245+
const data = await fetch('/api/shells').then((r) => {
1246+
if (!r.ok) throw new Error(`${r.status}`);
1247+
return r.json();
1248+
});
12291249
if (data.cwd) hubServerCwd = data.cwd;
12301250
} catch {}
12311251
}
@@ -1254,6 +1274,7 @@ <h3>
12541274

12551275
try {
12561276
const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
1277+
if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
12571278
const data = await res.json();
12581279
let items = '';
12591280
// Add parent (..) entry unless at root
@@ -1310,7 +1331,10 @@ <h3>
13101331

13111332
// Fetch version
13121333
fetch('/api/version')
1313-
.then((r) => r.json())
1334+
.then((r) => {
1335+
if (!r.ok) throw new Error(`${r.status}`);
1336+
return r.json();
1337+
})
13141338
.then((d) => {
13151339
document.getElementById('version').textContent = 'v' + d.version;
13161340
})

public/terminal.html

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2476,7 +2476,14 @@ <h3>
24762476

24772477
// ===== Init =====
24782478
async function init() {
2479-
const sessionList = await fetch('/api/sessions').then((r) => r.json());
2479+
let sessionList = [];
2480+
try {
2481+
const res = await fetch('/api/sessions');
2482+
if (!res.ok) throw new Error(`${res.status}`);
2483+
sessionList = await res.json();
2484+
} catch (err) {
2485+
console.error('Failed to load sessions:', err);
2486+
}
24802487
const initialId = new URLSearchParams(location.search).get('id');
24812488

24822489
for (const s of sessionList) addSession(s);
@@ -2678,7 +2685,10 @@ <h3>
26782685

26792686
// Version
26802687
fetch('/api/version')
2681-
.then((r) => r.json())
2688+
.then((r) => {
2689+
if (!r.ok) throw new Error(`${r.status}`);
2690+
return r.json();
2691+
})
26822692
.then((d) => {
26832693
window._termbeamVersion = 'v' + d.version;
26842694
document.getElementById('side-panel-version').textContent = 'v' + d.version;
@@ -3884,6 +3894,7 @@ <h3>
38843894
const res = await fetch('/api/dirs?q=' + encodeURIComponent(q ? q + '/' : ''), {
38853895
credentials: 'same-origin',
38863896
});
3897+
if (!res.ok) throw new Error(`Failed to browse directories: ${res.status}`);
38873898
const data = await res.json();
38883899
if (!data.dirs || !data.dirs.length) return;
38893900
browseDropdown = document.createElement('div');
@@ -3997,7 +4008,10 @@ <h3>
39974008
document.getElementById('ns-browse-btn').addEventListener('click', async () => {
39984009
if (serverCwd === '/') {
39994010
try {
4000-
const data = await fetch('/api/shells').then((r) => r.json());
4011+
const data = await fetch('/api/shells').then((r) => {
4012+
if (!r.ok) throw new Error(`${r.status}`);
4013+
return r.json();
4014+
});
40014015
if (data.cwd) serverCwd = data.cwd;
40024016
} catch {}
40034017
}
@@ -4025,6 +4039,7 @@ <h3>
40254039
nsBrowserList.innerHTML = '<div class="browser-empty">Loading…</div>';
40264040
try {
40274041
const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
4042+
if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
40284043
const data = await res.json();
40294044
let items = '';
40304045
// Add parent (..) entry unless at root
@@ -4082,7 +4097,10 @@ <h3>
40824097
if (shellsLoaded) return;
40834098
const sel = document.getElementById('ns-shell');
40844099
try {
4085-
const data = await fetch('/api/shells').then((r) => r.json());
4100+
const data = await fetch('/api/shells').then((r) => {
4101+
if (!r.ok) throw new Error(`${r.status}`);
4102+
return r.json();
4103+
});
40864104
if (data.cwd) {
40874105
serverCwd = data.cwd;
40884106
document.getElementById('ns-cwd').placeholder = data.cwd;
@@ -4140,10 +4158,16 @@ <h3>
41404158
headers: { 'Content-Type': 'application/json' },
41414159
body: JSON.stringify(body),
41424160
});
4161+
if (!res.ok) {
4162+
console.error(`Failed to create session: ${res.status}`);
4163+
return;
4164+
}
41434165
const data = await res.json();
41444166

41454167
// Fetch full session list to get the new session data
4146-
const list = await fetch('/api/sessions').then((r) => r.json());
4168+
const listRes = await fetch('/api/sessions');
4169+
if (!listRes.ok) throw new Error(`Failed to list sessions: ${listRes.status}`);
4170+
const list = await listRes.json();
41474171
const newSession = list.find((s) => s.id === data.id);
41484172
if (newSession) {
41494173
addSession(newSession);
@@ -4163,7 +4187,9 @@ <h3>
41634187
function startPolling() {
41644188
setInterval(async () => {
41654189
try {
4166-
const list = await fetch('/api/sessions').then((r) => r.json());
4190+
const pollRes = await fetch('/api/sessions');
4191+
if (!pollRes.ok) throw new Error(`${pollRes.status}`);
4192+
const list = await pollRes.json();
41674193
const serverIds = new Set(list.map((s) => s.id));
41684194

41694195
// Add new sessions created elsewhere

src/auth.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ function createAuth(password) {
177177
const shareTokens = new Map(); // share tokens: token -> expiry
178178

179179
// Periodically clean up expired tokens and stale rate-limit entries
180-
setInterval(
180+
const cleanupInterval = setInterval(
181181
() => {
182182
const now = Date.now();
183183
for (const [token, expiry] of tokens) {
@@ -296,6 +296,7 @@ function createAuth(password) {
296296
rateLimit,
297297
parseCookies,
298298
loginHTML: LOGIN_HTML,
299+
cleanup: () => clearInterval(cleanupInterval),
299300
};
300301
}
301302

src/cli.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,10 @@ function parseArgs() {
270270
publicTunnel = true;
271271
} else if (args[i].startsWith('--password=')) {
272272
password = args[i].split('=')[1];
273+
if (!password) {
274+
console.error('Error: --password= requires a non-empty value\n');
275+
process.exit(1);
276+
}
273277
explicitPassword = true;
274278
} else if (args[i] === '--help' || args[i] === '-h') {
275279
printHelp();
@@ -286,6 +290,10 @@ function parseArgs() {
286290
explicitPassword = true;
287291
} else if (args[i] === '--port' && args[i + 1]) {
288292
port = parseInt(args[++i], 10);
293+
if (!Number.isFinite(port) || port < 1 || port > 65535) {
294+
console.error('Error: --port must be a number between 1 and 65535\n');
295+
process.exit(1);
296+
}
289297
} else if (args[i] === '--lan') {
290298
host = '0.0.0.0';
291299
} else if (args[i] === '--host' && args[i + 1]) {
@@ -307,6 +315,12 @@ function parseArgs() {
307315
}
308316
}
309317

318+
const validLogLevels = ['error', 'warn', 'info', 'debug'];
319+
if (!validLogLevels.includes(logLevel)) {
320+
console.error(`Error: --log-level must be one of: ${validLogLevels.join(', ')}\n`);
321+
process.exit(1);
322+
}
323+
310324
// Default: auto-generate password if none specified
311325
if (!explicitPassword && !password) {
312326
password = crypto.randomBytes(16).toString('base64url');

0 commit comments

Comments
 (0)