Browse Source

Improve streaming server security (#10818)

* Check OAuth token scopes in the streaming API

* Use Sec-WebSocket-Protocol instead of query string to pass WebSocket token

Inspired by https://github.com/kubevirt/kubevirt/issues/1242
ThibG 3 weeks ago
parent
commit
d63c3c0cef
2 changed files with 44 additions and 12 deletions
  1. 1
    5
      app/javascript/mastodon/stream.js
  2. 43
    7
      streaming/index.js

+ 1
- 5
app/javascript/mastodon/stream.js View File

@@ -71,11 +71,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
71 71
 export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
72 72
   const params = [ `stream=${stream}` ];
73 73
 
74
-  if (accessToken !== null) {
75
-    params.push(`access_token=${accessToken}`);
76
-  }
77
-
78
-  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`);
74
+  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
79 75
 
80 76
   ws.onopen      = connected;
81 77
   ws.onmessage   = e => received(JSON.parse(e.data));

+ 43
- 7
streaming/index.js View File

@@ -195,14 +195,14 @@ const startWorker = (workerId) => {
195 195
     next();
196 196
   };
197 197
 
198
-  const accountFromToken = (token, req, next) => {
198
+  const accountFromToken = (token, allowedScopes, req, next) => {
199 199
     pgPool.connect((err, client, done) => {
200 200
       if (err) {
201 201
         next(err);
202 202
         return;
203 203
       }
204 204
 
205
-      client.query('SELECT oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1', [token], (err, result) => {
205
+      client.query('SELECT oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1', [token], (err, result) => {
206 206
         done();
207 207
 
208 208
         if (err) {
@@ -218,18 +218,29 @@ const startWorker = (workerId) => {
218 218
           return;
219 219
         }
220 220
 
221
+        const scopes = result.rows[0].scopes.split(' ');
222
+
223
+        if (allowedScopes.size > 0 && !scopes.some(scope => allowedScopes.includes(scope))) {
224
+          err = new Error('Access token does not cover required scopes');
225
+          err.statusCode = 401;
226
+
227
+          next(err);
228
+          return;
229
+        }
230
+
221 231
         req.accountId = result.rows[0].account_id;
222 232
         req.chosenLanguages = result.rows[0].chosen_languages;
233
+        req.allowNotifications = scopes.some(scope => ['read', 'read:notifications'].includes(scope));
223 234
 
224 235
         next();
225 236
       });
226 237
     });
227 238
   };
228 239
 
229
-  const accountFromRequest = (req, next, required = true) => {
240
+  const accountFromRequest = (req, next, required = true, allowedScopes = ['read']) => {
230 241
     const authorization = req.headers.authorization;
231 242
     const location = url.parse(req.url, true);
232
-    const accessToken = location.query.access_token;
243
+    const accessToken = location.query.access_token || req.headers['sec-websocket-protocol'];
233 244
 
234 245
     if (!authorization && !accessToken) {
235 246
       if (required) {
@@ -246,7 +257,7 @@ const startWorker = (workerId) => {
246 257
 
247 258
     const token = authorization ? authorization.replace(/^Bearer /, '') : accessToken;
248 259
 
249
-    accountFromToken(token, req, next);
260
+    accountFromToken(token, allowedScopes, req, next);
250 261
   };
251 262
 
252 263
   const PUBLIC_STREAMS = [
@@ -261,6 +272,16 @@ const startWorker = (workerId) => {
261 272
   const wsVerifyClient = (info, cb) => {
262 273
     const location = url.parse(info.req.url, true);
263 274
     const authRequired = !PUBLIC_STREAMS.some(stream => stream === location.query.stream);
275
+    const allowedScopes = [];
276
+
277
+    if (authRequired) {
278
+      allowedScopes.push('read');
279
+      if (location.query.stream === 'user:notification') {
280
+        allowedScopes.push('read:notifications');
281
+      } else {
282
+        allowedScopes.push('read:statuses');
283
+      }
284
+    }
264 285
 
265 286
     accountFromRequest(info.req, err => {
266 287
       if (!err) {
@@ -269,7 +290,7 @@ const startWorker = (workerId) => {
269 290
         log.error(info.req.requestId, err.toString());
270 291
         cb(false, 401, 'Unauthorized');
271 292
       }
272
-    }, authRequired);
293
+    }, authRequired, allowedScopes);
273 294
   };
274 295
 
275 296
   const PUBLIC_ENDPOINTS = [
@@ -286,7 +307,18 @@ const startWorker = (workerId) => {
286 307
     }
287 308
 
288 309
     const authRequired = !PUBLIC_ENDPOINTS.some(endpoint => endpoint === req.path);
289
-    accountFromRequest(req, next, authRequired);
310
+    const allowedScopes = [];
311
+
312
+    if (authRequired) {
313
+      allowedScopes.push('read');
314
+      if (req.path === '/api/v1/streaming/user/notification') {
315
+        allowedScopes.push('read:notifications');
316
+      } else {
317
+        allowedScopes.push('read:statuses');
318
+      }
319
+    }
320
+
321
+    accountFromRequest(req, next, authRequired, allowedScopes);
290 322
   };
291 323
 
292 324
   const errorMiddleware = (err, req, res, {}) => {
@@ -339,6 +371,10 @@ const startWorker = (workerId) => {
339 371
         return;
340 372
       }
341 373
 
374
+      if (event === 'notification' && !req.allowNotifications) {
375
+        return;
376
+      }
377
+
342 378
       // Only messages that may require filtering are statuses, since notifications
343 379
       // are already personalized and deletes do not matter
344 380
       if (!needsFiltering || event !== 'update') {

Loading…
Cancel
Save