Files
Termix/src/backend/hosts/session-sharing/routes.ts
T
ZacharyZcR 14d4128266 fix: harden file reads and timer cleanup (#1352)
* fix: harden file reads and timer cleanup

* fix: preserve literal file path escapes
2026-08-28 10:36:08 +08:00

495 lines
15 KiB
TypeScript

import crypto from "crypto";
import express, { type Request, type Response } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { sshLogger } from "../../utils/logger.js";
import { sessionManager } from "../terminal/session-manager.js";
import {
isLiveSession,
isLiveSessionOwnedBy,
isSharingEnabledForHost,
} from "./live-sessions.js";
import { GuacamoleTokenService } from "../guacamole/token-service.js";
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const permissionManager = PermissionManager.getInstance();
const tokenService = GuacamoleTokenService.getInstance();
const DEFAULT_EXPIRY_HOURS = 24;
const MAX_EXPIRY_HOURS = 24 * 30;
type Protocol = "ssh" | "rdp" | "vnc" | "telnet";
type PermissionLevel = "read-only" | "read-write";
interface ResolveRateEntry {
count: number;
windowStart: number;
}
const resolveAttempts = new Map<string, ResolveRateEntry>();
function computeExpiresAt(expiryHours: number | undefined): string {
const hours = Math.min(
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
MAX_EXPIRY_HOURS,
);
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
}
const RESOLVE_WINDOW_MS = 60 * 1000;
const RESOLVE_MAX_ATTEMPTS = 30;
function isResolveRateLimited(ip: string): boolean {
const now = Date.now();
const entry = resolveAttempts.get(ip);
if (!entry || now - entry.windowStart > RESOLVE_WINDOW_MS) {
resolveAttempts.set(ip, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
return entry.count > RESOLVE_MAX_ATTEMPTS;
}
setInterval(
() => {
const now = Date.now();
for (const [ip, entry] of resolveAttempts.entries()) {
if (now - entry.windowStart > RESOLVE_WINDOW_MS)
resolveAttempts.delete(ip);
}
},
5 * 60 * 1000,
).unref();
/**
* @openapi
* /session-sharing/create:
* post:
* summary: Create a session share (link or targeted user)
* description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - hostId
* - sessionId
* - protocol
* - shareType
* - permissionLevel
* properties:
* hostId:
* type: integer
* sessionId:
* type: string
* tabInstanceId:
* type: string
* protocol:
* type: string
* enum: [ssh, rdp, vnc, telnet]
* shareType:
* type: string
* enum: [link, user]
* targetUserId:
* type: string
* permissionLevel:
* type: string
* enum: [read-only, read-write]
* expiryHours:
* type: number
* responses:
* 200:
* description: Share created
* 400:
* description: Invalid request
* 403:
* description: Sharing disabled, or caller does not own the session
* 500:
* description: Server error
*/
router.post("/create", authenticateJWT, async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const {
hostId,
sessionId,
tabInstanceId,
protocol,
shareType,
targetUserId,
permissionLevel,
expiryHours,
} = req.body ?? {};
if (!hostId || !sessionId || !protocol || !shareType || !permissionLevel) {
return res.status(400).json({ error: "Missing required fields" });
}
if (!["ssh", "rdp", "vnc", "telnet"].includes(protocol)) {
return res.status(400).json({ error: "Invalid protocol" });
}
if (!["link", "user"].includes(shareType)) {
return res.status(400).json({ error: "Invalid shareType" });
}
if (!["read-only", "read-write"].includes(permissionLevel)) {
return res.status(400).json({ error: "Invalid permissionLevel" });
}
if (shareType === "user" && !targetUserId) {
return res
.status(400)
.json({ error: "targetUserId is required for user shares" });
}
const numericHostId = Number(hostId);
const { enabled: sharingEnabled } =
await isSharingEnabledForHost(numericHostId);
if (!sharingEnabled) {
return res
.status(403)
.json({ error: "Session sharing is disabled for this host" });
}
if (!isLiveSessionOwnedBy(protocol, String(sessionId), userId)) {
return res
.status(403)
.json({ error: "You do not own this live session" });
}
if (shareType === "user") {
const accessInfo = await permissionManager.canAccessHost(
targetUserId,
numericHostId,
"connect",
);
if (!accessInfo.hasAccess) {
return res.status(403).json({
error: "Target user does not have access to this host",
});
}
}
const shareId = crypto.randomUUID();
const linkToken =
shareType === "link"
? crypto.randomBytes(24).toString("base64url")
: null;
const expiresAt = computeExpiresAt(expiryHours);
const created = await createCurrentSessionShareRepository().create({
id: shareId,
hostId: numericHostId,
ownerUserId: userId,
protocol,
sessionId: String(sessionId),
tabInstanceId: tabInstanceId ?? null,
shareType,
targetUserId: shareType === "user" ? targetUserId : null,
linkToken,
permissionLevel,
expiresAt,
});
res.json({
shareId: created.id,
linkToken: created.linkToken,
expiresAt: created.expiresAt,
});
} catch (error) {
sshLogger.error("Failed to create session share", error, {
operation: "session_share_create_error",
});
res.status(500).json({ error: "Failed to create session share" });
}
});
/**
* @openapi
* /session-sharing/host/{hostId}/active:
* get:
* summary: List active session shares for a host
* description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: List of active shares
* 400:
* description: Invalid host id
* 500:
* description: Server error
*/
router.get(
"/host/:hostId/active",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const hostId = Number.parseInt(String(req.params.hostId), 10);
if (!hostId || Number.isNaN(hostId)) {
return res.status(400).json({ error: "Invalid host ID" });
}
const shares =
await createCurrentSessionShareRepository().findActiveSharesForHost(
hostId,
userId,
);
res.json({ shares });
} catch (error) {
sshLogger.error("Failed to list session shares", error, {
operation: "session_share_list_error",
});
res.status(500).json({ error: "Failed to list session shares" });
}
},
);
/**
* @openapi
* /session-sharing/{shareId}:
* delete:
* summary: Revoke a session share
* description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; guac joins are not force-disconnected in v1.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: shareId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Share revoked
* 403:
* description: Not authorized to revoke this share
* 404:
* description: Share not found
* 500:
* description: Server error
*/
router.delete(
"/:shareId",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const shareId = String(req.params.shareId);
const repository = createCurrentSessionShareRepository();
const share = await repository.findById(shareId);
if (!share) {
return res.status(404).json({ error: "Share not found" });
}
let revoked = await repository.revoke(shareId, userId);
if (!revoked) {
if (await permissionManager.isAdmin(userId)) {
revoked = await repository.revokeAsAdmin(shareId);
}
}
if (!revoked) {
return res
.status(403)
.json({ error: "Not authorized to revoke this share" });
}
// Best-effort kick of live participants. SSH sessions support ending
// just the guests via ownerEndSession; guac joins aren't force-kickable
// from a REST handler (guacamole-lite exposes no kick API), so a revoked
// guac link only blocks *future* resolves until the guest's own socket ends.
if (share.protocol === "ssh") {
try {
sessionManager.ownerEndSession(
share.sessionId,
"Session share revoked by owner",
);
} catch {
// best-effort only
}
}
res.json({ success: true });
} catch (error) {
sshLogger.error("Failed to revoke session share", error, {
operation: "session_share_revoke_error",
});
res.status(500).json({ error: "Failed to revoke session share" });
}
},
);
/**
* @openapi
* /session-sharing/resolve/{linkToken}:
* get:
* summary: Resolve a guest share link
* description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
* tags:
* - Session Sharing
* parameters:
* - in: path
* name: linkToken
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Resolved share connection info
* 404:
* description: Link not found, expired, revoked, or sharing disabled
* 429:
* description: Too many requests
* 500:
* description: Server error
*/
router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
try {
const ip = req.ip || req.socket.remoteAddress || "unknown";
if (isResolveRateLimited(ip)) {
return res.status(429).json({ error: "Too many requests" });
}
const linkToken = String(req.params.linkToken);
const repository = createCurrentSessionShareRepository();
const share = await repository.findByLinkToken(linkToken);
if (!share) {
return res.status(404).json({ error: "Link not found or expired" });
}
const { enabled: sharingEnabled } = await isSharingEnabledForHost(
share.hostId,
);
if (!sharingEnabled) {
return res.status(404).json({ error: "Link not found or expired" });
}
const protocol = share.protocol as Protocol;
if (!isLiveSession(protocol, share.sessionId)) {
return res.status(404).json({ error: "Session is no longer active" });
}
// Field-by-field by design - never spread a host row into this response.
// Anonymous guests must never see hostname/IP/username/hostId (decision #5).
const response: {
protocol: Protocol;
permissionLevel: PermissionLevel;
wsPath: string;
connectParams?: Record<string, string>;
} = {
protocol,
permissionLevel: share.permissionLevel as PermissionLevel,
wsPath:
protocol === "ssh"
? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}`
: "/guacamole/websocket/",
};
if (protocol !== "ssh") {
const joinToken = tokenService.createJoinToken(
share.sessionId,
share.permissionLevel === "read-only",
);
response.connectParams = { token: joinToken };
}
try {
await repository.touchShareUsage(share.id);
await repository.recordParticipantJoin(share.id, null, "Guest");
} catch {
// best-effort, never fail the resolve response over audit bookkeeping
}
res.json(response);
} catch (error) {
sshLogger.error("Failed to resolve session share link", error, {
operation: "session_share_resolve_error",
});
res.status(500).json({ error: "Failed to resolve share link" });
}
});
/**
* @openapi
* /session-sharing/{shareId}/end:
* post:
* summary: End a shared session for all participants
* description: Owner-only. Terminates the underlying session and notifies joined participants. Guac protocol kick is best-effort in v1.
* tags:
* - Session Sharing
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: shareId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Session ended
* 403:
* description: Not the owner of this share
* 404:
* description: Share not found
* 500:
* description: Server error
*/
router.post(
"/:shareId/end",
authenticateJWT,
async (req: Request, res: Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const shareId = String(req.params.shareId);
const repository = createCurrentSessionShareRepository();
const share = await repository.findById(shareId);
if (!share) {
return res.status(404).json({ error: "Share not found" });
}
if (share.ownerUserId !== userId) {
return res.status(403).json({ error: "Not the owner of this share" });
}
if (share.protocol === "ssh") {
sessionManager.ownerEndSession(
share.sessionId,
"Session ended by owner",
);
}
// Guac protocols: no kick API available from a REST handler in v1 - see
// DELETE /:shareId for the same limitation.
res.json({ success: true });
} catch (error) {
sshLogger.error("Failed to end shared session", error, {
operation: "session_share_end_error",
});
res.status(500).json({ error: "Failed to end shared session" });
}
},
);
export default router;