Skip to content

Commit 7ef5a80

Browse files
remcovmclaude
andcommitted
fix: Harden security — patch injection/XSS vulns, move auth to cookies, restrict CORS
- Sanitize sed inputs in ssh-conf and comment text in ssh-new to prevent shell injection - Escape password single quotes in ssh-ui deploy route askpass script - Validate target_host input against allowlist pattern - Add escapeHtml() and apply to all dynamic HTML in app.js (templates, history, host keys) - Move auth token from URL query params to HTTP-only SameSite=Strict cookie - Restrict SocketIO CORS from wildcard to localhost-only origins - Remove dead legacy scripts (bin/old/) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6d5b63a commit 7ef5a80

8 files changed

Lines changed: 69 additions & 128 deletions

File tree

‎bin/old/install.sh‎

Lines changed: 0 additions & 22 deletions
This file was deleted.

‎bin/old/ssh-del‎

Lines changed: 0 additions & 37 deletions
This file was deleted.

‎bin/old/ssh-new‎

Lines changed: 0 additions & 35 deletions
This file was deleted.

‎bin/ssh-conf‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ touch "$CONFIG_FILE" && chmod 600 "$CONFIG_FILE"
4242
if [ "$REMOVE_MODE" == "1" ]; then
4343
# remove mode: ssh-conf host --remove Option
4444
if [ -z "$OPTION" ]; then err "Specify option to remove."; fi
45-
sed -i.bak "/^${OPTION}\s/I d" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak"
45+
# Sanitize OPTION for use in sed pattern (escape regex metacharacters)
46+
SAFE_OPTION=$(printf '%s\n' "$OPTION" | sed 's/[[\.*^$()+?{|/]/\\&/g')
47+
sed -i.bak "/^${SAFE_OPTION}\s/I d" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak"
4648
echo "Removed '$OPTION'."
4749
log_event "conf-remove" "$HOST_NAME" "Removed $OPTION"
4850
else
@@ -54,8 +56,10 @@ else
5456
else
5557
# Add/Update
5658
if [ -z "$VALUE" ]; then err "Provide value for $OPTION."; fi
57-
if grep -q -i "^${OPTION}\s" "$CONFIG_FILE"; then
58-
sed -i.bak "s/^${OPTION}\s.*/${OPTION} ${VALUE}/I" "$CONFIG_FILE"
59+
SAFE_OPTION=$(printf '%s\n' "$OPTION" | sed 's/[[\.*^$()+?{|/]/\\&/g')
60+
SAFE_VALUE=$(printf '%s\n' "$VALUE" | sed 's/[&/\]/\\&/g')
61+
if grep -q -i "^${SAFE_OPTION}\s" "$CONFIG_FILE"; then
62+
sed -i.bak "s/^${SAFE_OPTION}\s.*/${OPTION} ${SAFE_VALUE}/I" "$CONFIG_FILE"
5963
echo "Updated '$OPTION'."
6064
else
6165
echo "${OPTION} ${VALUE}" >> "$CONFIG_FILE"

‎bin/ssh-new‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,10 @@ if [ "$LEGACY_MODE" -eq 1 ]; then
225225
fi
226226

227227
if [ -n "$COMMENT_TEXT" ]; then
228+
# Sanitize comment text: strip single quotes and backslashes to prevent shell injection
229+
SAFE_COMMENT=$(printf '%s' "$COMMENT_TEXT" | tr -d "'\\" )
228230
sed -i.bak '/^Match host %h exec/d' "$CONF" && rm -f "$CONF.bak"
229-
echo "Match host %h exec \"echo -e '\033[1;33m[NOTE] $COMMENT_TEXT\033[0m' >&2\"" >> "$CONF"
231+
echo "Match host %h exec \"echo -e '\033[1;33m[NOTE] $SAFE_COMMENT\033[0m' >&2\"" >> "$CONF"
230232
fi
231233

232234
cat > "$TRUSTED_CONF" << EOF

‎bin/ssh-ui‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ app.secret_key = SECRET_KEY
8484
# Initialize SocketIO if available
8585
socketio = None
8686
if SOCKETIO_AVAILABLE:
87-
socketio = SocketIO(app, async_mode='gevent', cors_allowed_origins='*')
87+
# CORS origins will be set at startup once port is known
88+
socketio = SocketIO(app, async_mode='gevent', cors_allowed_origins=[])
8889

8990
# Global map for active PTYs: sid -> {fd, pid, ...}
9091
# Global map for active PTYs: term_id -> {fd, pid, sid, timer}
@@ -211,15 +212,27 @@ def get_templates_list():
211212
return templates
212213

