Files
gerbeur/api/routes/ws.ts
khannurien 2ad8ae73d5 v3: the API stops trusting what it's handed — a review of api/ turned up a server anyone could crash, uploads that ran script on the app's origin, and a server that would fetch its own network on request, and this closes them along with the smaller holes around them. A WebSocket message that wasn't an object (a bare null will do) threw inside the listener, which Deno treats as uncaught and exits on; messages are now shape-checked and the handler can't throw out. Uploaded files kept the MIME type the uploader's browser claimed, so an .html or .svg dump opened from /api/files ran with the session token in reach; only images, audio, video, PDF and plain text are served inline now, the rest download, and every file carries nosniff and a sandboxing CSP (PDFs aside, which Chrome won't render sandboxed). The image proxy passed image/svg+xml straight through the same way and now takes raster formats only. Outbound fetches for previews, the proxy and Bandcamp checked the hostname against a partial list — no 169.254.169.254, no 0.0.0.0, an IPv6 check that never matched a bracketed hostname, nothing for names that resolve inward, and redirects followed blindly; every hop now resolves and is refused if any address is private or reserved, redirects are walked by hand, the curl fallback is pinned to the checked IP, and /api/preview needs a login. PATCH /users/me accepted a new password without the current one and now takes only the description and email; a wrong password at login answered 500 because the handler caught its own 401, and now says 401. Changing or resetting a password now ends every older session through users.tokens_valid_after (migration 0015) and an iat on every token, with change-password handing back fresh tokens the modal keeps, and all token checks go through one authenticate() that also refuses deleted users. Invites and reset links are claimed atomically before the async hash rather than marked after, so one invite no longer opens several accounts, and an email address belongs to one account. File and thumbnail URLs carry a media-only token rather than the session token, so a URL in a log exposes files and not the account; older sessions fetch one on load. Private things stop leaking into broadcasts: private playlist updates go to their owner, votes and likes on someone else's private dump 404 and their counts go to the owner alone, and a private dump can't be slipped into a playlist. Smaller: selfProvider only claims this site's own host, so github.com/users/bob stops rendering our bob; the chat read marker can't be parked in the future on an empty chat; dump mutations use the resolved id so a slug works; multipart dumps get the title and comment limits; logins and reset requests are rate-limited through a shared token bucket; the password compare is constant-time; ffmpeg reads only local files in known video containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:07:33 +00:00

450 lines
12 KiB
TypeScript

