fix: Distributed API auth hardening — Bearer tokens and URL normalization

- Extend WS upgrade handler to accept Authorization: Bearer tokens as a
  fallback to cookie auth. Remote Sencho instances receive proxied WS
  connections carrying Bearer (no cookie), so the previous cookie-only
  check caused immediate 401 rejections for all proxied log/terminal streams.
- Log token validation failures in authMiddleware (was silently swallowed,
  violating no-empty-catch directive).
- Normalize api_url by stripping trailing slashes in testRemoteConnection,
  the HTTP proxy target, and the WS proxy target to prevent double-slash URLs.
This commit is contained in:
SaelixCode
2026-03-19 15:11:57 -04:00
parent 67c7078128
commit 5932bced36
3 changed files with 13 additions and 5 deletions
+2
View File
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **Fixed:** Remote node authentication failures by updating middleware to support Bearer tokens in WebSocket upgrade handler (node-to-node WS proxy now authenticates correctly on the receiving instance).
- **Fixed:** Node connection testing logic updated to normalize `api_url` trailing slashes before constructing the authenticated HTTP ping URL.
- **Fixed:** Memory leak in `GlobalObservabilityView` SSE mode — log array now capped at 10,000 entries (`.slice(-10000)`) to prevent unbounded accumulation across long sessions.
- **Fixed:** Infinite re-fetch loop in `NodeContext``refreshNodes` useCallback no longer depends on `activeNode` state; replaced with a `useRef` to read current node inside the callback without being a reactive dependency.
- **Fixed:** Infinite page reload loop — `apiFetch` was calling `window.location.href = '/'` on every 401, causing a full browser reload before auth could complete. Replaced with a `sencho-unauthorized` custom event that `AuthContext` handles by setting `appStatus` to `notAuthenticated`.
+9 -4
View File
@@ -123,7 +123,8 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
// Accept both user sessions and node proxy tokens
req.user = { username: decoded.username || 'node-proxy' };
next();
} catch {
} catch (err) {
console.error('[Auth] Token validation failed:', (err as Error).message);
res.status(401).json({ error: 'Invalid or expired token' });
return;
}
@@ -327,7 +328,7 @@ app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
}
createProxyMiddleware<Request, Response>({
target: node.api_url,
target: node.api_url.replace(/\/$/, ''),
changeOrigin: true,
on: {
proxyReq: (proxyReq) => {
@@ -362,7 +363,11 @@ server.on('upgrade', async (req, socket, head) => {
cookieHeader.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v)
);
const token = cookies[COOKIE_NAME];
// Accept either cookie auth (browser sessions) or Bearer token auth (node-to-node WS proxy)
const cookieToken = cookies[COOKIE_NAME];
const authHeader = req.headers['authorization'] as string | undefined;
const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
const token = cookieToken || bearerToken;
if (!token) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
@@ -387,7 +392,7 @@ server.on('upgrade', async (req, socket, head) => {
// Remote Node WebSocket Proxy — forward the entire WS connection to the remote Sencho instance
if (node && node.type === 'remote' && node.api_url && node.api_token) {
const wsTarget = node.api_url.replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
req.headers['authorization'] = `Bearer ${node.api_token}`;
delete req.headers['x-node-id'];
wsProxyServer.ws(req, socket, head, { target: wsTarget });
+2 -1
View File
@@ -154,7 +154,8 @@ export class NodeRegistry {
}
try {
const response = await axios.get(`${node.api_url}/api/auth/check`, {
const baseUrl = node.api_url.replace(/\/$/, '');
const response = await axios.get(`${baseUrl}/api/auth/check`, {
headers: { Authorization: `Bearer ${node.api_token}` },
timeout: 8000,
});