diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index ed28121..f3e0568 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -11,7 +11,7 @@ import IconButton from '@mui/material/IconButton'; import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; import Badge from '@mui/material/Badge'; -import { apiOrigin } from '../lib/fetch'; +import { lcFetch } from '../lib/fetch'; import { getNameFromId } from '../lib/events'; import { getWcaAuthorizationUrl } from '../lib/wcaAuth'; import NotificationsIcon from '@mui/icons-material/Notifications'; @@ -73,7 +73,8 @@ function Header({ user, room, notifications }) { }; const logout = () => { - window.location = `${apiOrigin || ''}/auth/logout?redirect=${document.location.origin}/`; + lcFetch('/auth/logout', { method: 'POST' }) + .finally(() => { window.location = `${document.location.origin}/`; }); }; return ( diff --git a/client/src/lib/fetch.js b/client/src/lib/fetch.js index 605241f..b61c509 100644 --- a/client/src/lib/fetch.js +++ b/client/src/lib/fetch.js @@ -2,9 +2,45 @@ export const apiOrigin = process.env.REACT_APP_API_ORIGIN; -export const lcFetch = (url, options) => ( - fetch(`${apiOrigin}${url}`, { +const UNSAFE_METHODS = new Set(['DELETE', 'PATCH', 'POST', 'PUT']); +let csrfTokenRequest; + +const requestCsrfToken = async () => { + if (!csrfTokenRequest) { + csrfTokenRequest = fetch(`${apiOrigin}/api/csrf-token`, { + credentials: 'include', + }).then(async (response) => { + if (!response.ok) { + throw new Error('Unable to prepare a secure request.'); + } + + const { csrfToken } = await response.json(); + if (!csrfToken) { + throw new Error('Unable to prepare a secure request.'); + } + + return csrfToken; + }).catch((error) => { + csrfTokenRequest = null; + throw error; + }); + } + + return csrfTokenRequest; +}; + +const withCsrfToken = (headers, csrfToken) => ({ + ...(headers || {}), + 'x-csrf-token': csrfToken, +}); + +export const lcFetch = async (url, options = {}) => { + const method = (options.method || 'GET').toUpperCase(); + const csrfToken = UNSAFE_METHODS.has(method) ? await requestCsrfToken() : null; + + return fetch(`${apiOrigin}${url}`, { ...options, credentials: 'include', - }) -); + ...(csrfToken ? { headers: withCsrfToken(options.headers, csrfToken) } : {}), + }); +}; diff --git a/client/src/lib/fetch.test.js b/client/src/lib/fetch.test.js new file mode 100644 index 0000000..3fcd87a --- /dev/null +++ b/client/src/lib/fetch.test.js @@ -0,0 +1,48 @@ +import { lcFetch } from './fetch'; + +describe('lcFetch', () => { + beforeEach(() => { + window.fetch = jest.fn(); + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('adds a server-issued CSRF token to unsafe requests', async () => { + window.fetch + .mockResolvedValueOnce({ + json: () => Promise.resolve({ csrfToken: 'csrf-token' }), + ok: true, + }) + .mockResolvedValueOnce({ ok: true }); + + await lcFetch('/api/updatePreference', { + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + }); + + expect(window.fetch).toHaveBeenNthCalledWith(1, 'undefined/api/csrf-token', { + credentials: 'include', + }); + expect(window.fetch).toHaveBeenNthCalledWith(2, 'undefined/api/updatePreference', { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': 'csrf-token', + }, + method: 'PUT', + }); + }); + + it('does not fetch a CSRF token for safe requests', async () => { + window.fetch.mockResolvedValue({ ok: true }); + + await lcFetch('/api/me'); + + expect(window.fetch).toHaveBeenCalledTimes(1); + expect(window.fetch).toHaveBeenCalledWith('undefined/api/me', { + credentials: 'include', + }); + }); +}); diff --git a/client/src/store/middlewares/rooms.js b/client/src/store/middlewares/rooms.js index c2d9ac7..8897304 100644 --- a/client/src/store/middlewares/rooms.js +++ b/client/src/store/middlewares/rooms.js @@ -53,6 +53,7 @@ import { import { clearRoomPassword, persistRoomPassword, + purgeLegacyRoomPasswords, readRoomPassword, } from '../room/roomPasswordStorage'; import { @@ -91,6 +92,11 @@ export const createRoomsNamespaceMiddleware = ({ ackTimeoutMs = DEFAULT_ACK_TIMEOUT_MS, retryDelayMs = DEFAULT_RETRY_DELAY_MS, } = {}) => (store) => { + try { + purgeLegacyRoomPasswords(storage); + } catch { + // Passwords are intentionally kept in memory only. + } let roomsConnected = false; let joinedRoomId = null; let pendingJoinRequest = null; @@ -113,7 +119,7 @@ export const createRoomsNamespaceMiddleware = ({ const readStoredRoomPassword = (roomId) => { try { - return readRoomPassword(roomId, storage); + return readRoomPassword(roomId); } catch { return null; } @@ -121,7 +127,7 @@ export const createRoomsNamespaceMiddleware = ({ const forgetRoomPassword = (roomId) => { try { - clearRoomPassword(roomId, storage); + clearRoomPassword(roomId); } catch { // The next invalid join will try again. } @@ -145,7 +151,7 @@ export const createRoomsNamespaceMiddleware = ({ } try { - persistRoomPassword(roomId, password, storage); + persistRoomPassword(roomId, password); } catch { warnPasswordStorage(roomId); } diff --git a/client/src/store/middlewares/rooms.test.js b/client/src/store/middlewares/rooms.test.js index 3054bcc..a3f6237 100644 --- a/client/src/store/middlewares/rooms.test.js +++ b/client/src/store/middlewares/rooms.test.js @@ -20,6 +20,7 @@ import { persistPendingResult, } from '../room/resultOutbox'; import { + clearRoomPassword, persistRoomPassword, readRoomPassword, } from '../room/roomPasswordStorage'; @@ -161,6 +162,7 @@ const result = { describe('rooms middleware', () => { beforeEach(() => { window.localStorage.clear(); + ['private-room', 'new-private-room', 'room-one', 'room-two'].forEach(clearRoomPassword); }); it('keeps the loaded room mounted while reconnecting its socket', () => { @@ -195,8 +197,8 @@ describe('rooms middleware', () => { expect(joins[0].args[0]).toEqual({ id: 'room-two', password: 'secret' }); }); - it('uses a saved private room password after a refresh', () => { - persistRoomPassword('private-room', 'saved-password'); + it('does not reuse a private room password after a refresh', () => { + window.localStorage.setItem('letscube.roomPassword.v1.private-room', 'saved-password'); const { namespace, store } = buildStore({ roomState: { ...initialRoom(), @@ -209,10 +211,7 @@ describe('rooms middleware', () => { store.dispatch(joinRoom({ id: 'private-room' })); const join = emissionsFor(namespace, Protocol.JOIN_ROOM)[0]; - expect(join.args[0]).toEqual({ - id: 'private-room', - password: 'saved-password', - }); + expect(join.args[0]).toEqual({ id: 'private-room', password: null }); const joinedRoom = { ...initialRoom(), @@ -223,8 +222,9 @@ describe('rooms middleware', () => { delete joinedRoom.resultSubmission; join.args[1](null, joinedRoom); - expect(store.getState().room.password).toBe('saved-password'); - expect(readRoomPassword('private-room')).toBe('saved-password'); + expect(store.getState().room.password).toBeNull(); + expect(readRoomPassword('private-room')).toBeNull(); + expect(window.localStorage.getItem('letscube.roomPassword.v1.private-room')).toBeNull(); }); it('remembers a successful private room password', () => { diff --git a/client/src/store/room/roomPasswordStorage.js b/client/src/store/room/roomPasswordStorage.js index d282834..5129974 100644 --- a/client/src/store/room/roomPasswordStorage.js +++ b/client/src/store/room/roomPasswordStorage.js @@ -1,5 +1,7 @@ export const ROOM_PASSWORD_STORAGE_PREFIX = 'letscube.roomPassword.v1.'; +const passwords = new Map(); + const storageKey = (roomId) => { if ((typeof roomId !== 'string' && typeof roomId !== 'number') || String(roomId).length === 0) { @@ -9,24 +11,33 @@ const storageKey = (roomId) => { return `${ROOM_PASSWORD_STORAGE_PREFIX}${encodeURIComponent(String(roomId))}`; }; -export const readRoomPassword = (roomId, storage = window.localStorage) => { - const password = storage.getItem(storageKey(roomId)); - return password || null; +export const readRoomPassword = (roomId) => { + storageKey(roomId); + return passwords.get(String(roomId)) || null; }; -export const persistRoomPassword = ( - roomId, - password, - storage = window.localStorage, -) => { +export const persistRoomPassword = (roomId, password) => { if (typeof password !== 'string' || password.length === 0) { throw new Error('Cannot save an empty room password.'); } - storage.setItem(storageKey(roomId), password); + storageKey(roomId); + passwords.set(String(roomId), password); return password; }; -export const clearRoomPassword = (roomId, storage = window.localStorage) => { - storage.removeItem(storageKey(roomId)); +export const clearRoomPassword = (roomId) => { + storageKey(roomId); + passwords.delete(String(roomId)); +}; + +export const purgeLegacyRoomPasswords = (storage) => { + if (!storage) return; + + for (let index = storage.length - 1; index >= 0; index -= 1) { + const key = storage.key(index); + if (key && key.startsWith(ROOM_PASSWORD_STORAGE_PREFIX)) { + storage.removeItem(key); + } + } }; diff --git a/client/src/store/room/roomPasswordStorage.test.js b/client/src/store/room/roomPasswordStorage.test.js index 81326fb..8ab1819 100644 --- a/client/src/store/room/roomPasswordStorage.test.js +++ b/client/src/store/room/roomPasswordStorage.test.js @@ -2,15 +2,18 @@ import { ROOM_PASSWORD_STORAGE_PREFIX, clearRoomPassword, persistRoomPassword, + purgeLegacyRoomPasswords, readRoomPassword, } from './roomPasswordStorage'; describe('private room password storage', () => { beforeEach(() => { window.localStorage.clear(); + clearRoomPassword('room-one'); + clearRoomPassword('room-two'); }); - it('stores passwords separately for each room', () => { + it('keeps passwords in memory for the current tab only', () => { persistRoomPassword('room-one', 'first-password'); persistRoomPassword('room-two', 'second-password'); @@ -18,7 +21,7 @@ describe('private room password storage', () => { expect(readRoomPassword('room-two')).toBe('second-password'); expect(window.localStorage.getItem( `${ROOM_PASSWORD_STORAGE_PREFIX}room-one`, - )).toBe('first-password'); + )).toBeNull(); }); it('clears a password without affecting other rooms', () => { @@ -35,4 +38,14 @@ describe('private room password storage', () => { expect(() => persistRoomPassword(null, 'secret')).toThrow('room ID'); expect(() => persistRoomPassword('room-one', '')).toThrow('empty room password'); }); + + it('removes passwords saved by older versions', () => { + window.localStorage.setItem(`${ROOM_PASSWORD_STORAGE_PREFIX}room-one`, 'legacy-secret'); + window.localStorage.setItem('unrelated', 'keep'); + + purgeLegacyRoomPasswords(window.localStorage); + + expect(window.localStorage.getItem(`${ROOM_PASSWORD_STORAGE_PREFIX}room-one`)).toBeNull(); + expect(window.localStorage.getItem('unrelated')).toBe('keep'); + }); }); diff --git a/cypress/e2e/local_stack.cy.js b/cypress/e2e/local_stack.cy.js index 8b7917b..1e8fc39 100644 --- a/cypress/e2e/local_stack.cy.js +++ b/cypress/e2e/local_stack.cy.js @@ -1,15 +1,23 @@ describe('local app stack', () => { const apiOrigin = Cypress.env('apiOrigin') || 'http://localhost:8080'; + const post = (url, body) => cy.request(`${apiOrigin}/api/csrf-token`) + .then(({ body: { csrfToken } }) => cy.request({ + body, + headers: { 'x-csrf-token': csrfToken }, + method: 'POST', + url, + })); + const login = () => { - cy.request('POST', `${apiOrigin}/auth/code`, { + return post(`${apiOrigin}/auth/code`, { code: 'cypress-test-code', redirectUri: 'http://localhost:3000/wca-redirect', }); }; const loginAs = (userId) => { - cy.request('POST', `${apiOrigin}/auth/code`, { + return post(`${apiOrigin}/auth/code`, { code: `cypress-test-user-${userId}`, redirectUri: 'http://localhost:3000/wca-redirect', }); @@ -150,7 +158,7 @@ describe('local app stack', () => { loginAs(recipientId); loginAs(requesterId); - cy.request('POST', `${apiOrigin}/api/friends/requests`, { userId: recipientId }) + post(`${apiOrigin}/api/friends/requests`, { userId: recipientId }) .its('status').should('eq', 201); loginAs(recipientId); @@ -180,10 +188,10 @@ describe('local app stack', () => { loginAs(guestId); loginAs(hostId); - cy.request('POST', `${apiOrigin}/api/friends/requests`, { userId: guestId }) + post(`${apiOrigin}/api/friends/requests`, { userId: guestId }) .its('status').should('eq', 201); loginAs(guestId); - cy.request('POST', `${apiOrigin}/api/friends/requests/${hostId}/accept`) + post(`${apiOrigin}/api/friends/requests/${hostId}/accept`) .its('status').should('eq', 200); loginAs(hostId); diff --git a/server/api.js b/server/api.js index b75f25a..ad73f23 100644 --- a/server/api.js +++ b/server/api.js @@ -1,4 +1,5 @@ const express = require('express'); +const rateLimit = require('express-rate-limit'); const { User } = require('./models'); const auth = require('./middlewares/auth.js'); @@ -7,6 +8,7 @@ const createFriendsRouter = require('./api/friends'); const createNotificationsRouter = require('./api/notifications'); const createUsersRouter = require('./api/users'); const { isFeatureEnabled } = require('./features'); +const { apiRateLimitOptions } = require('./middlewares/apiRateLimit'); const PREFERENCE_KEYS = new Set([ 'showWCAID', @@ -18,6 +20,7 @@ const PREFERENCE_KEYS = new Set([ module.exports = (app) => { const router = express.Router(); + router.use(rateLimit(apiRateLimitOptions())); const sendError = (res, err) => { const body = { status: err.statusCode, diff --git a/server/api/friends.js b/server/api/friends.js index e2b42ed..fd7e848 100644 --- a/server/api/friends.js +++ b/server/api/friends.js @@ -1,7 +1,9 @@ const express = require('express'); +const rateLimit = require('express-rate-limit'); const auth = require('../middlewares/auth'); const relationshipService = require('../social/relationshipService'); +const { apiRateLimitOptions } = require('../middlewares/apiRateLimit'); const sendError = (res, err) => res.status(err.statusCode || 500).json({ code: err.code || 'internal_error', @@ -19,6 +21,7 @@ const asyncHandler = (handler) => async (req, res) => { const createFriendsRouter = (service = relationshipService) => { const router = express.Router(); + router.use(rateLimit(apiRateLimitOptions())); router.use(auth); router.get('/', asyncHandler(async (req, res) => { diff --git a/server/api/notifications.js b/server/api/notifications.js index 0f365c3..3ec861a 100644 --- a/server/api/notifications.js +++ b/server/api/notifications.js @@ -1,7 +1,9 @@ const express = require('express'); +const rateLimit = require('express-rate-limit'); const auth = require('../middlewares/auth'); const notificationService = require('../social/notificationService'); +const { apiRateLimitOptions } = require('../middlewares/apiRateLimit'); const sendError = (res, err) => res.status(err.statusCode || 500).json({ code: err.code || 'internal_error', @@ -18,6 +20,7 @@ const asyncHandler = (handler) => async (req, res) => { const createNotificationsRouter = (service = notificationService) => { const router = express.Router(); + router.use(rateLimit(apiRateLimitOptions())); router.use(auth); router.get('/', asyncHandler(async (req, res) => { diff --git a/server/api/users.js b/server/api/users.js index 5dcc735..d8e5ba7 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -1,6 +1,8 @@ const express = require('express'); +const rateLimit = require('express-rate-limit'); const auth = require('../middlewares/auth'); const { createDiscoveryService } = require('../social/discoveryService'); +const { apiRateLimitOptions } = require('../middlewares/apiRateLimit'); const sendError = (res, err) => res.status(err.statusCode || 500).json({ code: err.code || 'user_unavailable', @@ -10,6 +12,7 @@ const sendError = (res, err) => res.status(err.statusCode || 500).json({ const createUsersRouter = (service = createDiscoveryService()) => { const router = express.Router(); + router.use(rateLimit(apiRateLimitOptions())); router.use(auth); router.get('/search', async (req, res) => { diff --git a/server/auth/index.js b/server/auth/index.js index 9a1d907..c49c081 100644 --- a/server/auth/index.js +++ b/server/auth/index.js @@ -136,13 +136,13 @@ module.exports = (app, passport) => { })(req, res, next); }); - router.get('/logout', (req, res, next) => { + router.post('/logout', (req, res, next) => { req.logout((err) => { if (err) { return next(err); } - return res.redirect(req.query.redirect); + return res.status(204).end(); }); }); diff --git a/server/index.js b/server/index.js index cfd7a20..704e262 100644 --- a/server/index.js +++ b/server/index.js @@ -4,6 +4,7 @@ const express = require('express'); const cors = require('cors'); const morgan = require('morgan'); const passport = require('passport'); +const lusca = require('lusca'); const config = require('./runtimeConfig'); const { connect } = require('./database'); @@ -65,6 +66,7 @@ const init = async () => { app.set('trust proxy', 1); app.use(session); + app.use(lusca.csrf()); app.use(passport.initialize()); app.use(passport.session()); @@ -77,6 +79,8 @@ const init = async () => { // config.cors.origin.map((o) => new RegExp(o)), })); + app.get('/api/csrf-token', (req, res) => res.json({ csrfToken: req.csrfToken() })); + app.use(express.static(path.join(__dirname, '../client/build'))); app.get('/', (req, res) => { diff --git a/server/middlewares/apiRateLimit.js b/server/middlewares/apiRateLimit.js new file mode 100644 index 0000000..877ca24 --- /dev/null +++ b/server/middlewares/apiRateLimit.js @@ -0,0 +1,20 @@ +const API_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; +const API_RATE_LIMIT_MAX = 300; + +const apiRateLimitOptions = (options = {}) => ({ + legacyHeaders: false, + limit: API_RATE_LIMIT_MAX, + message: { + code: 'rate_limited', + message: 'Too many requests. Please try again later.', + }, + standardHeaders: 'draft-8', + windowMs: API_RATE_LIMIT_WINDOW_MS, + ...options, +}); + +module.exports = { + API_RATE_LIMIT_MAX, + API_RATE_LIMIT_WINDOW_MS, + apiRateLimitOptions, +}; diff --git a/server/middlewares/apiRateLimit.test.js b/server/middlewares/apiRateLimit.test.js new file mode 100644 index 0000000..7588151 --- /dev/null +++ b/server/middlewares/apiRateLimit.test.js @@ -0,0 +1,46 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const http = require('http'); +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const { apiRateLimitOptions } = require('./apiRateLimit'); + +const request = (server) => new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + method: 'GET', + path: '/', + port: server.address().port, + }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ body: JSON.parse(body), status: res.statusCode })); + }); + req.on('error', reject); + req.end(); +}); + +describe('API rate limiter', () => { + let server; + + beforeEach((done) => { + const app = express(); + app.use(rateLimit(apiRateLimitOptions({ limit: 1, windowMs: 60_000 }))); + app.get('/', (req, res) => res.json({ ok: true })); + server = app.listen(0, '127.0.0.1', () => done()); + }); + + afterEach((done) => { server.close(done); }); + + it('rejects requests beyond the configured IP window', async () => { + expect(await request(server)).toEqual({ body: { ok: true }, status: 200 }); + expect(await request(server)).toEqual({ + body: { + code: 'rate_limited', + message: 'Too many requests. Please try again later.', + }, + status: 429, + }); + }); +}); diff --git a/server/middlewares/csrf.test.js b/server/middlewares/csrf.test.js new file mode 100644 index 0000000..aa739da --- /dev/null +++ b/server/middlewares/csrf.test.js @@ -0,0 +1,74 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const http = require('http'); +const express = require('express'); +const session = require('express-session'); +const lusca = require('lusca'); + +const request = (server, { cookie, headers, method = 'GET', path = '/' } = {}) => new Promise((resolve, reject) => { + const req = http.request({ + headers: { ...(cookie ? { cookie } : {}), ...headers }, + host: '127.0.0.1', + method, + path, + port: server.address().port, + }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ + body: body ? JSON.parse(body) : null, + cookie: res.headers['set-cookie'] && res.headers['set-cookie'][0].split(';')[0], + status: res.statusCode, + })); + }); + req.on('error', reject); + req.end(); +}); + +describe('CSRF protection', () => { + let server; + + beforeEach((done) => { + const app = express(); + app.use(session({ resave: false, saveUninitialized: false, secret: 'test-secret' })); + app.use(lusca.csrf()); + app.get('/csrf-token', (req, res) => res.json({ csrfToken: req.csrfToken() })); + app.post('/change', (req, res) => res.json({ changed: true })); + app.use((err, req, res, next) => { + if (err.message.startsWith('CSRF token')) { + return res.status(403).json({ code: 'csrf_invalid' }); + } + return next(err); + }); + server = app.listen(0, '127.0.0.1', () => done()); + }); + + afterEach((done) => { server.close(done); }); + + it('requires a session token for state-changing requests', async () => { + const tokenResponse = await request(server, { path: '/csrf-token' }); + expect(tokenResponse.status).toBe(200); + expect(tokenResponse.body.csrfToken).toEqual(expect.any(String)); + expect(tokenResponse.cookie).toEqual(expect.stringContaining('connect.sid=')); + + expect(await request(server, { + cookie: tokenResponse.cookie, + method: 'POST', + path: '/change', + })).toEqual(expect.objectContaining({ + body: { code: 'csrf_invalid' }, + status: 403, + })); + + expect(await request(server, { + cookie: tokenResponse.cookie, + headers: { 'x-csrf-token': tokenResponse.body.csrfToken }, + method: 'POST', + path: '/change', + })).toEqual(expect.objectContaining({ + body: { changed: true }, + status: 200, + })); + }); +}); diff --git a/server/package.json b/server/package.json index 460da4b..3490ae7 100644 --- a/server/package.json +++ b/server/package.json @@ -8,13 +8,15 @@ "connect-mongo": "^6.0.0", "cors": "^2.8.5", "express": "^4.22.2", + "express-rate-limit": "^8.2.1", "express-session": "^1.19.0", "getconfig": "^4.5.0", "ioredis": "^5.8.2", "letscube-scrambles": "0.1.0", "lodash": "^4.17.21", - "mongoose": "^8.24.1", + "lusca": "^1.7.0", "mongodb": "^6.21.0", + "mongoose": "^8.24.1", "morgan": "^1.11.0", "passport": "^0.7.0", "passport-custom": "^1.1.1", diff --git a/yarn.lock b/yarn.lock index f270f0b..727f4f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3529,31 +3529,6 @@ chart.js@4.5.1: dependencies: "@kurkle/color" "^0.3.0" -cheerio-select@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" - integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== - dependencies: - boolbase "^1.0.0" - css-select "^5.1.0" - css-what "^6.1.0" - domelementtype "^2.3.0" - domhandler "^5.0.3" - domutils "^3.0.1" - -cheerio@1.0.0-rc.12: - version "1.0.0-rc.12" - resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" - integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== - dependencies: - cheerio-select "^2.1.0" - dom-serializer "^2.0.0" - domhandler "^5.0.3" - domutils "^3.0.1" - htmlparser2 "^8.0.1" - parse5 "^7.0.0" - parse5-htmlparser2-tree-adapter "^7.0.0" - chokidar@^3.2.2: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -3906,17 +3881,6 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== -css-select@^5.1.0: - version "5.2.2" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e" - integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== - dependencies: - boolbase "^1.0.0" - css-what "^6.1.0" - domhandler "^5.0.2" - domutils "^3.0.1" - nth-check "^2.0.1" - css-vendor@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/css-vendor/-/css-vendor-2.0.8.tgz#e47f91d3bd3117d49180a3c935e62e3d9f7f449d" @@ -3925,11 +3889,6 @@ css-vendor@^2.0.8: "@babel/runtime" "^7.8.3" is-in-browser "^1.0.2" -css-what@^6.1.0: - version "6.2.2" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" - integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== - css.escape@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" @@ -4252,36 +4211,6 @@ dom-helpers@^5.0.1, dom-helpers@^5.1.3: "@babel/runtime" "^7.8.7" csstype "^3.0.2" -dom-serializer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" - integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== - dependencies: - domelementtype "^2.3.0" - domhandler "^5.0.2" - entities "^4.2.0" - -domelementtype@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" - integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== - -domhandler@^5.0.2, domhandler@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" - integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== - dependencies: - domelementtype "^2.3.0" - -domutils@^3.0.1: - version "3.2.2" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" - integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== - dependencies: - dom-serializer "^2.0.0" - domelementtype "^2.3.0" - domhandler "^5.0.3" - dot-prop@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" @@ -4427,11 +4356,6 @@ engine.io@~6.6.0: engine.io-parser "~5.2.1" ws "~8.21.0" -entities@^4.2.0, entities@^4.4.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" - integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== - entities@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" @@ -5014,6 +4938,13 @@ expect@^29.0.0: jest-message-util "^29.7.0" jest-util "^29.7.0" +express-rate-limit@^8.2.1: + version "8.5.2" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.5.2.tgz#5922dbf76df2124611cea955d93432b37514b2f3" + integrity sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A== + dependencies: + ip-address "^10.2.0" + express-session@^1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.19.0.tgz#682139f6eec6c69db9febbe2362e8a85dd84af2f" @@ -5703,16 +5634,6 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== -htmlparser2@^8.0.1: - version "8.0.2" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" - integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== - dependencies: - domelementtype "^2.3.0" - domhandler "^5.0.3" - domutils "^3.0.1" - entities "^4.4.0" - http-cache-semantics@^4.0.0: version "4.2.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" @@ -5937,6 +5858,11 @@ ioredis@^5.8.2: redis-parser "3.0.0" standard-as-callback "2.1.0" +ip-address@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" + integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -7339,6 +7265,13 @@ lru.min@^1.0.0, lru.min@^1.1.0: resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b" integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA== +lusca@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/lusca/-/lusca-1.7.0.tgz#a5d979f1b51776e60d41e0ca98f886f1b8b95502" + integrity sha512-msnrplCfY7zaqlZBDEloCIKld+RUeMZVeWzSPaGUKeRXFlruNSdKg2XxCyR+zj6BqzcXhXlRnvcvx6rAGgsvMA== + dependencies: + tsscmp "^1.0.5" + lz-string@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" @@ -7759,13 +7692,6 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -nth-check@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" - integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== - dependencies: - boolbase "^1.0.0" - nwsapi@^2.2.16: version "2.2.24" resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.24.tgz#f8927043d4c9b516abdebe804a32c8d1f9484d1f" @@ -8036,14 +7962,6 @@ parse-json@^5.0.0, parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5-htmlparser2-tree-adapter@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz#b5a806548ed893a43e24ccb42fbb78069311e81b" - integrity sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g== - dependencies: - domhandler "^5.0.3" - parse5 "^7.0.0" - parse5@^7.0.0, parse5@^7.2.1: version "7.3.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" @@ -9989,6 +9907,11 @@ tslib@^2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tsscmp@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"