Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ services:
image: ${IMAGE_NAME:-ghcr.io/team-triple/triple_yjs}:${IMAGE_TAG:-develop}
ports:
- "1234:1234"
env_file:
- .env
environment:
PORT: "1234"
SECONDARY_JWT_SECRET: "change-me"
Y_LEVELDB_PATH: "/data/y-leveldb"
MAX_USERS_PER_ROOM: "20"
volumes:
Expand Down
31 changes: 12 additions & 19 deletions src/collaboration/SecondaryTokenAuthenticator.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,24 @@ export class SecondaryTokenAuthenticator {
};
}

const rawTravelDocId = this.extractTravelDocId(req);
if (!rawTravelDocId) {
const rawTravelDocId = payload?.travelItineraryId;
if (typeof rawTravelDocId !== "string" && typeof rawTravelDocId !== "number") {
return {
ok: false,
statusCode: 400,
message: "Missing travelItineraryId query parameter"
statusCode: 401,
message: "Secondary token must include travelItineraryId"
};
}

const userId = String(rawUserId);
const travelDocId = rawTravelDocId;
const travelDocId = String(rawTravelDocId).trim();
if (!travelDocId) {
return {
ok: false,
statusCode: 401,
message: "Secondary token must include travelItineraryId"
};
}

return {
ok: true,
Expand Down Expand Up @@ -83,20 +90,6 @@ export class SecondaryTokenAuthenticator {
return { ok: true, token };
}

extractTravelDocId(req) {
const travelItineraryId = this.getRequestUrl(req).searchParams.get("travelItineraryId");
if (!travelItineraryId) {
return null;
}

const normalizedTravelItineraryId = travelItineraryId.trim();
if (!normalizedTravelItineraryId) {
return null;
}

return normalizedTravelItineraryId;
}

isSecondaryTokenUsed(token) {
this.pruneUsedSecondaryTokens();
return this.usedSecondaryTokens.has(token);
Expand Down
90 changes: 88 additions & 2 deletions src/collaboration/TravelDocWebSocketServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,40 @@ export class TravelDocWebSocketServer {
start() {
this.server.on("upgrade", this.handleUpgrade.bind(this));
this.wss.on("connection", this.handleConnection.bind(this));
console.log(
`[ws] upgrade handler ready maxUsersPerRoom=${this.maxUsersPerRoom}`
);
}

handleUpgrade(req, socket, head) {
const requestLog = this.getRequestLogContext(req, socket);
console.log(
`[ws upgrade] received url=${requestLog.url} remote=${requestLog.remoteAddress} host=${requestLog.host}`
);

const authResult = this.authenticator.authenticateUpgrade(req);
if (!authResult.ok) {
console.warn(
`[ws upgrade] auth rejected status=${authResult.statusCode} message="${authResult.message}" url=${requestLog.url} remote=${requestLog.remoteAddress}`
);
this.rejectUpgrade(socket, authResult.statusCode, authResult.message);
return;
}

const { travelDocId, userId } = authResult.session;
console.log(
`[ws upgrade] auth accepted userId=${userId} travelDocId=${travelDocId} url=${requestLog.url}`
);

const reservationResult = this.reserveRoomSlot(travelDocId, userId);
console.log(
`[ws upgrade] room reservation result ok=${reservationResult.ok} reserved=${reservationResult.reserved ?? false} userId=${userId} travelDocId=${travelDocId} activeUsers=${this.getRoomUserCount(travelDocId)} pendingUsers=${this.getPendingRoomUserCount(travelDocId)}`
);

if (!reservationResult.ok) {
console.warn(
`[ws upgrade] room rejected maxUsersPerRoom=${this.maxUsersPerRoom} userId=${userId} travelDocId=${travelDocId}`
);
this.rejectUpgrade(
socket,
403,
Expand All @@ -37,9 +59,15 @@ export class TravelDocWebSocketServer {
if (reservationResult.reserved) {
this.releaseReservedRoomSlot(travelDocId, userId);
}
console.warn(
`[ws upgrade] secondary token rejected as already used userId=${userId} travelDocId=${travelDocId}`
);
this.rejectUpgrade(socket, 401, "Secondary token already used");
return;
}
console.log(
`[ws upgrade] secondary token consumed userId=${userId} travelDocId=${travelDocId}`
);

let reservationSettled = false;
const rollbackReservation = () => {
Expand All @@ -48,6 +76,9 @@ export class TravelDocWebSocketServer {
}
reservationSettled = true;
this.releaseReservedRoomSlot(travelDocId, userId);
console.warn(
`[ws upgrade] room reservation rolled back userId=${userId} travelDocId=${travelDocId} activeUsers=${this.getRoomUserCount(travelDocId)} pendingUsers=${this.getPendingRoomUserCount(travelDocId)}`
);
};
const finalizeReservation = () => {
if (reservationSettled) {
Expand All @@ -69,17 +100,24 @@ export class TravelDocWebSocketServer {
try {
this.wss.handleUpgrade(req, socket, head, ws => {
finalizeReservation();
console.log(
`[ws upgrade] handshake accepted userId=${userId} travelDocId=${travelDocId} url=${requestLog.url}`
);
this.wss.emit("connection", ws, req);
});
} catch {
} catch (error) {
rollbackReservation();
console.error(
`[ws upgrade] handshake failed userId=${userId} travelDocId=${travelDocId} error=${error?.message ?? error}`
);
this.rejectUpgrade(socket, 500, "WebSocket handshake failed");
}
}

handleConnection(ws, req) {
const session = req.session;
if (!session) {
console.error("[ws connection] rejected because session is missing");
ws.close(1011, "session missing");
return;
}
Expand All @@ -90,11 +128,23 @@ export class TravelDocWebSocketServer {
}
this.addRoomParticipant(travelDocId, userId);
this.addUserSession(userId, ws);
console.log(
`[ws connection] connected userId=${userId} travelDocId=${travelDocId} userSessions=${this.getUserSessionCount(userId)} activeUsers=${this.getRoomUserCount(travelDocId)}`
);
this.yWebSocketHandler.handleConnection(ws, req, session);

ws.on("close", () => {
ws.on("close", (code, reason) => {
this.removeUserSession(userId, ws);
this.removeRoomParticipant(travelDocId, userId);
console.log(
`[ws connection] closed userId=${userId} travelDocId=${travelDocId} code=${code} reason="${reason.toString()}" userSessions=${this.getUserSessionCount(userId)} activeUsers=${this.getRoomUserCount(travelDocId)}`
);
});

ws.on("error", error => {
console.error(
`[ws connection] error userId=${userId} travelDocId=${travelDocId} error=${error?.message ?? error}`
);
});
}

Expand Down Expand Up @@ -201,7 +251,43 @@ export class TravelDocWebSocketServer {
}
}

getRoomUserCount(travelDocId) {
return this.roomParticipants.get(travelDocId)?.size ?? 0;
}

getPendingRoomUserCount(travelDocId) {
return this.pendingRoomParticipants.get(travelDocId)?.size ?? 0;
}

getUserSessionCount(userId) {
return this.userSessions.get(userId)?.size ?? 0;
}

getRequestLogContext(req, socket) {
return {
url: this.sanitizeRequestUrl(req),
host: req.headers.host ?? "-",
remoteAddress:
req.headers["x-forwarded-for"] ?? socket.remoteAddress ?? "-"
};
}

sanitizeRequestUrl(req) {
const host = req.headers.host ?? "localhost";
try {
const url = new URL(req.url ?? "/", `http://${host}`);
if (url.searchParams.has("secondaryToken")) {
url.searchParams.set("secondaryToken", "[redacted]");
}
return `${url.pathname}${url.search}`;
} catch {
return req.url ?? "/";
}
Comment on lines +283 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The catch block in sanitizeRequestUrl returns the original req.url if parsing fails. If the URL contains a sensitive secondaryToken and the parser throws an error (e.g., due to a malformed host header or an invalid URL structure), the unredacted token will be logged. It is safer to return a generic string or perform a basic string replacement as a fallback to prevent sensitive data exposure in logs.

Suggested change
} catch {
return req.url ?? "/";
}
} catch {
return (req.url ?? "/").replace(/secondaryToken=[^&]*/g, "secondaryToken=[redacted]");
}

}

rejectUpgrade(socket, statusCode, message) {
console.warn(`[ws upgrade] responding status=${statusCode} message="${message}"`);

let statusText = "Internal Server Error";
if (statusCode === 400) {
statusText = "Bad Request";
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ travelDocWebSocketServer.start();
server.listen(port, () => {
console.log(`HTTP server listening on http://localhost:${port}`);
console.log(
`WebSocket server listening on ws://localhost:${port}?secondaryToken=Bearer%20{jwt}&travelItineraryId={travelItineraryId}`
`WebSocket server listening on ws://localhost:${port}?secondaryToken=Bearer%20{jwt}`
);
console.log(`Y-LevelDB path: ${yLeveldbPath}`);
});
Loading