213214
def check_auth():
214-
token = request.args.get('token') or session.get('auth_token')
215-
if token == AUTH_TOKEN:
216-
if 'auth_token' not in session: session['auth_token'] = token
217-
return True
218-
return False
215+
"""Check auth via HTTP-only cookie, or session fallback."""
216+
token = request.cookies.get('auth_token') or session.get('auth_token')
217+
return token == AUTH_TOKEN
219218

220219
@app.before_request
221220
def before_request():
222221
if request.endpoint == 'static' or request.endpoint == 'custom_static': return
222+
if request.endpoint == 'favicon' or request.endpoint == 'robots': return
223+
# If token is in URL query param, set HTTP-only cookie and redirect to strip it
224+
url_token = request.args.get('token')
225+
if url_token == AUTH_TOKEN:
226+
# Build clean URL without the token param
227+
from urllib.parse import urlencode, parse_qs, urlparse, urlunparse
228+
parsed = urlparse(request.url)
229+
params = parse_qs(parsed.query, keep_blank_values=True)
230+
params.pop('token', None)
231+
clean_query = urlencode(params, doseq=True)
232+
clean_url = urlunparse(parsed._replace(query=clean_query))
233+
resp = redirect(clean_url)
234+
resp.set_cookie('auth_token', AUTH_TOKEN, httponly=True, samesite='Strict', path='/')
235+
return resp
223236
if not check_auth(): return "Unauthorized: Missing or invalid token", 401
224237

225238
@app.route('/')
@@ -229,10 +242,9 @@ def index():
229242
result_status = session.pop('last_status', None)
230243
templates = get_templates_list()
231244

