feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -21,6 +21,7 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory /
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. | "API key", "JWT" |
## Relationships

View File

@@ -8,9 +8,11 @@
"name": "metoyou-server",
"version": "1.0.0",
"dependencies": {
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"dotenv": "^17.3.1",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"reflect-metadata": "^0.2.2",
"sql.js": "^1.9.0",
"typeorm": "^0.3.28",
@@ -21,6 +23,7 @@
"metoyou-server": "dist/index.js"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.14",
"@types/express": "^4.17.18",
"@types/node": "^20.8.0",
@@ -212,6 +215,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -538,6 +548,15 @@
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -1058,6 +1077,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1383,6 +1420,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -11,7 +11,9 @@
"dev": "ts-node-dev --respawn src/index.ts"
},
"dependencies": {
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"express-rate-limit": "^8.2.1",
"dotenv": "^17.3.1",
"express": "^4.18.2",
"reflect-metadata": "^0.2.2",
@@ -21,6 +23,7 @@
"ws": "^8.14.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.14",
"@types/express": "^4.17.18",
"@types/node": "^20.8.0",

View File

@@ -1,13 +1,53 @@
import express from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { registerRoutes } from './routes';
import { getCorsAllowlist } from './config/variables';
const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many authentication attempts', errorCode: 'RATE_LIMITED' }
});
const joinRateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many join attempts', errorCode: 'RATE_LIMITED' }
});
function buildCorsOptions() {
const allowlist = getCorsAllowlist();
if (allowlist.length === 0) {
return {};
}
return {
origin(origin: string | undefined, callback: (error: Error | null, allow?: boolean) => void) {
if (!origin || allowlist.includes(origin)) {
callback(null, true);
return;
}
callback(new Error('CORS origin not allowed'));
}
};
}
export function createApp(): express.Express {
const app = express();
app.set('trust proxy', true);
app.use(cors());
// Trust loopback proxies only — avoids express-rate-limit ERR_ERL_PERMISSIVE_TRUST_PROXY.
app.set('trust proxy', 'loopback');
app.use(cors(buildCorsOptions()));
app.use(express.json());
app.use('/api/users/login', authRateLimiter);
app.use('/api/users/register', authRateLimiter);
app.use('/api/servers/:id/join', joinRateLimiter);
registerRoutes(app);

View File

@@ -23,6 +23,7 @@ export interface ServerVariablesConfig {
serverProtocol: ServerHttpProtocol;
serverHost: string;
serverTag: string;
corsAllowlist: string[];
linkPreview: LinkPreviewConfig;
openApiDocs: OpenApiDocsConfig;
}
@@ -113,6 +114,17 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
}
function normalizeCorsAllowlist(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
const raw = (value && typeof value === 'object' && !Array.isArray(value))
? value as Record<string, unknown>
@@ -169,6 +181,7 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
serverTag: normalizeServerTag(remainingParsed.serverTag),
corsAllowlist: normalizeCorsAllowlist(remainingParsed.corsAllowlist),
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
};
@@ -186,11 +199,23 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
serverProtocol: normalized.serverProtocol,
serverHost: normalized.serverHost,
serverTag: normalized.serverTag,
corsAllowlist: normalized.corsAllowlist,
linkPreview: normalized.linkPreview,
openApiDocs: normalized.openApiDocs
};
}
export function getCorsAllowlist(): string[] {
if (hasEnvironmentOverride(process.env.CORS_ALLOWLIST)) {
return (process.env.CORS_ALLOWLIST ?? '')
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
return getVariablesConfig().corsAllowlist;
}
export function getVariablesConfig(): ServerVariablesConfig {
return ensureVariablesConfig();
}

View File

@@ -0,0 +1,12 @@
import { DataSource } from 'typeorm';
import { AuthUserEntity } from '../../../entities';
export async function handleUpdateUserPasswordHash(
dataSource: DataSource,
userId: string,
passwordHash: string
): Promise<void> {
const repo = dataSource.getRepository(AuthUserEntity);
await repo.update({ id: userId }, { passwordHash });
}

View File

@@ -0,0 +1,12 @@
import { DataSource } from 'typeorm';
import { AuthUserEntity } from '../../../entities';
export async function handleUpdateUserSigningPublicKey(
dataSource: DataSource,
userId: string,
signingPublicKey: string
): Promise<void> {
const repo = dataSource.getRepository(AuthUserEntity);
await repo.update({ id: userId }, { signingPublicKey });
}

View File

@@ -20,6 +20,8 @@ import { handleGetTrendingServers } from './queries/handlers/getTrendingServers'
import { handleGetServerById } from './queries/handlers/getServerById';
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
import { handleUpdateUserPasswordHash } from './commands/handlers/updateUserPasswordHash';
import { handleUpdateUserSigningPublicKey } from './commands/handlers/updateUserSigningPublicKey';
export const registerUser = (user: AuthUserPayload) =>
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
@@ -62,3 +64,9 @@ export const getJoinRequestById = (requestId: string) =>
export const getPendingRequestsForServer = (serverId: string) =>
handleGetPendingRequestsForServer({ type: QueryType.GetPendingRequestsForServer, payload: { serverId } }, getDataSource());
export const updateUserPasswordHash = (userId: string, passwordHash: string) =>
handleUpdateUserPasswordHash(getDataSource(), userId, passwordHash);
export const updateUserSigningPublicKey = (userId: string, signingPublicKey: string) =>
handleUpdateUserSigningPublicKey(getDataSource(), userId, signingPublicKey);

View File

@@ -14,7 +14,8 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
username: row.username,
passwordHash: row.passwordHash,
displayName: row.displayName,
createdAt: row.createdAt
createdAt: row.createdAt,
signingPublicKey: row.signingPublicKey ?? null
};
}

