Skip to content

Commit 5a74205

Browse files
authored
chore: security updates and HTML error pages (#475)
* chore(security): update SECURITY.md and bump webssh2_client to 3.1.0 - Update supported version from 2.6.x to 3.1.x - Add solid-js/seroval vulnerability assessment (CVE-2026-23737, CVE-2025-27109) - Refresh Shai-hulud 2.0 IoC scan dates - Bump webssh2_client dependency from ^3.0.0 to ^3.1.0 * feat(routes): add HTML error pages for browser clients - Add content negotiation for error responses (HTML vs JSON) - Create styled error page template with retry button for auth errors - Update express-adapter to render HTML for browser requests - Document error response format and content negotiation in ROUTES.md - Update AUTHENTICATION.md with error handling details
1 parent 9a9077b commit 5a74205

File tree

7 files changed

+361
-14
lines changed

7 files changed

+361
-14
lines changed

DOCS/api/ROUTES.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,53 @@ http://localhost:2222/ssh/host/example.com?port=2244&sshterm=xterm-256color&env=
179179
| 401 | Unauthorized (authentication required or failed) |
180180
| 404 | Not Found (invalid route) |
181181
| 500 | Internal Server Error |
182+
| 502 | Bad Gateway (SSH server unreachable) |
183+
| 504 | Gateway Timeout (SSH connection timed out) |
184+
185+
## Error Response Format
186+
187+
WebSSH2 uses content negotiation to return appropriate error responses based on the client type.
188+
189+
### Browser Requests (Accept: text/html)
190+
191+
When a browser requests a page and an SSH error occurs, WebSSH2 returns a styled HTML error page with:
192+
- Error title and description
193+
- Host and port details
194+
- "Try Again" button (for 401 authentication errors)
195+
196+
This provides a user-friendly experience instead of raw JSON.
197+
198+
### API Requests (Accept: application/json)
199+
200+
API clients receive JSON error responses:
201+
202+
```json
203+
{
204+
"error": "Connection failed",
205+
"message": "Handshake failed: no matching key exchange algorithm",
206+
"host": "example.com",
207+
"port": 22
208+
}
209+
```
210+
211+
### Content Negotiation
212+
213+
The response format is determined by the `Accept` header:
214+
- If `text/html` appears before `application/json` → HTML error page
215+
- Otherwise → JSON response
216+
217+
**Examples:**
218+
219+
```bash
220+
# Browser request - gets HTML error page
221+
curl -H "Accept: text/html" \
222+
"http://localhost:2222/ssh/host/example.com"
223+
224+
# API request - gets JSON response
225+
curl -H "Accept: application/json" \
226+
-u "user:pass" \
227+
"http://localhost:2222/ssh/host/example.com"
228+
```
182229

183230
## Migration Guide
184231

DOCS/features/AUTHENTICATION.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,33 @@ if (!validationResult.success) {
139139

140140
This approach:
141141
- ✅ Prevents invalid credentials from reaching WebSocket layer
142-
- ✅ Provides immediate feedback on authentication failures
142+
- ✅ Provides immediate feedback on authentication failures
143143
- ✅ Follows HTTP standards for proper status codes
144144
- ✅ Differentiates between auth failures and network issues
145145
- ✅ Prevents unnecessary re-authentication attempts for network problems
146146

147+
#### Error Response Format
148+
149+
WebSSH2 uses content negotiation to provide appropriate error responses:
150+
151+
**Browser Requests** (`Accept: text/html`):
152+
- Returns a styled HTML error page
153+
- Shows error title, message, and connection details
154+
- Includes "Try Again" button for 401 errors
155+
156+
**API Requests** (`Accept: application/json`):
157+
- Returns JSON with error details:
158+
```json
159+
{
160+
"error": "Authentication failed",
161+
"message": "All configured authentication methods failed",
162+
"host": "example.com",
163+
"port": 22
164+
}
165+
```
166+
167+
This ensures browsers display user-friendly error pages while API clients receive machine-readable JSON.
168+
147169
## Migration Guide: Basic Auth → POST Auth
148170

149171
### Why Migrate?
@@ -299,7 +321,9 @@ curl -u "username:password" "http://localhost:2222/ssh/host/example.com"
299321
- **200 OK**: SSH credentials valid, terminal interface served
300322
- **401 Unauthorized**: SSH credentials invalid, includes `WWW-Authenticate` header
301323
- **400 Bad Request**: Malformed request or invalid parameters
302-
- **500 Internal Server Error**: Server-side issues (SSH connection problems, etc.)
324+
- **500 Internal Server Error**: Server-side issues
325+
- **502 Bad Gateway**: SSH server unreachable (network/DNS issues)
326+
- **504 Gateway Timeout**: SSH connection timed out
303327

304328
### Headers
305329

SECURITY.md

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ We currently support only the latest released version of WebSSH2 with security u
66

77
| Version | Supported |
88
| ------- | ------------------ |
9-
| 2.6.x | :white_check_mark: |
10-
| < 2.6.0 | :x: |
9+
| 3.1.x | :white_check_mark: |
10+
| < 3.1.0 | :x: |
1111

1212
**We strongly recommend always using the latest release to ensure you have the most recent security patches and improvements.**
1313

@@ -55,9 +55,43 @@ When deploying WebSSH2:
5555

5656
Thank you for helping keep WebSSH2 and its users secure!
5757

58+
## Solid-js and Seroval vulnerability assessment
59+
60+
As of 2026-01-27, we evaluated the following vulnerabilities affecting our client dependencies:
61+
62+
### CVE-2026-23737 (Seroval RCE)
63+
64+
| Aspect | Status |
65+
|--------|--------|
66+
| Affected versions | seroval < 1.4.1 |
67+
| Our version | seroval@1.5.0 (transitive via solid-js) |
68+
| Status | **Not vulnerable** - already on patched version |
69+
70+
This vulnerability affects the `fromJSON` and `fromCrossJSON` functions in client-to-server transmission scenarios, requiring Solid Start server functions to exploit.
71+
72+
**Why we are not affected:**
73+
- webssh2_client is a plain Solid.js SPA, not a Solid Start application
74+
- No `"use server"` directives or server functions are used
75+
- All client-server communication uses Socket.IO's native JSON serialization
76+
- seroval is only a transitive dependency and is not directly imported or used
77+
78+
### CVE-2025-27109 (Solid-js XSS)
79+
80+
| Aspect | Status |
81+
|--------|--------|
82+
| Vulnerability type | Cross-site Scripting (XSS) |
83+
| Status | **Not vulnerable** - safe coding patterns used |
84+
85+
**Why we are not affected:**
86+
- No `innerHTML` or `dangerouslySetInnerHTML` usage in the codebase
87+
- All JSX uses Solid.js safe text binding
88+
- Terminal output is rendered through xterm.js which safely handles escape sequences
89+
90+
---
91+
5892
## Shai-hulud 2.0 supply chain risk
5993

60-
As of 2025-12-03, automated checks for Shai-hulud 2.0 indicators of compromise (IoCs) found **no evidence of compromise** in this repository.
94+
As of 2026-01-27, automated checks for Shai-hulud 2.0 indicators of compromise (IoCs) found **no evidence of compromise** in this repository.
6195

6296
The scanner performed the following checks:
6397

@@ -85,6 +119,6 @@ For more information about detection logic or mitigations, contact the security
85119

86120
---
87121

88-
**Last updated:** 2025-12-03
122+
**Last updated:** 2026-01-27
89123

90-
**Next review:** 2026-01-03
124+
**Next review:** 2026-02-27

app/routes/adapters/express-adapter.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { AuthSession } from '../../auth/auth-utils.js'
88
import { createNamespacedDebug } from '../../logger.js'
99
import { HTTP } from '../../constants/index.js'
1010
import type { SshRouteResponse, SshRouteRequest } from '../handlers/ssh-handler.js'
11+
import { renderErrorPage } from '../templates/error-page.js'
1112

1213
const debug = createNamespacedDebug('routes:adapter')
1314

@@ -30,6 +31,43 @@ export const extractRouteRequest = (req: ExpressRequest): SshRouteRequest => {
3031
}
3132
}
3233

34+
/**
35+
* Check if an error response data object has the expected structure
36+
*/
37+
const isErrorResponseData = (
38+
data: unknown
39+
): data is { error: string; message: string; host: string; port: number } => {
40+
if (data === null || typeof data !== 'object') {
41+
return false
42+
}
43+
const obj = data as Record<string, unknown>
44+
return (
45+
typeof obj['error'] === 'string' &&
46+
typeof obj['message'] === 'string' &&
47+
typeof obj['host'] === 'string' &&
48+
typeof obj['port'] === 'number'
49+
)
50+
}
51+
52+
/**
53+
* Check if request prefers HTML response based on Accept header
54+
*/
55+
const prefersHtml = (req: Request): boolean => {
56+
const acceptHeader = req.get('accept') ?? ''
57+
// Check if text/html appears before application/json in Accept header
58+
// or if text/html is present and application/json is not
59+
const htmlIndex = acceptHeader.indexOf('text/html')
60+
const jsonIndex = acceptHeader.indexOf('application/json')
61+
62+
if (htmlIndex === -1) {
63+
return false
64+
}
65+
if (jsonIndex === -1) {
66+
return true
67+
}
68+
return htmlIndex < jsonIndex
69+
}
70+
3371
/**
3472
* Apply route response to Express response
3573
*/
@@ -53,8 +91,27 @@ export const applyRouteResponse = (
5391
// Send response
5492
res.status(response.status)
5593

56-
// For 502 Bad Gateway, send plain text for compatibility with tests
57-
if (response.status === 502) {
94+
// Check if this is an error response that should be rendered as HTML for browsers
95+
const isErrorStatus = response.status >= 400
96+
const req = res.req
97+
const acceptsHtml = prefersHtml(req)
98+
99+
if (isErrorStatus && acceptsHtml && isErrorResponseData(response.data)) {
100+
// Browser request with error - return HTML error page
101+
const showRetry = response.status === HTTP.UNAUTHORIZED
102+
const html = renderErrorPage({
103+
title: response.data.error,
104+
message: response.data.message,
105+
host: response.data.host,
106+
port: response.data.port,
107+
showRetry
108+
})
109+
res.type('text/html').send(html)
110+
return
111+
}
112+
113+
// For 502 Bad Gateway without proper error data, send plain text for compatibility
114+
if (response.status === HTTP.BAD_GATEWAY && !isErrorResponseData(response.data)) {
58115
res.send('Bad Gateway')
59116
return
60117
}

0 commit comments

Comments
 (0)