import { Router } from "@oak/oak";
import { ALLOWED_ORIGINS } from "../config.ts";
import { authenticate } from "../lib/auth.ts";
import {
broadcastChatMessage,
broadcastChatMessageDeleted,
broadcastChatMessageUpdated,
broadcastCommentLikeUpdate,
broadcastPresence,
broadcastTyping,
broadcastVoteUpdate,
getOnlineUsers,
handleClientPong,
isUserChatOpen,
type RateLimit,
register,
sendToUser,
setClientTyping,
takeToken,
unregister,
type WsClient,
} from "../services/ws-service.ts";
import {
createChatMessage,
deleteChatMessage,
getChatReadState,
markChatRead,
updateChatMessage,
} from "../services/chat-service.ts";
import { notifyMentions } from "../services/notification-service.ts";
import { hasPermission } from "../lib/permissions.ts";
import {
castVote,
getUserVotes,
removeVote,
} from "../services/vote-service.ts";
import {
castCommentLike,
getUserCommentLikes,
removeCommentLike,
} from "../services/comment-like-service.ts";
import { getUnreadCount } from "../services/notification-service.ts";
import { getUserById } from "../services/user-service.ts";
import {
APIException,
type ClientToServerMessage,
type User,
} from "../model/interfaces.ts";
const router = new Router();
function isAllowedOrigin(origin: string): boolean {
return ALLOWED_ORIGINS.includes(origin);
}
router.get("/ws", async (ctx) => {
const origin = ctx.request.headers.get("origin") ?? "";
if (!isAllowedOrigin(origin)) {
ctx.response.status = 403;
return;
}
if (!ctx.isUpgradable) {
ctx.response.status = 426;
return;
}
// authenticate() refuses a token whose user is gone or which predates a
// password change, and reads the live role from the DB. Such a socket is
// simply anonymous; the client's next API call gets the 401.
const token = ctx.request.url.searchParams.get("token");
const authPayload = token ? await authenticate(token) : null;
let user: User | undefined;
try {
user = authPayload ? getUserById(authPayload.userId) : undefined;
} catch {
user = undefined;
}
const socket = ctx.upgrade();
const client: WsClient = {
socket,
userId: user?.id,
username: user?.username,
role: user?.role,
avatarMime: user?.avatarMime,
};
// Use addEventListener — more reliable than onopen= with Deno.serve
socket.addEventListener("open", () => {
register(client);
broadcastPresence();
try {
const myVotes = user ? getUserVotes(user.id) : [];
const myCommentLikes = user ? getUserCommentLikes(user.id) : [];
const unreadNotificationCount = user ? getUnreadCount(user.id) : 0;
const chatRead = user ? getChatReadState(user.id) : undefined;
socket.send(JSON.stringify({
type: "welcome",
users: getOnlineUsers(),
myVotes,
myCommentLikes,
unreadNotificationCount,
chatReadAt: chatRead?.readAt,
unreadChatCount: chatRead?.unread ?? 0,
}));
} catch (err) {
console.error("[ws] welcome send failed:", err);
}
});
socket.addEventListener("message", (event) => {
// An exception escaping a WebSocket listener is uncaught in Deno and ends
// the process, so nothing a client sends may throw out of here.
try {
handleMessage(client, event.data);
} catch (err) {
console.error("[ws] message handler failed:", err);
}
});
socket.addEventListener("close", () => {
unregister(client);
broadcastPresence();
// If they were mid-compose, drop them from the typing set right away.
if (client.typingAt) broadcastTyping();
});
});
function handleMessage(client: WsClient, data: unknown): void {
if (typeof data !== "string") return;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
return;
}
// Anything but an object with a string `type` (`null`, a number, an array)
// is ignored rather than read from.
if (
typeof parsed !== "object" || parsed === null ||
typeof (parsed as { type?: unknown }).type !== "string"
) return;
const msg = parsed as ClientToServerMessage;
const { socket } = client;
switch (msg.type) {
case "ping":
send(socket, { type: "pong" });
break;
case "pong":
handleClientPong(client);
break;
case "vote_cast":
handleVote(client, msg.dumpId, "cast");
break;
case "vote_remove":
handleVote(client, msg.dumpId, "remove");
break;
case "comment_like_cast":
handleCommentLike(client, msg.commentId, "cast");
break;
case "comment_like_remove":
handleCommentLike(client, msg.commentId, "remove");
break;
case "chat_send":
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
break;
case "chat_typing":
// Typing is cosmetic: past the limit it's just dropped.
if (
client.userId &&
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
) {
setClientTyping(client, msg.typing === true);
}
break;
case "chat_focus":
client.chatOpen = msg.open === true;
break;
case "chat_edit":
handleChatEdit(client, msg.id, msg.body);
break;
case "chat_delete":
handleChatDelete(client, msg.id);
break;
case "chat_read":
handleChatRead(client, msg.at);
break;
}
}
// Sends only while the socket is open: `send` on a closing socket throws.
function send(socket: WebSocket, data: Record<string, unknown>): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
}
function handleVote(
client: WsClient,
dumpId: unknown,
action: "cast" | "remove",
): void {
const { socket } = client;
if (!client.userId) {
send(socket, { type: "error", message: "Authentication required" });
return;
}
if (typeof dumpId !== "string") return;
try {
const { voteCount, isPrivate } = action === "cast"
? castVote(dumpId, client.userId)
: removeVote(dumpId, client.userId);
send(socket, {
type: "vote_ack",
dumpId,
action,
success: true,
voteCount,
});
// Only a private dump's owner can vote on it, so they're its whole
// audience.
broadcastVoteUpdate(
dumpId,
voteCount,
client.userId,
action,
isPrivate ? client.userId : undefined,
);
} catch (err) {
const message = err instanceof APIException ? err.message : "Vote failed";
send(socket, { type: "error", message, dumpId, action });
}
}
function handleCommentLike(
client: WsClient,
commentId: unknown,
action: "cast" | "remove",
): void {
const { socket } = client;
if (!client.userId) {
send(socket, { type: "error", message: "Authentication required" });
return;
}
if (typeof commentId !== "string") return;
try {
const { likeCount, isPrivate } = action === "cast"
? castCommentLike(commentId, client.userId)
: removeCommentLike(commentId, client.userId);
send(socket, {
type: "comment_like_ack",
commentId,
action,
likeCount,
});
broadcastCommentLikeUpdate(
commentId,
likeCount,
client.userId,
action,
isPrivate ? client.userId : undefined,
);
} catch (err) {
const message = err instanceof APIException ? err.message : "Like failed";
send(socket, { type: "error", message, commentId, action });
}
}
// Sending, editing and deleting share one bucket: a burst of 8, then one every
// 1.5s — room for a quick back-and-forth, not for a flood.
const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 };
const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 };
// Read markers are cheap for the client to send often; past this they're
// dropped, and the next one catches up.
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
function sendChatError(
client: WsClient,
message: string,
nonce?: string,
): void {
send(client.socket, { type: "error", scope: "chat", message, nonce });
}
// Validates the common preconditions of a chat write: signed in and under the
// rate limit. Returns the user id, or undefined after reporting why not.
function chatWriter(client: WsClient, nonce?: string): string | undefined {
if (!client.userId) {
sendChatError(client, "Authentication required", nonce);
return undefined;
}
if (!takeToken(`chat:${client.userId}`, CHAT_WRITE_LIMIT)) {
sendChatError(
client,
"You're sending messages too fast — wait a moment",
nonce,
);
return undefined;
}
return client.userId;
}
function optionalString(value: unknown, maxLength: number): string | undefined {
return typeof value === "string" && value.length <= maxLength
? value
: undefined;
}
function handleChatSend(
client: WsClient,
body: unknown,
replyToId: unknown,
rawNonce: unknown,
): void {
const nonce = optionalString(rawNonce, 64);
const userId = chatWriter(client, nonce);
if (!userId) return;
if (typeof body !== "string") {
sendChatError(client, "Message cannot be empty", nonce);
return;
}
try {
const message = createChatMessage(
userId,
body,
optionalString(replyToId, 64),
);
// Sending a message ends composition — clear the typing flag before the
// message broadcast so the indicator doesn't linger next to it.
setClientTyping(client, false);
// Broadcast to everyone including the sender, whose pending copy the nonce
// retires.
broadcastChatMessage(message, nonce);
// Skip mention notifications for users who already have the chat open —
// they've seen the message live.
notifyMentions(
userId,
message.body,
"chat",
message.id,
"",
undefined,
isUserChatOpen,
);
} catch (err) {
sendChatError(
client,
err instanceof APIException ? err.message : "Failed to send message",
nonce,
);
}
}
// Reads the role from the DB on every moderation check rather than trusting the
// one captured at connect: a demoted moderator loses the power right away, not
// whenever their socket next reconnects.
function canModerateChat(client: WsClient): boolean {
if (!client.userId) return false;
try {
client.role = getUserById(client.userId).role;
} catch {
client.role = undefined;
}
return !!client.role && hasPermission(client.role, "chat:moderate");
}
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
const userId = chatWriter(client);
if (!userId) return;
if (typeof id !== "string" || typeof body !== "string") {
sendChatError(client, "Invalid message");
return;
}
try {
const message = updateChatMessage(
id,
body,
userId,
canModerateChat(client),
);
broadcastChatMessageUpdated(message);
// A newly added @mention in an edit should still notify (deduped per
// message+user), skipping anyone already reading the chat.
notifyMentions(
userId,
message.body,
"chat",
message.id,
"",
undefined,
isUserChatOpen,
);
} catch (err) {
sendChatError(
client,
err instanceof APIException ? err.message : "Failed to edit message",
);
}
}
function handleChatDelete(client: WsClient, id: unknown): void {
const userId = chatWriter(client);
if (!userId) return;
if (typeof id !== "string") {
sendChatError(client, "Invalid message");
return;
}
try {
deleteChatMessage(id, userId, canModerateChat(client));
broadcastChatMessageDeleted(id);
} catch (err) {
sendChatError(
client,
err instanceof APIException ? err.message : "Failed to delete message",
);
}
}
// Moves the user's read marker and tells all their sockets, so every tab and
// device drops its badge together.
function handleChatRead(client: WsClient, at: unknown): void {
if (!client.userId || typeof at !== "string") return;
if (!takeToken(`read:${client.userId}`, CHAT_READ_LIMIT)) return;
try {
const state = markChatRead(client.userId, at);
sendToUser(client.userId, {
type: "chat_read_update",
readAt: state.readAt,
unread: state.unread,
});
} catch {
// A malformed marker changes nothing; there's nothing to report.
}
}
export default router;