View File

@@ -28,6 +28,7 @@ export interface AuthUserPayload {
passwordHash: string;
displayName: string;
createdAt: number;
signingPublicKey?: string | null;
}
export type ServerChannelType = 'text' | 'voice';

View File

@@ -21,7 +21,8 @@ import {
PluginDataEntity,
ServerPluginSettingsEntity,
PluginUserMetadataEntity,
DeviceTokenEntity
DeviceTokenEntity,
SessionTokenEntity
} from '../entities';
import { serverMigrations } from '../migrations';
import {
@@ -272,7 +273,8 @@ export async function initDatabase(): Promise<void> {
PluginDataEntity,
ServerPluginSettingsEntity,
PluginUserMetadataEntity,
DeviceTokenEntity
DeviceTokenEntity,
SessionTokenEntity
],
migrations: serverMigrations,
synchronize: process.env.DB_SYNCHRONIZE === 'true',

View File

@@ -20,4 +20,7 @@ export class AuthUserEntity {
@Column('integer')
createdAt!: number;
@Column('text', { nullable: true })
signingPublicKey!: string | null;
}

View File

@@ -0,0 +1,22 @@
import {
Entity,
PrimaryColumn,
Column,
Index
} from 'typeorm';
@Entity('session_tokens')
export class SessionTokenEntity {
@PrimaryColumn('text')
token!: string;
@Index('idx_session_tokens_user_id')
@Column('text')
userId!: string;
@Column('integer')
issuedAt!: number;
@Column('integer')
expiresAt!: number;
}

View File

@@ -18,3 +18,4 @@ export { PluginDataEntity } from './PluginDataEntity';
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
export { DeviceTokenEntity } from './DeviceTokenEntity';
export { SessionTokenEntity } from './SessionTokenEntity';

View File

@@ -21,6 +21,7 @@ import {
ServerHttpProtocol
} from './config/variables';
import { setupWebSocket } from './websocket';
import { pruneExpiredSessionTokens } from './services/session-auth.service';
function formatHostForUrl(host: string): string {
if (host.startsWith('[') || !host.includes(':')) {
@@ -61,6 +62,7 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
let listeningServer: ReturnType<typeof buildServer> | null = null;
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
let sessionTokenPruneInterval: ReturnType<typeof setInterval> | null = null;
async function bootstrap(): Promise<void> {
const variablesConfig = ensureVariablesConfig();
@@ -99,6 +101,11 @@ async function bootstrap(): Promise<void> {
.catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000);
sessionTokenPruneInterval = setInterval(() => {
pruneExpiredSessionTokens()
.catch(err => console.error('Failed to prune expired session tokens:', err));
}, 60 * 1000);
const onListening = () => {
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
@@ -137,6 +144,11 @@ async function gracefulShutdown(signal: string): Promise<void> {
staleJoinRequestInterval = null;
}
if (sessionTokenPruneInterval) {
clearInterval(sessionTokenPruneInterval);
sessionTokenPruneInterval = null;
}
console.log(`\n[Shutdown] ${signal} received - closing database...`);
if (listeningServer?.listening) {

View File

@@ -0,0 +1,72 @@
import '../types/express-augmentation';
import {
NextFunction,
Request,
Response
} from 'express';
import { consumeSessionToken } from '../services/session-auth.service';
function readBearerToken(req: Request): string | null {
const header = req.header('authorization');
if (!header || !header.toLowerCase().startsWith('bearer ')) {
return null;
}
const token = header.slice(7).trim();
return token || null;
}
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const token = readBearerToken(req);
if (!token) {
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
return;
}
const session = await consumeSessionToken(token);
if (!session) {
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
return;
}
req.authToken = session.token;
req.authUserId = session.user.id;
req.authUser = session.user;
next();
}
export function getAuthenticatedUserId(req: Request): string {
const userId = req.authUserId;
if (!userId) {
throw new Error('Authenticated user id missing after requireAuth');
}
return userId;
}
export function rejectSpoofedUserId(
req: Request,
res: Response,
bodyUserId: unknown,
fieldName: string
): bodyUserId is string {
if (typeof bodyUserId !== 'string' || !bodyUserId.trim()) {
return false;
}
if (bodyUserId !== req.authUserId) {
res.status(400).json({
error: `${fieldName} must match the authenticated user`,
errorCode: 'USER_ID_MISMATCH'
});
return false;
}
return true;
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SessionTokens1000000000011 implements MigrationInterface {
name = 'SessionTokens1000000000011';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "session_tokens" (
"token" TEXT PRIMARY KEY NOT NULL,
"userId" TEXT NOT NULL,
"issuedAt" INTEGER NOT NULL,
"expiresAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_session_tokens_user_id" ON "session_tokens" ("userId")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "session_tokens"`);
}
}

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SigningPublicKey1000000000012 implements MigrationInterface {
name = 'SigningPublicKey1000000000012';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "users" ADD COLUMN "signingPublicKey" text');
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "users" DROP COLUMN "signingPublicKey"');
}
}

View File

@@ -9,6 +9,8 @@ import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
import { ServerIcons1000000000009 } from './1000000000009-ServerIcons';
import { DeviceTokens1000000000010 } from './1000000000010-DeviceTokens';
import { SessionTokens1000000000011 } from './1000000000011-SessionTokens';
import { SigningPublicKey1000000000012 } from './1000000000012-SigningPublicKey';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -21,5 +23,7 @@ export const serverMigrations = [
PluginSupport1000000000007,
ServerPluginInstallMetadata1000000000008,
ServerIcons1000000000009,
DeviceTokens1000000000010
DeviceTokens1000000000010,
SessionTokens1000000000011,
SigningPublicKey1000000000012
];

View File

@@ -4,6 +4,7 @@ import {
listDeviceTokensForUser,
upsertDeviceToken
} from '../services/push-dispatch.service';
import { requireAuth } from '../middleware/require-auth';
export interface DeviceTokenRecord {
userId: string;
@@ -14,6 +15,8 @@ export interface DeviceTokenRecord {
const router = Router();
router.use(requireAuth);
router.post('/', async (req, res) => {
const { userId, platform, token } = req.body as Partial<DeviceTokenRecord>;
@@ -21,12 +24,20 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Missing or invalid userId/platform/token' });
}
if (userId !== req.authUserId) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
await upsertDeviceToken({ userId, platform, token });
res.status(201).json({ ok: true });
});
router.get('/:userId', async (req, res) => {
if (req.params.userId !== req.authUserId) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
const records = await listDeviceTokensForUser(req.params.userId);
res.json({
@@ -40,6 +51,10 @@ router.get('/:userId', async (req, res) => {
});
router.post('/:userId/dispatch', async (req, res) => {
if (req.params.userId !== req.authUserId) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
const { title, body, data } = req.body as {
title?: string;
body?: string;

View File

@@ -7,20 +7,29 @@ import {
} from '../cqrs';
import { notifyUser } from '../websocket/broadcast';
import { resolveServerPermission } from '../services/server-permissions.service';
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
const router = Router();
router.put('/:id', async (req, res) => {
router.put('/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const { ownerId, status } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const request = await getJoinRequestById(id);
if (!request)
return res.status(404).json({ error: 'Request not found' });
if (ownerId && ownerId !== authenticatedUserId) {
return res.status(400).json({
error: 'ownerId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
const server = await getServerById(request.serverId);
if (!server || !ownerId || !resolveServerPermission(server, String(ownerId), 'manageServer'))
if (!server || !resolveServerPermission(server, authenticatedUserId, 'manageServer'))
return res.status(403).json({ error: 'Not authorized' });
await updateJoinRequestStatus(id, status as JoinRequestPayload['status']);

View File

@@ -8,6 +8,7 @@ import {
upsertPluginRequirement
} from '../services/plugin-support.service';
import { broadcastToServer } from '../websocket/broadcast';
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
const router = Router();
@@ -21,8 +22,28 @@ function sendPluginSupportError(error: unknown, res: Response): void {
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
}
function readActorUserId(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
type AuthenticatedRequest = Parameters<typeof getAuthenticatedUserId>[0] & { body: { actorUserId?: unknown } };
function readOptionalActorUserId(req: AuthenticatedRequest, res: Response): string | null {
let authenticatedUserId: string;
try {
authenticatedUserId = getAuthenticatedUserId(req);
} catch {
res.status(401).json({ error: 'Missing or invalid authorization token', errorCode: 'UNAUTHORIZED' });
return null;
}
if (typeof req.body.actorUserId === 'string' && req.body.actorUserId.trim() && req.body.actorUserId !== authenticatedUserId) {
res.status(400).json({
error: 'actorUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
return null;
}
return authenticatedUserId;
}
async function broadcastRequirementsSnapshot(serverId: string): Promise<void> {
@@ -43,12 +64,17 @@ router.get('/:serverId/plugins', async (req, res) => {
}
});
router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
router.put('/:serverId/plugins/:pluginId/requirement', requireAuth, async (req, res) => {
const { serverId, pluginId } = req.params;
const actorUserId = readOptionalActorUserId(req, res);
if (!actorUserId) {
return;
}
try {
const requirement = await upsertPluginRequirement({
actorUserId: readActorUserId(req.body.actorUserId),
actorUserId,
installUrl: req.body.installUrl,
manifest: req.body.manifest,
pluginId,
@@ -66,12 +92,17 @@ router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
}
});
router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
router.delete('/:serverId/plugins/:pluginId/requirement', requireAuth, async (req, res) => {
const { serverId, pluginId } = req.params;
const actorUserId = readOptionalActorUserId(req, res);
if (!actorUserId) {
return;
}
try {
await deletePluginRequirement({
actorUserId: readActorUserId(req.body.actorUserId),
actorUserId,
pluginId,
serverId
});
@@ -83,12 +114,17 @@ router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
}
});
router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
router.put('/:serverId/plugins/:pluginId/events/:eventName', requireAuth, async (req, res) => {
const { serverId, pluginId, eventName } = req.params;
const actorUserId = readOptionalActorUserId(req, res);
if (!actorUserId) {
return;
}
try {
const eventDefinition = await upsertPluginEventDefinition({
actorUserId: readActorUserId(req.body.actorUserId),
actorUserId,
direction: req.body.direction,
eventName,
maxPayloadBytes: req.body.maxPayloadBytes,
@@ -106,12 +142,17 @@ router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) =>
}
});
router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
router.delete('/:serverId/plugins/:pluginId/events/:eventName', requireAuth, async (req, res) => {
const { serverId, pluginId, eventName } = req.params;
const actorUserId = readOptionalActorUserId(req, res);
if (!actorUserId) {
return;
}
try {
await deletePluginEventDefinition({
actorUserId: readActorUserId(req.body.actorUserId),
actorUserId,
eventName,
pluginId,
serverId

View File

@@ -35,6 +35,11 @@ import {
canModerateServerMember,
resolveServerPermission
} from '../services/server-permissions.service';
import {
getAuthenticatedUserId,
requireAuth,
rejectSpoofedUserId
} from '../middleware/require-auth';
const router = Router();
@@ -185,7 +190,7 @@ router.get('/trending', async (req, res) => {
res.json({ servers: enrichedResults, total: enrichedResults.length, limit });
});
router.post('/', async (req, res) => {
router.post('/', requireAuth, async (req, res) => {
const {
id: clientId,
name,
@@ -204,12 +209,17 @@ router.post('/', async (req, res) => {
if (!name || !ownerId || !ownerPublicKey)
return res.status(400).json({ error: 'Missing required fields' });
if (!rejectSpoofedUserId(req, res, ownerId, 'ownerId')) {
return;
}
const authenticatedOwnerId = getAuthenticatedUserId(req);
const passwordHash = passwordHashForInput(password);
const server: ServerPayload = {
id: clientId || uuidv4(),
name,
description,
ownerId,
ownerId: authenticatedOwnerId,
ownerPublicKey,
hasPassword: !!passwordHash,
passwordHash,
@@ -225,12 +235,12 @@ router.post('/', async (req, res) => {
};
await upsertServer(server);
await ensureServerMembership(server.id, ownerId);
await ensureServerMembership(server.id, authenticatedOwnerId);
res.status(201).json(await enrichServer(server, getRequestOrigin(req)));
});
router.put('/:id', async (req, res) => {
router.put('/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const {
currentOwnerId,
@@ -242,13 +252,16 @@ router.put('/:id', async (req, res) => {
...updates
} = req.body;
const existing = await getServerById(id);
const authenticatedOwnerId = currentOwnerId ?? req.body.ownerId;
const authenticatedOwnerId = getAuthenticatedUserId(req);
if (!existing)
return res.status(404).json({ error: 'Server not found' });
if (!authenticatedOwnerId) {
return res.status(400).json({ error: 'Missing currentOwnerId' });
if (currentOwnerId && currentOwnerId !== authenticatedOwnerId) {
return res.status(400).json({
error: 'currentOwnerId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (!canManageServerUpdate(existing, authenticatedOwnerId, {
@@ -276,18 +289,22 @@ router.put('/:id', async (req, res) => {
res.json(await enrichServer(server, getRequestOrigin(req)));
});
router.post('/:id/join', async (req, res) => {
router.post('/:id/join', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { userId, password, inviteId } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
if (!userId) {
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
if (userId && userId !== authenticatedUserId) {
return res.status(400).json({
error: 'userId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
try {
const result = await joinServerWithAccess({
serverId,
userId: String(userId),
userId: authenticatedUserId,
password: typeof password === 'string' ? password : undefined,
inviteId: typeof inviteId === 'string' ? inviteId : undefined
});
@@ -305,12 +322,16 @@ router.post('/:id/join', async (req, res) => {
}
});
router.post('/:id/invites', async (req, res) => {
router.post('/:id/invites', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { requesterUserId, requesterDisplayName } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
if (!requesterUserId) {
return res.status(400).json({ error: 'Missing requesterUserId', errorCode: 'MISSING_USER' });
if (requesterUserId && requesterUserId !== authenticatedUserId) {
return res.status(400).json({
error: 'requesterUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
const server = await getServerById(serverId);
@@ -322,7 +343,7 @@ router.post('/:id/invites', async (req, res) => {
try {
const invite = await createServerInvite(
serverId,
String(requesterUserId),
authenticatedUserId,
typeof requesterDisplayName === 'string' ? requesterDisplayName : undefined
);
@@ -332,9 +353,10 @@ router.post('/:id/invites', async (req, res) => {
}
});
router.post('/:id/moderation/kick', async (req, res) => {
router.post('/:id/moderation/kick', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, targetUserId } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server) {
@@ -345,7 +367,14 @@ router.post('/:id/moderation/kick', async (req, res) => {
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
}
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'kickMembers')) {
if (actorUserId && actorUserId !== authenticatedUserId) {
return res.status(400).json({
error: 'actorUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'kickMembers')) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
@@ -354,9 +383,10 @@ router.post('/:id/moderation/kick', async (req, res) => {
res.json({ ok: true });
});
router.post('/:id/moderation/ban', async (req, res) => {
router.post('/:id/moderation/ban', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, targetUserId, banId, displayName, reason, expiresAt } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server) {
@@ -367,7 +397,14 @@ router.post('/:id/moderation/ban', async (req, res) => {
return res.status(400).json({ error: 'Missing targetUserId', errorCode: 'MISSING_TARGET' });
}
if (!canModerateServerMember(server, String(actorUserId || ''), String(targetUserId), 'banMembers')) {
if (actorUserId && actorUserId !== authenticatedUserId) {
return res.status(400).json({
error: 'actorUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (!canModerateServerMember(server, authenticatedUserId, String(targetUserId), 'banMembers')) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
@@ -375,7 +412,7 @@ router.post('/:id/moderation/ban', async (req, res) => {
serverId,
userId: String(targetUserId),
banId: typeof banId === 'string' ? banId : undefined,
bannedBy: String(actorUserId || ''),
bannedBy: authenticatedUserId,
displayName: typeof displayName === 'string' ? displayName : undefined,
reason: typeof reason === 'string' ? reason : undefined,
expiresAt: typeof expiresAt === 'number' ? expiresAt : undefined
@@ -384,16 +421,24 @@ router.post('/:id/moderation/ban', async (req, res) => {
res.json({ ok: true });
});
router.post('/:id/moderation/unban', async (req, res) => {
router.post('/:id/moderation/unban', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { actorUserId, banId, targetUserId } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!resolveServerPermission(server, String(actorUserId || ''), 'manageBans')) {
if (actorUserId && actorUserId !== authenticatedUserId) {
return res.status(400).json({
error: 'actorUserId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (!resolveServerPermission(server, authenticatedUserId, 'manageBans')) {
return res.status(403).json({ error: 'Not authorized', errorCode: 'NOT_AUTHORIZED' });
}
@@ -406,25 +451,29 @@ router.post('/:id/moderation/unban', async (req, res) => {
res.json({ ok: true });
});
router.post('/:id/leave', async (req, res) => {
router.post('/:id/leave', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { userId } = req.body;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server) {
return res.status(404).json({ error: 'Server not found', errorCode: 'SERVER_NOT_FOUND' });
}
if (!userId) {
return res.status(400).json({ error: 'Missing userId', errorCode: 'MISSING_USER' });
if (userId && userId !== authenticatedUserId) {
return res.status(400).json({
error: 'userId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
await leaveServerUser(serverId, String(userId));
await leaveServerUser(serverId, authenticatedUserId);
res.json({ ok: true });
});
router.post('/:id/heartbeat', async (req, res) => {
router.post('/:id/heartbeat', requireAuth, async (req, res) => {
const { id } = req.params;
const { currentUsers } = req.body;
const existing = await getServerById(id);
@@ -442,30 +491,38 @@ router.post('/:id/heartbeat', async (req, res) => {
res.json({ ok: true });
});
router.delete('/:id', async (req, res) => {
router.delete('/:id', requireAuth, async (req, res) => {
const { id } = req.params;
const { ownerId } = req.body;
const authenticatedOwnerId = getAuthenticatedUserId(req);
const existing = await getServerById(id);
if (!existing)
return res.status(404).json({ error: 'Server not found' });
if (existing.ownerId !== ownerId)
if (ownerId && ownerId !== authenticatedOwnerId) {
return res.status(400).json({
error: 'ownerId must match the authenticated user',
errorCode: 'USER_ID_MISMATCH'
});
}
if (existing.ownerId !== authenticatedOwnerId)
return res.status(403).json({ error: 'Not authorized' });
await deleteServer(id);
res.json({ ok: true });
});
router.get('/:id/requests', async (req, res) => {
router.get('/:id/requests', requireAuth, async (req, res) => {
const { id: serverId } = req.params;
const { ownerId } = req.query;
const authenticatedUserId = getAuthenticatedUserId(req);
const server = await getServerById(serverId);
if (!server)
return res.status(404).json({ error: 'Server not found' });
if (server.ownerId !== ownerId)
if (!resolveServerPermission(server, authenticatedUserId, 'manageServer'))
return res.status(403).json({ error: 'Not authorized' });
const requests = await getPendingRequestsForServer(serverId);

View File

@@ -1,13 +1,30 @@
import crypto from 'crypto';
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { getUserByUsername, registerUser } from '../cqrs';
import {
getUserById,
getUserByUsername,
registerUser,
updateUserPasswordHash,
updateUserSigningPublicKey
} from '../cqrs';
import { hashPasswordForStorage, verifyPassword } from '../services/password-auth.service';
import { issueSessionToken, revokeSessionToken } from '../services/session-auth.service';
import { getAuthenticatedUserId, requireAuth } from '../middleware/require-auth';
const router = Router();
function hashPassword(pw: string): string {
return crypto.createHash('sha256').update(pw)
.digest('hex');
function buildAuthResponse(user: {
id: string;
username: string;
displayName: string;
}, token: string, expiresAt: number) {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
token,
expiresAt
};
}
router.post('/register', async (req, res) => {
@@ -24,23 +41,64 @@ router.post('/register', async (req, res) => {
const user = {
id: uuidv4(),
username,
passwordHash: hashPassword(password),
passwordHash: await hashPasswordForStorage(password),
displayName: displayName || username,
createdAt: Date.now()
};
await registerUser(user);
res.status(201).json({ id: user.id, username: user.username, displayName: user.displayName });
const session = await issueSessionToken(user.id);
res.status(201).json(buildAuthResponse(user, session.token, session.expiresAt));
});
router.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await getUserByUsername(username);
if (!user || user.passwordHash !== hashPassword(password))
if (!user || !(await verifyPassword(password, user.passwordHash)))
return res.status(401).json({ error: 'Invalid credentials' });
res.json({ id: user.id, username: user.username, displayName: user.displayName });
const upgradedHash = await hashPasswordForStorage(password, user.passwordHash);
if (upgradedHash !== user.passwordHash) {
await updateUserPasswordHash(user.id, upgradedHash);
}
const session = await issueSessionToken(user.id);
res.json(buildAuthResponse(user, session.token, session.expiresAt));
});
router.put('/me/signing-key', requireAuth, async (req, res) => {
const { publicKeyJwk } = req.body;
const userId = getAuthenticatedUserId(req);
if (!publicKeyJwk || typeof publicKeyJwk !== 'object') {
return res.status(400).json({ error: 'Missing publicKeyJwk', errorCode: 'INVALID_SIGNING_KEY' });
}
await updateUserSigningPublicKey(userId, JSON.stringify(publicKeyJwk));
res.json({ ok: true });
});
router.get('/:id/signing-public-key', async (req, res) => {
const user = await getUserById(req.params.id);
if (!user?.signingPublicKey) {
return res.status(404).json({ error: 'Signing public key not found', errorCode: 'SIGNING_KEY_NOT_FOUND' });
}
res.json({ publicKeyJwk: JSON.parse(user.signingPublicKey) });
});
router.post('/logout', requireAuth, async (req, res) => {
if (req.authToken) {
await revokeSessionToken(req.authToken);
}
res.json({ ok: true });
});
export default router;

View File

@@ -0,0 +1,45 @@
import {
describe,
it,
expect
} from 'vitest';
import crypto from 'crypto';
import {
hashPasswordForStorage,
isLegacySha256Hash,
verifyPassword
} from './password-auth.service';
describe('password-auth.service', () => {
it('stores bcrypt hashes for new passwords', async () => {
const hash = await hashPasswordForStorage('secret-pass');
expect(hash.startsWith('$2')).toBe(true);
expect(isLegacySha256Hash(hash)).toBe(false);
});
it('verifies bcrypt passwords', async () => {
const hash = await hashPasswordForStorage('secret-pass');
await expect(verifyPassword('secret-pass', hash)).resolves.toBe(true);
await expect(verifyPassword('wrong-pass', hash)).resolves.toBe(false);
});
it('verifies legacy sha256 passwords', async () => {
const legacyHash = crypto.createHash('sha256').update('legacy-pass')
.digest('hex');
expect(isLegacySha256Hash(legacyHash)).toBe(true);
await expect(verifyPassword('legacy-pass', legacyHash)).resolves.toBe(true);
await expect(verifyPassword('wrong-pass', legacyHash)).resolves.toBe(false);
});
it('rehashes legacy passwords to bcrypt after successful verification', async () => {
const legacyHash = crypto.createHash('sha256').update('legacy-pass')
.digest('hex');
const upgraded = await hashPasswordForStorage('legacy-pass', legacyHash);
expect(upgraded.startsWith('$2')).toBe(true);
await expect(verifyPassword('legacy-pass', upgraded)).resolves.toBe(true);
});
});

View File

@@ -0,0 +1,33 @@
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
const BCRYPT_ROUNDS = 12;
export function isLegacySha256Hash(passwordHash: string): boolean {
return /^[a-f0-9]{64}$/i.test(passwordHash);
}
export async function hashPasswordForStorage(
password: string,
existingHash?: string
): Promise<string> {
if (existingHash && isLegacySha256Hash(existingHash)) {
const legacyMatches = crypto.createHash('sha256').update(password)
.digest('hex') === existingHash;
if (legacyMatches) {
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
}
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
if (isLegacySha256Hash(passwordHash)) {
return crypto.createHash('sha256').update(password)
.digest('hex') === passwordHash;
}
return bcrypt.compare(password, passwordHash);
}

View File

@@ -134,6 +134,34 @@ export async function countServerMemberships(serverId: string): Promise<number>
return await getMembershipRepository().count({ where: { serverId } });
}
export async function usersShareServerMembership(userA: string, userB: string): Promise<boolean> {
if (userA === userB) {
return true;
}
const repo = getMembershipRepository();
const membershipsForA = await repo.find({
where: { userId: userA },
select: ['serverId']
});
if (membershipsForA.length === 0) {
return false;
}
for (const membership of membershipsForA) {
const shared = await repo.findOne({
where: { serverId: membership.serverId, userId: userB }
});
if (shared) {
return true;
}
}
return false;
}
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
const repo = getMembershipRepository();
const now = Date.now();

View File

@@ -0,0 +1,110 @@
import { randomBytes } from 'crypto';
import { getDataSource } from '../db/database';
import { SessionTokenEntity } from '../entities/SessionTokenEntity';
import { getUserById } from '../cqrs';
import type { AuthUserPayload } from '../cqrs/types';
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
export interface IssuedSessionToken {
token: string;
userId: string;
issuedAt: number;
expiresAt: number;
}
export interface AuthenticatedSession {
token: string;
user: AuthUserPayload;
issuedAt: number;
expiresAt: number;
}
function getTokenRepository() {
return getDataSource().getRepository(SessionTokenEntity);
}
export function getSessionTokenTtlMs(): number {
const configured = Number(process.env.SESSION_TOKEN_TTL_MS);
return Number.isFinite(configured) && configured > 0
? configured
: DEFAULT_TOKEN_TTL_MS;
}
export async function issueSessionToken(userId: string): Promise<IssuedSessionToken> {
const token = randomBytes(32).toString('hex');
const issuedAt = Date.now();
const expiresAt = issuedAt + getSessionTokenTtlMs();
const repo = getTokenRepository();
await repo.save(repo.create({
token,
userId,
issuedAt,
expiresAt
}));
return { token, userId, issuedAt, expiresAt };
}
export async function consumeSessionToken(token: string): Promise<AuthenticatedSession | null> {
const normalized = token.trim();
if (!normalized) {
return null;
}
const repo = getTokenRepository();
const record = await repo.findOne({ where: { token: normalized } });
if (!record || record.expiresAt < Date.now()) {
if (record) {
await repo.delete({ token: normalized });
}
return null;
}
const user = await getUserById(record.userId);
if (!user) {
await repo.delete({ token: normalized });
return null;
}
return {
token: record.token,
user,
issuedAt: record.issuedAt,
expiresAt: record.expiresAt
};
}
export async function revokeSessionToken(token: string): Promise<void> {
const normalized = token.trim();
if (!normalized) {
return;
}
await getTokenRepository().delete({ token: normalized });
}
export async function revokeAllSessionTokensForUser(userId: string): Promise<void> {
await getTokenRepository().delete({ userId });
}
export async function pruneExpiredSessionTokens(): Promise<void> {
const repo = getTokenRepository();
const now = Date.now();
const expired = await repo.createQueryBuilder('token')
.where('token.expiresAt < :now', { now })
.getMany();
if (expired.length === 0) {
return;
}
await repo.remove(expired);
}

View File

@@ -0,0 +1,11 @@
import type { AuthUserPayload } from '../cqrs/types';
declare module 'express-serve-static-core' {
interface Request {
authUserId?: string;
authUser?: AuthUserPayload;
authToken?: string;
}
}
export {};

View File

@@ -0,0 +1,120 @@
import {
describe,
it,
expect,
beforeEach,
vi
} from 'vitest';
import { WebSocket } from 'ws';
import { connectedUsers } from './state';
import { ConnectedUser } from './types';
import { handleWebSocketMessage } from './handler';
vi.mock('../services/server-access.service', () => ({
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
usersShareServerMembership: vi.fn(async () => false)
}));
vi.mock('../services/session-auth.service', () => ({
consumeSessionToken: vi.fn(async (token: string) => {
if (token !== 'valid-token') {
return null;
}
return {
token,
user: {
id: 'user-1',
username: 'alice',
displayName: 'Alice',
passwordHash: 'hash',
createdAt: Date.now()
},
issuedAt: Date.now(),
expiresAt: Date.now() + 60_000
};
})
}));
function createMockWs(): WebSocket & { sentMessages: string[] } {
const sent: string[] = [];
const ws = {
readyState: WebSocket.OPEN,
send: (data: string) => { sent.push(data); },
close: () => {},
sentMessages: sent
} as unknown as WebSocket & { sentMessages: string[] };
return ws;
}
function createConnectedUser(connectionId: string): ConnectedUser {
const ws = createMockWs();
const user: ConnectedUser = {
oderId: connectionId,
ws,
authenticated: false,
serverIds: new Set(),
displayName: 'Test User',
lastPong: Date.now()
};
connectedUsers.set(connectionId, user);
return user;
}
describe('server websocket handler - authentication', () => {
beforeEach(() => {
connectedUsers.clear();
vi.clearAllMocks();
});
it('rejects non-identify messages until the connection is authenticated', async () => {
createConnectedUser('conn-1');
await handleWebSocketMessage('conn-1', { type: 'typing', serverId: 'server-1' });
const user = connectedUsers.get('conn-1');
const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages;
const response = JSON.parse(sentMessages[0]) as { type: string };
expect(response.type).toBe('auth_required');
expect(user?.authenticated).toBe(false);
});
it('rejects identify without a session token', async () => {
createConnectedUser('conn-1');
await handleWebSocketMessage('conn-1', {
type: 'identify',
oderId: 'user-1',
displayName: 'Alice'
});
const user = connectedUsers.get('conn-1');
const sentMessages = (user?.ws as WebSocket & { sentMessages: string[] }).sentMessages;
const response = JSON.parse(sentMessages[0]) as { type: string; code: string };
expect(response.type).toBe('auth_error');
expect(response.code).toBe('MISSING_TOKEN');
expect(user?.authenticated).toBe(false);
});
it('binds identify to the authenticated user id from the token', async () => {
createConnectedUser('conn-1');
await handleWebSocketMessage('conn-1', {
type: 'identify',
token: 'valid-token',
oderId: 'user-1',
displayName: 'Alice'
});
const user = connectedUsers.get('conn-1');
expect(user?.authenticated).toBe(true);
expect(user?.oderId).toBe('user-1');
});
});

View File

@@ -63,6 +63,7 @@ function createConnectedUser(
displayName: `User ${oderId}`,
lastPong: Date.now(),
oderId,
authenticated: true,
serverIds: new Set(),
ws: createMockWs(),
...overrides

View File

@@ -14,6 +14,41 @@ vi.mock('../services/server-access.service', () => ({
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
}));
let authenticatedUserId = 'user-1';
vi.mock('../services/session-auth.service', () => ({
consumeSessionToken: vi.fn(async (token: string) => {
if (token !== 'test-token') {
return null;
}
return {
token,
user: {
id: authenticatedUserId,
username: 'test-user',
displayName: 'Test User',
passwordHash: 'hash',
createdAt: Date.now()
},
issuedAt: Date.now(),
expiresAt: Date.now() + 60_000
};
})
}));
vi.mock('../services/plugin-support.service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
return {
...actual,
getPluginRequirementsSnapshot: vi.fn(async () => ({
requirements: [],
eventDefinitions: []
}))
};
});
/**
* Minimal mock WebSocket that records sent messages.
*/
@@ -38,6 +73,7 @@ function createConnectedUser(
const user: ConnectedUser = {
oderId,
ws,
authenticated: true,
serverIds: new Set(),
displayName: 'Test User',
lastPong: Date.now(),
@@ -168,7 +204,8 @@ describe('server websocket handler - status_update', () => {
getSentMessagesStore(user2).sentMessages.length = 0;
// Identify first (required for handler)
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
authenticatedUserId = 'user-1';
await handleWebSocketMessage('conn-1', { type: 'identify', token: 'test-token', oderId: 'user-1', displayName: 'User 1' });
// user-2 joins server -> should receive server_users with user-1's status
getSentMessagesStore(user2).sentMessages.length = 0;
@@ -201,7 +238,8 @@ describe('server websocket handler - user_joined includes status', () => {
getRequiredConnectedUser('conn-1').status = 'busy';
// Identify user-1
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
authenticatedUserId = 'user-1';
await handleWebSocketMessage('conn-1', { type: 'identify', token: 'test-token', oderId: 'user-1', displayName: 'User 1' });
getSentMessagesStore(user2).sentMessages.length = 0;
@@ -237,8 +275,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
bob.serverIds.add('server-1');
getSentMessagesStore(bob).sentMessages.length = 0;
authenticatedUserId = 'user-1';
await handleWebSocketMessage('conn-1', {
type: 'identify',
token: 'test-token',
oderId: 'user-1',
displayName: 'Alice Updated',
description: 'Updated bio',
@@ -261,8 +301,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
alice.serverIds.add('server-1');
bob.serverIds.add('server-1');
authenticatedUserId = 'user-1';
await handleWebSocketMessage('conn-1', {
type: 'identify',
token: 'test-token',
oderId: 'user-1',
displayName: 'Alice',
description: 'Alice bio',
@@ -291,8 +333,10 @@ describe('server websocket handler - profile metadata in presence messages', ()
alice.serverIds.add('server-1');
bob.serverIds.add('server-1');
authenticatedUserId = 'user-1';
await handleWebSocketMessage('conn-1', {
type: 'identify',
token: 'test-token',
oderId: 'user-1',
displayName: 'Alice',
homeSignalServerUrl: 'http://signal.example.com:3001/'

View File

@@ -7,7 +7,12 @@ import {
getUniqueUsersInServer,
isOderIdConnectedToServer
} from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service';
import {
authorizeWebSocketJoin,
findServerMembership,
usersShareServerMembership
} from '../services/server-access.service';
import { consumeSessionToken } from '../services/session-auth.service';
import {
getPluginRequirementsSnapshot,
PluginSupportError,
@@ -131,8 +136,67 @@ async function sendPluginRequirements(user: ConnectedUser, serverId: string): Pr
}
}
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const newOderId = readMessageId(message['oderId']) ?? connectionId;
const DIRECT_SIGNALING_TYPES = new Set([
'direct-message',
'direct-message-status',
'direct-message-mutation',
'direct-message-typing',
'direct-message-sync-request',
'direct-message-sync',
'direct-call'
]);
const SERVER_SCOPED_SIGNALING_TYPES = new Set([
'server_icon_peer_request',
'server_icon_peer_data',
'server_icon_available',
'server_icon_sync_request'
]);
function sendAuthRequired(user: ConnectedUser): void {
user.ws.send(JSON.stringify({
type: 'auth_required',
message: 'identify with a valid session token before sending messages'
}));
}
async function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const token = typeof message['token'] === 'string' ? message['token'].trim() : '';
if (!token) {
user.ws.send(JSON.stringify({
type: 'auth_error',
code: 'MISSING_TOKEN',
message: 'identify requires a session token'
}));
return;
}
const session = await consumeSessionToken(token);
if (!session) {
user.ws.send(JSON.stringify({
type: 'auth_error',
code: 'INVALID_TOKEN',
message: 'invalid or expired session token'
}));
return;
}
const claimedOderId = readMessageId(message['oderId']);
if (claimedOderId && claimedOderId !== session.user.id) {
user.ws.send(JSON.stringify({
type: 'auth_error',
code: 'USER_ID_MISMATCH',
message: 'oderId must match the authenticated user'
}));
return;
}
const newOderId = session.user.id;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
const previousDisplayName = normalizeDisplayName(user.displayName);
const previousDescription = user.description;
@@ -140,6 +204,7 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
user.oderId = newOderId;
user.authenticated = true;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
@@ -277,11 +342,45 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
);
}
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
async function canForwardRtcMessage(user: ConnectedUser, message: WsMessage, targetUserId: string): Promise<boolean> {
if (!targetUserId || targetUserId === user.oderId) {
return false;
}
if (DIRECT_SIGNALING_TYPES.has(message.type)) {
return true;
}
if (SERVER_SCOPED_SIGNALING_TYPES.has(message.type)) {
const serverId = readMessageId(message['serverId']);
if (!serverId) {
return false;
}
const senderMembership = await findServerMembership(serverId, user.oderId);
const targetMembership = await findServerMembership(serverId, targetUserId);
return !!senderMembership && !!targetMembership;
}
if (message.type === 'offer' || message.type === 'answer' || message.type === 'ice_candidate') {
return true;
}
return usersShareServerMembership(user.oderId, targetUserId);
}
async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promise<void> {
const targetUserId = readMessageId(message['targetUserId']) ?? '';
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
if (!(await canForwardRtcMessage(user, message, targetUserId))) {
console.log(`Blocked ${message.type} relay from ${user.oderId} to ${targetUserId}`);
return;
}
const targetUser = findUserByOderId(targetUserId);
if (targetUser) {
@@ -482,13 +581,18 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
user.lastPong = Date.now();
connectedUsers.set(connectionId, user);
if (!user.authenticated && message.type !== 'identify' && message.type !== 'keepalive') {
sendAuthRequired(user);
return;
}
switch (message.type) {
case 'keepalive':
user.ws.send(JSON.stringify({ type: 'keepalive_ack', serverTime: Date.now() }));
break;
case 'identify':
handleIdentify(user, message, connectionId);
await handleIdentify(user, message, connectionId);
break;
case 'join_server':
@@ -515,7 +619,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
case 'direct-call':
case 'server_icon_peer_request':
case 'server_icon_peer_data':
forwardRtcMessage(user, message);
await forwardRtcMessage(user, message);
break;
case 'chat_message':

View File

@@ -80,7 +80,13 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
const connectionId = uuidv4();
const now = Date.now();
connectedUsers.set(connectionId, { oderId: connectionId, ws, serverIds: new Set(), lastPong: now });
connectedUsers.set(connectionId, {
oderId: connectionId,
ws,
authenticated: false,
serverIds: new Set(),
lastPong: now
});
ws.on('pong', () => {
const user = connectedUsers.get(connectionId);

View File

@@ -3,6 +3,7 @@ import { WebSocket } from 'ws';
export interface ConnectedUser {
oderId: string;
ws: WebSocket;
authenticated: boolean;
serverIds: Set<string>;
viewedServerId?: string;
displayName?: string;