From c3bd5c5b00580c6b198d18deb04da131e231c79f Mon Sep 17 00:00:00 2001 From: NishikawaKoharu Date: Mon, 31 Aug 2026 15:27:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?y-websocket=20JWT=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=EF=BC=88=E3=82=AF=E3=83=A9=E3=82=A4=E3=82=A2?= =?UTF-8?q?=E3=83=B3=E3=83=88=E9=80=81=E4=BF=A1=E3=83=BBauth=20Docker?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/wiki/static/wikiPageMilkdown.js | 8 +- docker/y-websocket-auth/Dockerfile | 22 +++++ docker/y-websocket-auth/auth.cjs | 109 +++++++++++++++++++++ docker/y-websocket-auth/server-wrapper.cjs | 39 ++++++++ 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 docker/y-websocket-auth/Dockerfile create mode 100644 docker/y-websocket-auth/auth.cjs create mode 100644 docker/y-websocket-auth/server-wrapper.cjs diff --git a/addons/wiki/static/wikiPageMilkdown.js b/addons/wiki/static/wikiPageMilkdown.js index 6edfab6..a41e42c 100644 --- a/addons/wiki/static/wikiPageMilkdown.js +++ b/addons/wiki/static/wikiPageMilkdown.js @@ -380,8 +380,14 @@ async function createMEditor(editor, vm, template) { return ret; }; + const wsToken = window.contextVars.wiki.metadata.yWebsocketToken; + const wsParams = wsToken ? { token: wsToken } : {}; + if (!wsProvider) { - wsProvider = new yWebsocket.WebsocketProvider(wsUrl, docId, doc, { disableBc: true }); + wsProvider = new yWebsocket.WebsocketProvider(wsUrl, docId, doc, { + disableBc: true, + params: wsParams, + }); } originalContent = template; diff --git a/docker/y-websocket-auth/Dockerfile b/docker/y-websocket-auth/Dockerfile new file mode 100644 index 0000000..059912c --- /dev/null +++ b/docker/y-websocket-auth/Dockerfile @@ -0,0 +1,22 @@ +# Authenticated entrypoint for y-websocket. +# RDM-y-websocket itself is not modified; this image adds auth on top of the base image. +# +# Local build example (after building RDM-y-websocket): +# docker build --build-arg Y_WEBSOCKET_BASE_IMAGE=rdm-20260715-2-y-websocket \ +# -t rdm-y-websocket-auth:local docker/y-websocket-auth +# +# Production build example: +# docker build -t rdm-y-websocket-auth:prod docker/y-websocket-auth + +ARG Y_WEBSOCKET_BASE_IMAGE=niicloudoperation/rdm-y-websocket:latest +FROM ${Y_WEBSOCKET_BASE_IMAGE} + +WORKDIR /home/node/app + +COPY auth.cjs server-wrapper.cjs ./ + +USER node + +EXPOSE 1234 + +CMD ["node", "./server-wrapper.cjs"] diff --git a/docker/y-websocket-auth/auth.cjs b/docker/y-websocket-auth/auth.cjs new file mode 100644 index 0000000..e7a77ac --- /dev/null +++ b/docker/y-websocket-auth/auth.cjs @@ -0,0 +1,109 @@ +const crypto = require('crypto') +const url = require('url') + +const Y_WEBSOCKET_SECRET = process.env.Y_WEBSOCKET_SECRET || '' +const Y_WEBSOCKET_ALLOWED_ORIGINS = (process.env.Y_WEBSOCKET_ALLOWED_ORIGINS || '') + .split(',') + .map(origin => origin.trim()) + .filter(Boolean) + +function base64UrlDecode (str) { + str = str.replace(/-/g, '+').replace(/_/g, '/') + while (str.length % 4) { + str += '=' + } + return Buffer.from(str, 'base64') +} + +function verifyJwt (token, secret) { + const parts = token.split('.') + if (parts.length !== 3) { + return null + } + + const [headerB64, payloadB64, signatureB64] = parts + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(`${headerB64}.${payloadB64}`) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + if (signatureB64 !== expectedSignature) { + return null + } + + try { + const payload = JSON.parse(base64UrlDecode(payloadB64).toString('utf8')) + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + return null + } + return payload + } catch (error) { + return null + } +} + +function isOriginAllowed (origin) { + if (Y_WEBSOCKET_ALLOWED_ORIGINS.length === 0) { + return true + } + if (!origin) { + return false + } + return Y_WEBSOCKET_ALLOWED_ORIGINS.includes(origin) +} + +function getDocIdFromRequest (request) { + const parsedUrl = url.parse(request.url || '', true) + const pathname = parsedUrl.pathname || '' + const docId = pathname.replace(/^\/+/, '').split('/')[0] + return docId || null +} + +function getTokenFromRequest (request) { + const parsedUrl = url.parse(request.url || '', true) + return parsedUrl.query.token || null +} + +function isAuthEnabled () { + return Boolean(Y_WEBSOCKET_SECRET) +} + +function authorizeUpgrade (request) { + if (!isAuthEnabled()) { + return { authorized: true } + } + + const origin = request.headers.origin + if (!isOriginAllowed(origin)) { + return { authorized: false, reason: 'origin not allowed' } + } + + const docId = getDocIdFromRequest(request) + const token = getTokenFromRequest(request) + if (!docId || !token) { + return { authorized: false, reason: 'missing doc id or token' } + } + + const payload = verifyJwt(token, Y_WEBSOCKET_SECRET) + if (!payload) { + return { authorized: false, reason: 'invalid token' } + } + + if (payload.doc_id !== docId) { + return { authorized: false, reason: 'doc id mismatch' } + } + + return { authorized: true, docId } +} + +module.exports = { + authorizeUpgrade, + isAuthEnabled, + verifyJwt, + isOriginAllowed, + getDocIdFromRequest, + getTokenFromRequest +} diff --git a/docker/y-websocket-auth/server-wrapper.cjs b/docker/y-websocket-auth/server-wrapper.cjs new file mode 100644 index 0000000..3452824 --- /dev/null +++ b/docker/y-websocket-auth/server-wrapper.cjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +const WebSocket = require('ws') +const http = require('http') +const number = require('lib0/number') +const wss = new WebSocket.Server({ noServer: true }) +const setupWSConnection = require('./bin/utils.cjs').setupWSConnection +const { authorizeUpgrade, isAuthEnabled } = require('./auth.cjs') + +const host = process.env.HOST || 'localhost' +const port = number.parseInt(process.env.PORT || '1234') + +const server = http.createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/plain' }) + response.end('okay') +}) + +wss.on('connection', setupWSConnection) + +server.on('upgrade', (request, socket, head) => { + const authResult = authorizeUpgrade(request) + if (!authResult.authorized) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + socket.destroy() + return + } + + wss.handleUpgrade(request, socket, head, /** @param {any} ws */ ws => { + wss.emit('connection', ws, request) + }) +}) + +server.listen(port, host, () => { + if (isAuthEnabled()) { + console.log(`running at '${host}' on port ${port} with auth enabled`) + } else { + console.log(`running at '${host}' on port ${port} (auth disabled: Y_WEBSOCKET_SECRET is not set)`) + } +}) From c62c15ba217130105467f3fd1abb5191244de655 Mon Sep 17 00:00:00 2001 From: NishikawaKoharu Date: Fri, 25 Sep 2026 14:41:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?y-websocket=20JWT=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=82=92=E3=83=A9=E3=82=A4=E3=83=96=E3=83=A9=E3=83=AA=E5=8C=96?= =?UTF-8?q?=E3=81=97fail-fast=E3=81=A8sub=E5=BF=85=E9=A0=88=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/y-websocket-auth/Dockerfile | 2 + docker/y-websocket-auth/auth.cjs | 91 ++++++++++++---------- docker/y-websocket-auth/server-wrapper.cjs | 21 +++-- 3 files changed, 66 insertions(+), 48 deletions(-) diff --git a/docker/y-websocket-auth/Dockerfile b/docker/y-websocket-auth/Dockerfile index 059912c..ffe9bb5 100644 --- a/docker/y-websocket-auth/Dockerfile +++ b/docker/y-websocket-auth/Dockerfile @@ -15,6 +15,8 @@ WORKDIR /home/node/app COPY auth.cjs server-wrapper.cjs ./ +USER root +RUN npm install jsonwebtoken@9.0.2 --omit=dev USER node EXPOSE 1234 diff --git a/docker/y-websocket-auth/auth.cjs b/docker/y-websocket-auth/auth.cjs index e7a77ac..5181577 100644 --- a/docker/y-websocket-auth/auth.cjs +++ b/docker/y-websocket-auth/auth.cjs @@ -1,5 +1,5 @@ -const crypto = require('crypto') const url = require('url') +const jwt = require('jsonwebtoken') const Y_WEBSOCKET_SECRET = process.env.Y_WEBSOCKET_SECRET || '' const Y_WEBSOCKET_ALLOWED_ORIGINS = (process.env.Y_WEBSOCKET_ALLOWED_ORIGINS || '') @@ -7,36 +7,20 @@ const Y_WEBSOCKET_ALLOWED_ORIGINS = (process.env.Y_WEBSOCKET_ALLOWED_ORIGINS || .map(origin => origin.trim()) .filter(Boolean) -function base64UrlDecode (str) { - str = str.replace(/-/g, '+').replace(/_/g, '/') - while (str.length % 4) { - str += '=' +function assertYWebsocketSecretConfigured () { + if (!Y_WEBSOCKET_SECRET) { + console.error('Y_WEBSOCKET_SECRET is required; refusing to start without authentication') + process.exit(1) } - return Buffer.from(str, 'base64') } function verifyJwt (token, secret) { - const parts = token.split('.') - if (parts.length !== 3) { - return null - } - - const [headerB64, payloadB64, signatureB64] = parts - const expectedSignature = crypto - .createHmac('sha256', secret) - .update(`${headerB64}.${payloadB64}`) - .digest('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - - if (signatureB64 !== expectedSignature) { - return null - } - try { - const payload = JSON.parse(base64UrlDecode(payloadB64).toString('utf8')) - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + const payload = jwt.verify(token, secret, { + algorithms: ['HS256'] + }) + // Require exp so tokens without expiry are rejected. + if (!payload || typeof payload !== 'object' || !payload.exp) { return null } return payload @@ -62,46 +46,71 @@ function getDocIdFromRequest (request) { return docId || null } +/** + * Read the token query parameter. + * Accepts only a single non-empty string. + * Multiple values (?token=a&token=b) become an array and are rejected. + */ function getTokenFromRequest (request) { const parsedUrl = url.parse(request.url || '', true) - return parsedUrl.query.token || null -} + const token = parsedUrl.query.token -function isAuthEnabled () { - return Boolean(Y_WEBSOCKET_SECRET) -} + if (token === undefined || token === null || token === '') { + return { token: null } + } -function authorizeUpgrade (request) { - if (!isAuthEnabled()) { - return { authorized: true } + if (typeof token !== 'string') { + return { + token: null, + error: 'invalid token parameter', + statusCode: 400 + } } + return { token } +} + +function authorizeUpgrade (request) { const origin = request.headers.origin if (!isOriginAllowed(origin)) { - return { authorized: false, reason: 'origin not allowed' } + return { authorized: false, reason: 'origin not allowed', statusCode: 401 } } const docId = getDocIdFromRequest(request) - const token = getTokenFromRequest(request) + const tokenResult = getTokenFromRequest(request) + if (tokenResult.error) { + return { + authorized: false, + reason: tokenResult.error, + statusCode: tokenResult.statusCode || 400 + } + } + + const token = tokenResult.token if (!docId || !token) { - return { authorized: false, reason: 'missing doc id or token' } + return { authorized: false, reason: 'missing doc id or token', statusCode: 401 } } const payload = verifyJwt(token, Y_WEBSOCKET_SECRET) if (!payload) { - return { authorized: false, reason: 'invalid token' } + return { authorized: false, reason: 'invalid token', statusCode: 401 } } if (payload.doc_id !== docId) { - return { authorized: false, reason: 'doc id mismatch' } + return { authorized: false, reason: 'doc id mismatch', statusCode: 401 } + } + + // Require signed subject (OSF user GUID) for connection traceability. + if (typeof payload.sub !== 'string' || !payload.sub) { + return { authorized: false, reason: 'missing subject', statusCode: 401 } } - return { authorized: true, docId } + return { authorized: true, docId, sub: payload.sub } } module.exports = { + assertYWebsocketSecretConfigured, authorizeUpgrade, - isAuthEnabled, verifyJwt, isOriginAllowed, getDocIdFromRequest, diff --git a/docker/y-websocket-auth/server-wrapper.cjs b/docker/y-websocket-auth/server-wrapper.cjs index 3452824..42fbea3 100644 --- a/docker/y-websocket-auth/server-wrapper.cjs +++ b/docker/y-websocket-auth/server-wrapper.cjs @@ -5,7 +5,12 @@ const http = require('http') const number = require('lib0/number') const wss = new WebSocket.Server({ noServer: true }) const setupWSConnection = require('./bin/utils.cjs').setupWSConnection -const { authorizeUpgrade, isAuthEnabled } = require('./auth.cjs') +const { + assertYWebsocketSecretConfigured, + authorizeUpgrade +} = require('./auth.cjs') + +assertYWebsocketSecretConfigured() const host = process.env.HOST || 'localhost' const port = number.parseInt(process.env.PORT || '1234') @@ -20,20 +25,22 @@ wss.on('connection', setupWSConnection) server.on('upgrade', (request, socket, head) => { const authResult = authorizeUpgrade(request) if (!authResult.authorized) { - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') + const statusCode = authResult.statusCode || 401 + const statusText = statusCode === 400 ? 'Bad Request' : 'Unauthorized' + socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\n\r\n`) socket.destroy() return } + console.log( + `authorized connection docId=${authResult.docId} sub=${authResult.sub}` + ) + wss.handleUpgrade(request, socket, head, /** @param {any} ws */ ws => { wss.emit('connection', ws, request) }) }) server.listen(port, host, () => { - if (isAuthEnabled()) { - console.log(`running at '${host}' on port ${port} with auth enabled`) - } else { - console.log(`running at '${host}' on port ${port} (auth disabled: Y_WEBSOCKET_SECRET is not set)`) - } + console.log(`running at '${host}' on port ${port} with auth enabled`) })