232-
return render_template('index.html',
233-
identities=identities,
245+
return render_template('index.html',
246+
identities=identities,
234247
templates=templates,
235-
token=AUTH_TOKEN,
236248
result_output=result_output,
237249
result_status=result_status,
238250
xterm_enabled=SOCKETIO_AVAILABLE)
@@ -302,7 +314,7 @@ def create_identity():
302314
launch_terminal_script(script_content, f"create_{safe_host}")
303315
session['last_status'] = 'info'
304316
session['last_output'] = f"Launched Terminal for: {user_host}..."
305-
return redirect(url_for('index', token=AUTH_TOKEN))
317+
return redirect(url_for('index'))
306318
except Exception as e: return f"Launch error: {str(e)}", 500
307319

308320
@app.route('/connect', methods=['POST'])
@@ -330,14 +342,20 @@ def deploy_key():
330342
password = request.form.get('password')
331343
if not all([uuid, user, target_host, password]): return "Missing fields", 400
332344

345+
import re
333346
safe_uuid = os.path.basename(uuid)
334347
safe_user = os.path.basename(user)
348+
# Validate target_host to prevent command injection
349+
if not re.match(r'^[a-zA-Z0-9.\-_@:]+$', target_host):
350+
return "Security block: Invalid target host characters.", 400
335351
identity_pub_path = os.path.join(UUID_DIR, safe_uuid, safe_user, "identity.pub")
336352
if not os.path.exists(identity_pub_path): return "Identity not found", 404
337-
353+
338354
try:
355+
# Escape single quotes in password to prevent shell injection
356+
safe_password = password.replace("'", "'\\''")
339357
with tempfile.NamedTemporaryFile(mode='w', delete=False) as tf:
340-
tf.write(f"#!/bin/sh\necho '{password}'\n")
358+
tf.write(f"#!/bin/sh\necho '{safe_password}'\n")
341359
askpass_path = tf.name
342360
os.chmod(askpass_path, 0o700)
343361

@@ -352,7 +370,7 @@ def deploy_key():
352370

353371
session['last_output'] = result.stderr + "\n" + result.stdout
354372
session['last_status'] = 'success' if result.returncode == 0 else 'error'
355-
return redirect(url_for('index', token=AUTH_TOKEN))
373+
return redirect(url_for('index'))
356374
except Exception as e:
357375
if 'askpass_path' in locals() and os.path.exists(askpass_path): os.unlink(askpass_path)
358376
return f"Error: {e}", 500
@@ -418,7 +436,7 @@ def create_template():
418436
key_type = "ed25519"
419437
key_path = os.path.join(template_path, f"id_{key_type}")
420438
subprocess.run(['ssh-keygen', '-t', key_type, '-f', key_path, '-N', '', '-C', f"template:{safe_name}"], check=True, capture_output=True)
421-
return redirect(url_for('index', token=AUTH_TOKEN, view='templates'))
439+
return redirect(url_for('index', view='templates'))
422440
except Exception as e: return f"Error: {e}", 500
423441

424442
@app.route('/api/templates/<name>', methods=['DELETE'])
@@ -716,8 +734,13 @@ if __name__ == '__main__':
716734
else:
717735
print(" -> WebSockets DISABLED (Legacy mode)")
718736

737+
# Restrict SocketIO CORS to localhost on the actual port
738+
if SOCKETIO_AVAILABLE:
739+
allowed = [f"http://127.0.0.1:{PORT}", f"http://localhost:{PORT}"]
740+
socketio.server.cors_allowed_origins = allowed
741+
719742
threading.Timer(1.0, open_browser, args=[PORT]).start()
720-
743+
721744
# Update app port
722745
if SOCKETIO_AVAILABLE:
723746
socketio.run(app, host=HOST, port=PORT, debug=False)

‎lib/ui/app.js‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ var termHandle = null;
33
var socket = null;
44
var fitAddon = null;
55

6+
// HTML escaping to prevent XSS
7+
function escapeHtml(str) {
8+
if (!str) return '';
9+
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
10+
}
11+
612
// Initialize when DOM is ready
713
document.addEventListener('DOMContentLoaded', function () {
814
if (window.identitiesList) {
@@ -58,7 +64,7 @@ function rotateUserKey(uuid, user) {
5864
var formData = new FormData();
5965
formData.append('uuid', uuid);
6066
formData.append('user', user);
61-
fetch('/api/user/rotate?token=' + window.authToken, { method: 'POST', body: formData })
67+
fetch('/api/user/rotate', { method: 'POST', body: formData })
6268
.then(response => {
6369
if (response.ok) {
6470
alert("Rotation successful!");
@@ -76,7 +82,7 @@ function deleteUser(uuid, user) {
7682
var formData = new FormData();
7783
formData.append('uuid', uuid);
7884
formData.append('user', user);
79-
fetch('/api/user/delete?token=' + window.authToken, { method: 'POST', body: formData })
85+
fetch('/api/user/delete', { method: 'POST', body: formData })
8086
.then(response => {
8187
if (response.ok) { window.location.reload(); } else { response.text().then(t => alert("Error: " + t)); }
8288
})
@@ -87,13 +93,13 @@ function deleteUser(uuid, user) {
8793
function fetchTemplates() {
8894
var el = document.getElementById('templateList');
8995
el.innerHTML = 'Loading...';
90-
fetch('/api/templates?token=' + window.authToken)
96+
fetch('/api/templates')
9197
.then(res => res.json())
9298
.then(data => {
9399
if (data.length === 0) { el.innerText = "No templates found."; return; }
94100
var html = '<table class="table"><thead><tr><th>Name</th><th>Keys</th><th>Actions</th></tr></thead><tbody>';
95101
data.forEach(t => {
96-
html += `<tr><td><strong>${t.name}</strong></td><td>${t.keys.join(', ')}</td><td style="text-align:right;"><button class="btn-red" onclick="deleteTemplate('${t.name}')" style="padding:4px 8px;">Delete</button></td></tr>`;
102+
html += `<tr><td><strong>${escapeHtml(t.name)}</strong></td><td>${escapeHtml(t.keys.join(', '))}</td><td style="text-align:right;"><button class="btn-red" onclick="deleteTemplate('${escapeHtml(t.name)}')" style="padding:4px 8px;">Delete</button></td></tr>`;
97103
});
98104
html += '</tbody></table>';
99105
el.innerHTML = html;
@@ -102,21 +108,21 @@ function fetchTemplates() {
102108

103109
function deleteTemplate(name) {
104110
if (!confirm("Delete template " + name + "?")) return;
105-
fetch('/api/templates/' + name + '?token=' + window.authToken, { method: 'DELETE' }).then(r => fetchTemplates());
111+
fetch('/api/templates/' + name, { method: 'DELETE' }).then(r => fetchTemplates());
106112
}
107113

108114
// --- History ---
109115
function fetchHistory() {
110116
var el = document.getElementById('historyList');
111117
el.innerHTML = '<tr><td colspan="5">Loading...</td></tr>';
112-
fetch('/api/history?token=' + window.authToken)
118+
fetch('/api/history')
113119
.then(res => res.json())
114120
.then(data => {
115121
var html = '';
116122
if (data.length === 0) html = '<tr><td colspan="5">No history.</td></tr>';
117123
else {
118124
data.forEach(e => {
119-
html += `<tr><td>${e.ts.replace('T', ' ').replace('Z', '')}</td><td>${e.user}</td><td><strong>${e.action}</strong></td><td>${e.target}</td><td>${e.details}</td></tr>`;
125+
html += `<tr><td>${escapeHtml(e.ts.replace('T', ' ').replace('Z', ''))}</td><td>${escapeHtml(e.user)}</td><td><strong>${escapeHtml(e.action)}</strong></td><td>${escapeHtml(e.target)}</td><td>${escapeHtml(e.details)}</td></tr>`;
120126
});
121127
}
122128
el.innerHTML = html;
@@ -140,7 +146,7 @@ function openInfoModal(uuid) {
140146
tbody.innerHTML = '';
141147
if (data.host_keys && data.host_keys.length > 0) {
142148
data.host_keys.forEach(k => {
143-
tbody.innerHTML += `<tr><td>${k.type}</td><td style="font-family:monospace; word-break:break-all;">${k.key}</td></tr>`;
149+
tbody.innerHTML += `<tr><td>${escapeHtml(k.type)}</td><td style="font-family:monospace; word-break:break-all;">${escapeHtml(k.key)}</td></tr>`;
144150
});
145151
} else { tbody.innerHTML = '<tr><td colspan="2" style="text-align:center;">No keys</td></tr>'; }
146152
document.getElementById('infoModal').style.display = 'block';
@@ -318,7 +324,7 @@ function prepareConnect(uuid, user) {
318324
document.getElementById('connect_user').value = user;
319325
aliases.forEach((alias, index) => {
320326
var checked = index === 0 ? 'checked' : '';
321-
container.innerHTML += `<div style="margin-bottom:8px;"><label style="display:flex;align-items:center;cursor:pointer;"><input type="radio" name="connect_host" value="${alias}" ${checked} style="margin-right:8px;"> ${alias}</label></div>`;
327+
container.innerHTML += `<div style="margin-bottom:8px;"><label style="display:flex;align-items:center;cursor:pointer;"><input type="radio" name="connect_host" value="${escapeHtml(alias)}" ${checked} style="margin-right:8px;"> ${escapeHtml(alias)}</label></div>`;
322328
});
323329
document.getElementById('connectModal').style.display = 'block';
324330
}
@@ -346,7 +352,7 @@ function triggerConnect(user, host) {
346352
var formData = new FormData();
347353
formData.append('user', user);
348354
formData.append('host', host);
349-
fetch('/connect?token=' + window.authToken, { method: 'POST', body: formData })
355+
fetch('/connect', { method: 'POST', body: formData })
350356
.then(response => {
351357
if (response.ok) { } else { response.text().then(t => alert("Error: " + t)); }
352358
})

‎lib/ui/index.html‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ <h1 style="margin:0;">SSH Key Manager</h1>
3333
<div class="nav-item" onclick="switchView('history')" id="nav-history">History</div>
3434

3535
<div class="nav-right">
36-
<a href="/logout?token={{ token }}" class="btn" style="background-color: #666; color: white;">Logout</a>
36+
<a href="/logout" class="btn" style="background-color: #666; color: white;">Logout</a>
3737
</div>
3838
</div>
3939

@@ -182,7 +182,7 @@ <h2>Template Management</h2>
182182
<div
183183
style="background: #fafafa; padding: 15px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 20px;">
184184
<strong>Create New Template</strong>
185-
<form action="/api/templates?token={{ token }}" method="POST"
185+
<form action="/api/templates" method="POST"
186186
style="margin-top: 10px; display: flex; gap: 10px; max-width: 500px;">
187187
<input type="text" name="name" placeholder="Template Name (e.g. 'work')" required
188188
style="flex-grow: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
@@ -223,7 +223,7 @@ <h2>Operation History</h2>
223223
<div class="modal-content">
224224
<h3>Add New Identity</h3>
225225
<!-- Updated form to support Xterm Interception -->
226-
<form action="/create?token={{ token }}" method="POST" onsubmit="handleCreateSubmit(event)">
226+
<form action="/create" method="POST" onsubmit="handleCreateSubmit(event)">
227227
<div style="margin-bottom: 10px;">
228228
<label>Target Host (user@hostname):</label><br>
229229
<input type="text" name="user_host" id="create_user_host" required
@@ -258,7 +258,7 @@ <h3>Add New Identity</h3>
258258
<div class="modal-content">
259259
<h3>Deploy Key</h3>
260260
<p>Deploy key for <strong id="deploy_user"></strong> to host?</p>
261-
<form action="/deploy?token={{ token }}" method="POST">
261+
<form action="/deploy" method="POST">
262262
<input type="hidden" name="uuid" id="deploy_uuid">
263263
<input type="hidden" name="user" id="deploy_user_input">
264264
<input type="hidden" name="target_host" id="deploy_target_host">
@@ -354,7 +354,7 @@ <h3>Execution Result: {{ result_status|default('info')|upper }}</h3>
354354
<!-- Initialization and External JS -->
355355
<script>
356356
window.identitiesList = {{ identities | tojson }};
357-
window.authToken = "{{ token }}";
357+
window.authToken = ""; // Auth is now handled via HTTP-only cookie
358358
window.xtermEnabled = {{ 'true' if xterm_enabled else 'false' }};
359359
</script>
360360
<script src="/assets/app.js"></script>

0 commit comments

Comments
 (0)