Ratelimit endpoints (#20)

This commit is contained in:
Barnaby 2022-01-23 20:35:45 +00:00 committed by GitHub
parent 1ad019523c
commit 0654d159e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 184 additions and 87 deletions

View File

@ -1,16 +1,19 @@
const path = require( "path" )
const accounts = require( path.join( __dirname, "../shared/accounts.js" ) )
const accounts = require( path.join( __dirname, "../shared/accounts.js" ) )
module.exports = ( fastify, opts, done ) => {
const { getRatelimit } = require("../shared/ratelimit.js")
module.exports = ( fastify, opts, done ) => {
fastify.register( require( "fastify-multipart" ) )
// exported routes
// POST /accounts/write_persistence
// attempts to write persistent data for a player
// note: this is entirely insecure atm, at the very least, we should prevent it from being called on servers that the account being written to isn't currently connected to
fastify.post( '/accounts/write_persistence',
fastify.post( '/accounts/write_persistence',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__ACCOUNT_WRITEPERSISTENCE") }, // ratelimit
schema: {
querystring: {
"id": { type: "string" },
@ -19,14 +22,14 @@ module.exports = ( fastify, opts, done ) => {
},
},
async ( request, response ) => {
let clientIp = request.ip
// pull the client ip address from a custom header if one is specified
if (process.env.CLIENT_IP_HEADER && request.headers[process.env.CLIENT_IP_HEADER])
clientIp = request.headers[process.env.CLIENT_IP_HEADER]
// check if account exists
// check if account exists
let account = await accounts.AsyncGetPlayerByID( request.query.id )
if ( !account )
return null
@ -42,15 +45,15 @@ module.exports = ( fastify, opts, done ) => {
if ( !server || clientIp != server.ip || account.currentServerId != request.query.serverId )
return null
}
// mostly temp
let buf = await ( await request.file() ).toBuffer()
let buf = await ( await request.file() ).toBuffer()
if ( buf.length == account.persistentDataBaseline.length )
await accounts.AsyncWritePlayerPersistenceBaseline( request.query.id, buf )
return null
})
done()
}

View File

@ -1,19 +1,22 @@
const path = require( "path" )
const crypto = require( "crypto" )
const { GameServer, GetGameServers } = require( path.join( __dirname, "../shared/gameserver.js" ) )
const accounts = require( path.join( __dirname, "../shared/accounts.js" ) )
const asyncHttp = require( path.join( __dirname, "../shared/asynchttp.js" ) )
const accounts = require( path.join( __dirname, "../shared/accounts.js" ) )
const asyncHttp = require( path.join( __dirname, "../shared/asynchttp.js" ) )
let shouldRequireSessionToken = process.env.REQUIRE_SESSION_TOKEN = true
const { getRatelimit } = require("../shared/ratelimit.js")
module.exports = ( fastify, opts, done ) => {
// exported routes
// POST /client/origin_auth
// used to authenticate a user on northstar, so we know the person using their uid is really them
// returns the user's northstar session token
fastify.get( '/client/origin_auth',
fastify.get( '/client/origin_auth',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__CLIENT_ORIGINAUTH") }, // ratelimit
schema: {
querystring: {
id: { type: "string" }, // the authing player's id
@ -35,20 +38,20 @@ module.exports = ( fastify, opts, done ) => {
port: 443,
path: `/nucleus-oauth.php?qt=origin-requesttoken&type=server_token&code=${ request.query.token }&forceTrial=0&proto=0&json=1&&env=production&userId=${ parseInt( request.query.id ).toString(16).toUpperCase() }`
} )
let authJson
try {
authJson = JSON.parse( authResponse.toString() )
} catch (error) {
return { success: false }
}
// check origin auth was fine
// unsure if we can check the exact value of storeUri? doing an includes check just in case
if ( !authResponse.length || authJson.hasOnlineAccess != "1" /* this is actually a string of either "1" or "0" */ || !authJson.storeUri.includes( "titanfall-2" ) )
return { success: false }
}
let account = await accounts.AsyncGetPlayerByID( request.query.id )
if ( !account ) // create account for user
{
@ -60,7 +63,7 @@ module.exports = ( fastify, opts, done ) => {
accounts.AsyncUpdateCurrentPlayerAuthToken( account.id, authToken )
let clientIp = request.ip
// pull the client ip address from a custom header if one is specified
if (process.env.CLIENT_IP_HEADER && request.headers[process.env.CLIENT_IP_HEADER])
clientIp = request.headers[process.env.CLIENT_IP_HEADER]
@ -76,8 +79,9 @@ module.exports = ( fastify, opts, done ) => {
// POST /client/auth_with_server
// attempts to authenticate a client with a gameserver, so they can connect
// authentication includes giving them a 1-time token to join the gameserver, as well as sending their persistent data to the gameserver
fastify.post( '/client/auth_with_server',
fastify.post( '/client/auth_with_server',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__CLIENT_AUTHWITHSERVER") }, // ratelimit
schema: {
querystring: {
id: { type: "string" }, // id of the player trying to auth
@ -89,14 +93,14 @@ module.exports = ( fastify, opts, done ) => {
},
async ( request, reply ) => {
let server = GetGameServers()[ request.query.server ]
if ( !server || ( server.hasPassword && request.query.password != server.password ) )
return { success: false }
let account = await accounts.AsyncGetPlayerByID( request.query.id )
if ( !account )
return { success: false }
if ( shouldRequireSessionToken )
{
// check token
@ -110,41 +114,42 @@ module.exports = ( fastify, opts, done ) => {
// fix this: game doesnt seem to set serverFilter right if it's >31 chars long, so restrict it to 31
let authToken = crypto.randomBytes( 16 ).toString( "hex" ).substr( 0, 31 )
// todo: build persistent data here, rather than sending baseline only
let pdata = await accounts.AsyncGetPlayerPersistenceBufferForMods( request.query.id, server.modInfo.Mods.filter( m => !!m.pdiff ).map( m => m.pdiff ) )
let authResponse = await asyncHttp.request( {
method: "POST",
host: server.ip,
port: server.authPort,
let authResponse = await asyncHttp.request( {
method: "POST",
host: server.ip,
port: server.authPort,
path: `/authenticate_incoming_player?id=${request.query.id}&authToken=${authToken}&serverAuthToken=${server.serverAuthToken}`
}, pdata )
if ( !authResponse )
return { success: false }
let jsonResponse = JSON.parse( authResponse.toString() )
if ( !jsonResponse.success )
return { success: false }
// update the current server for the player account
accounts.AsyncUpdatePlayerCurrentServer( account.id, server.id )
return {
success: true,
ip: server.ip,
port: server.port,
authToken: authToken
}
})
// POST /client/auth_with_self
// attempts to authenticate a client with their own server, before the server is created
// note: atm, this just sends pdata to clients and doesn't do any kind of auth stuff, potentially rewrite later
fastify.post( '/client/auth_with_self',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__CLIENT_AUTHWITHSELF") }, // ratelimit
schema: {
querystring: {
id: { type: "string" }, // id of the player trying to auth
@ -156,7 +161,7 @@ module.exports = ( fastify, opts, done ) => {
let account = await accounts.AsyncGetPlayerByID( request.query.id )
if ( !account )
return { success: false }
if ( shouldRequireSessionToken )
{
// check token
@ -171,16 +176,16 @@ module.exports = ( fastify, opts, done ) => {
// fix this: game doesnt seem to set serverFilter right if it's >31 chars long, so restrict it to 31
let authToken = crypto.randomBytes( 16 ).toString("hex").substr( 0, 31 )
accounts.AsyncUpdatePlayerCurrentServer( account.id, "self" ) // bit of a hack: use the "self" id for local servers
return {
success: true,
id: account.id,
authToken: authToken,
// this fucking sucks, but i couldn't get game to behave if i sent it as an ascii string, so using this for now
persistentData: Array.from( new Uint8Array( account.persistentDataBaseline ) )
persistentData: Array.from( new Uint8Array( account.persistentDataBaseline ) )
}
})
done()
}

View File

@ -1,12 +1,13 @@
const path = require( "path" )
const fs = require( "fs" )
const { getRatelimit } = require("../shared/ratelimit.js")
let promodataPath = path.join( __dirname, "mainmenupromodata.json" )
// watch the mainmenupromodata file so we can update it without a masterserver restart
fs.watch( promodataPath, ( curr, prev ) => {
try
try
{
mainMenuPromoData = JSON.parse( fs.readFileSync( promodataPath ).toString() )
console.log( "updated main menu promo data successfully!" )
@ -24,11 +25,13 @@ if ( fs.existsSync( promodataPath ))
module.exports = ( fastify, opts, done ) => {
// exported routes
// GET /client/mainmenupromos
// returns main menu promo info
fastify.get( '/client/mainmenupromos',
{},
fastify.get( '/client/mainmenupromos',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__CLIENT_MAINMENUPROMOS") }, // ratelimit
},
async ( request, reply ) => {
return mainMenuPromoData
})

View File

@ -1,9 +1,14 @@
const { getRatelimit } = require("../shared/ratelimit.js")
module.exports = ( fastify, opts, done ) => {
// exported routes
// GET /
// redirect anyone going to northstar.tf in a browser to the github
fastify.get( '/',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__REDIRECT") }, // ratelimit
},
async ( request, reply ) => {
reply.redirect( "https://github.com/R2Northstar" )
})
@ -11,6 +16,9 @@ module.exports = ( fastify, opts, done ) => {
// GET /discord
// redirect anyone going to northstar.tf/discord to the discord
fastify.get( '/discord',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__REDIRECT") }, // ratelimit
},
async ( request, reply ) => {
reply.redirect( "https://discord.gg/GYVRKC9pJh" )
})
@ -18,6 +26,9 @@ module.exports = ( fastify, opts, done ) => {
// GET /wiki
// redirect anyone going to northstar.tf/wiki to the wiki
fastify.get( '/wiki',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__REDIRECT") }, // ratelimit
},
async ( request, reply ) => {
reply.redirect( "https://r2northstar.gitbook.io/" )
})

View File

@ -1,32 +1,38 @@
const path = require( "path" )
const { GameServer, GetGameServers, RemoveGameServer } = require( path.join( __dirname, "../shared/gameserver.js" ) )
const { getRatelimit } = require("../shared/ratelimit.js")
module.exports = ( fastify, opts, done ) => {
fastify.register(require( "fastify-cors" ))
// exported routes
// GET /client/servers
// GET /client/servers
// returns a list of available servers
fastify.get( '/client/servers', async ( request, response ) => {
fastify.get( '/client/servers',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__CLIENT_SERVERS") }, // ratelimit
},
async ( request, response ) => {
let displayServerArray = []
let expiredServers = [] // might be better to move this to another function at some point, but easiest to do here atm
let servers = Object.values( GetGameServers() )
for ( let i = 0; i < servers.length; i++ )
{
{
// prune servers if they've had 30 seconds since last heartbeat
if ( Date.now() - servers[ i ].lastHeartbeat > 30000 )
{
expiredServers.push( servers[ i ] )
continue
}
// don't show non-private_match servers on lobby since they'll pollute server list
if ( servers[ i ].map == "mp_lobby" && servers[ i ].playlist != "private_match" )
continue
// create a copy of the gameserver obj for clients so we can hide sensitive info
let copy = new GameServer( servers[ i ] )
delete copy.ip
@ -34,16 +40,16 @@ module.exports = ( fastify, opts, done ) => {
delete copy.authPort
delete copy.password
delete copy.serverAuthToken
displayServerArray.push( copy )
}
// delete servers that we've marked for deletion
for ( let server of expiredServers )
RemoveGameServer( server )
return displayServerArray
})
done()
}

17
dev.env
View File

@ -12,4 +12,19 @@ CLIENT_IP_HEADER=
# not used for dev
SSL_KEY_PATH=
SSL_CERT_PATH=
TRUST_PROXY=
TRUST_PROXY=
# ratelimit
USE_RATELIMIT=1
REQ_PER_MINUTE__GLOBAL=100
REQ_PER_MINUTE__REDIRECT=25
REQ_PER_MINUTE__CLIENT_ORIGINAUTH=5
REQ_PER_MINUTE__CLIENT_AUTHWITHSERVER=10
REQ_PER_MINUTE__CLIENT_AUTHWITHSELF=25
REQ_PER_MINUTE__CLIENT_MAINMENUPROMOS=20
REQ_PER_MINUTE__CLIENT_SERVERS=100
REQ_PER_MINUTE__SERVER_ADDSERVER=5
REQ_PER_MINUTE__SERVER_HEARTBEAT=60
REQ_PER_MINUTE__SERVER_UPDATEVALUES=20
REQ_PER_MINUTE__SERVER_REMOVESERVER=5
REQ_PER_MINUTE__ACCOUNT_WRITEPERSISTENCE=50

View File

@ -29,6 +29,23 @@ else
const ROUTE_PATHS = [ "client", "server", "account" ]
if(!!(process.env.USE_RATELIMIT)) {
fastify.register(require('fastify-rate-limit'),
{
global: false,
errorResponseBuilder: function(req, context) {
return {
code: 429,
error: 'Too Many Requests',
message: `Request limit reached. You can send ${context.max} requests every ${context.after}. Timeout expiry: ${context.ttl}ms.`,
date: Date.now(),
expiresIn: context.ttl // milliseconds
}
}
}
);
}
for ( let routePath of ROUTE_PATHS )
{
for ( let file of fs.readdirSync( routePath ) )

21
package-lock.json generated
View File

@ -12,6 +12,7 @@
"fastify": "^3.24.0",
"fastify-cors": "^6.0.2",
"fastify-multipart": "^5.1.0",
"fastify-rate-limit": "^5.7.0",
"sqlite3": "^5.0.2"
},
"devDependencies": {
@ -1254,6 +1255,16 @@
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.0.tgz",
"integrity": "sha512-ZdCvKEEd92DNLps5n0v231Bha8bkz1DjnPP/aEz37rz/q42Z5JVLmgnqR4DYuNn3NXAO3IDCPyRvgvxtJ4Ym4w=="
},
"node_modules/fastify-rate-limit": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/fastify-rate-limit/-/fastify-rate-limit-5.7.0.tgz",
"integrity": "sha512-9pfnVpz6rUy7VGqBVN9blIT7LgmETiK7hRA0Pu8eHJFq8qq8FBPvrQYXxGXb2nt63laD6ZuvT1O7TxucZI1HIA==",
"dependencies": {
"fastify-plugin": "^3.0.0",
"ms": "^2.1.1",
"tiny-lru": "^7.0.0"
}
},
"node_modules/fastify-warning": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/fastify-warning/-/fastify-warning-0.2.0.tgz",
@ -4521,6 +4532,16 @@
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-3.0.0.tgz",
"integrity": "sha512-ZdCvKEEd92DNLps5n0v231Bha8bkz1DjnPP/aEz37rz/q42Z5JVLmgnqR4DYuNn3NXAO3IDCPyRvgvxtJ4Ym4w=="
},
"fastify-rate-limit": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/fastify-rate-limit/-/fastify-rate-limit-5.7.0.tgz",
"integrity": "sha512-9pfnVpz6rUy7VGqBVN9blIT7LgmETiK7hRA0Pu8eHJFq8qq8FBPvrQYXxGXb2nt63laD6ZuvT1O7TxucZI1HIA==",
"requires": {
"fastify-plugin": "^3.0.0",
"ms": "^2.1.1",
"tiny-lru": "^7.0.0"
}
},
"fastify-warning": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/fastify-warning/-/fastify-warning-0.2.0.tgz",

View File

@ -20,6 +20,7 @@
"fastify": "^3.24.0",
"fastify-cors": "^6.0.2",
"fastify-multipart": "^5.1.0",
"fastify-rate-limit": "^5.7.0",
"sqlite3": "^5.0.2"
},
"devDependencies": {
@ -27,4 +28,4 @@
"husky": "^7.0.0",
"nodemon": "^2.0.15"
}
}
}

View File

@ -1,27 +1,29 @@
const path = require( "path" )
const crypto = require( "crypto" )
const { GameServer, GetGameServers, AddGameServer, RemoveGameServer } = require( path.join( __dirname, "../shared/gameserver.js" ) )
const asyncHttp = require( path.join( __dirname, "../shared/asynchttp.js" ) )
const asyncHttp = require( path.join( __dirname, "../shared/asynchttp.js" ) )
const pjson = require( path.join( __dirname, "../shared/pjson.js" ) )
const Filter = require('bad-words')
let filter = new Filter();
const VERIFY_STRING = "I am a northstar server!"
const { getRatelimit } = require("../shared/ratelimit.js")
async function SharedTryAddServer( request )
{
// check server's verify endpoint on their auth server, make sure it's fine
// in the future we could probably check the server's connect port too, with a c2s_connect packet or smth, but atm this is good enough
let clientIp = request.ip
// pull the client ip address from a custom header if one is specified
if (process.env.CLIENT_IP_HEADER && request.headers[process.env.CLIENT_IP_HEADER])
clientIp = request.headers[process.env.CLIENT_IP_HEADER]
let hasValidModInfo = true
let modInfo
if ( request.isMultipart() )
{
try
@ -38,10 +40,10 @@ async function SharedTryAddServer( request )
port: request.query.authPort,
path: "/verify"
})
if ( !authServerResponse || authServerResponse.toString() != VERIFY_STRING )
return { success: false }
// pdiff stuff
if ( modInfo && modInfo.Mods )
{
@ -55,7 +57,7 @@ async function SharedTryAddServer( request )
mod.pdiff = pjson.ParseDefinitionDiffs( mod.pdiff )
mod.pdiff.hash = pdiffHash
}
catch ( ex )
catch ( ex )
{
mod.pdiff = null
}
@ -74,7 +76,7 @@ async function SharedTryAddServer( request )
request.query.port = parseInt( request.query.port )
if ( typeof request.query.authPort == 'string' )
request.query.authPort = parseInt( request.query.authPort )
request.query.authPort = parseInt( request.query.authPort )
let name = filter.clean( request.query.name )
let description = request.query.description == "" ? "" : filter.clean( request.query.description )
@ -92,11 +94,12 @@ module.exports = ( fastify, opts, done ) => {
fastify.register( require( "fastify-multipart" ) )
// exported routes
// POST /server/add_server
// adds a gameserver to the server list
fastify.post( '/server/add_server',
fastify.post( '/server/add_server',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__SERVER_ADDSERVER") }, // ratelimit
schema: {
querystring: {
port: { type: "integer" }, // the port the gameserver is being hosted on ( for connect )
@ -113,11 +116,12 @@ module.exports = ( fastify, opts, done ) => {
async ( request, reply ) => {
return SharedTryAddServer( request )
})
// POST /server/heartbeat
// refreshes a gameserver's last heartbeat time, gameservers are removed after 30 seconds without a heartbeat
fastify.post( '/server/heartbeat',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__SERVER_HEARTBEAT") }, // ratelimit
schema: {
querystring: {
id: { type: "string" }, // the id of the server sending this message
@ -126,20 +130,20 @@ module.exports = ( fastify, opts, done ) => {
}
},
async ( request, reply ) => {
let clientIp = request.ip
// pull the client ip address from a custom header if one is specified
if (process.env.CLIENT_IP_HEADER && request.headers[process.env.CLIENT_IP_HEADER])
clientIp = request.headers[process.env.CLIENT_IP_HEADER]
let server = GetGameServers()[ request.query.id ]
// dont update if the server doesnt exist, or the server isnt the one sending the heartbeat
if ( !server || clientIp != server.ip || !request.query.id )// remove !request.playerCount as if playercount==0 it will trigger skip heartbeat update
{
return null
}
else // Added else so update heartbeat will trigger,Have to add the brackets for me to work for some reason
{
server.lastHeartbeat = Date.now()
@ -147,21 +151,24 @@ module.exports = ( fastify, opts, done ) => {
return null
}
})
// POST /server/update_values
// updates values shown on the server list, such as map, playlist, or player count
// no schema for this one, since it's fully dynamic and fastify doesnt do optional params
fastify.post( '/server/update_values', async ( request, reply ) => {
fastify.post( '/server/update_values',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__SERVER_UPDATEVALUES") }, // ratelimit
},
async ( request, reply ) => {
let clientIp = request.ip
// pull the client ip address from a custom header if one is specified
if (process.env.CLIENT_IP_HEADER && request.headers[process.env.CLIENT_IP_HEADER])
clientIp = request.headers[process.env.CLIENT_IP_HEADER]
if ( !( "id" in request.query ) )
return null
let server = GetGameServers()[ request.query.id ]
// if server doesn't exist, try adding it
@ -174,12 +181,12 @@ module.exports = ( fastify, opts, done ) => {
// update heartbeat
server.lastHeartbeat = Date.now()
for ( let key of Object.keys( request.query ) )
{
if ( key == "id" || key == "port" || key == "authport" || !( key in server ) || request.query[ key ].length >= 512 )
continue
if ( key == "playerCount" || key == "maxPlayers" )
{
server[ key ] = parseInt( request.query[ key ] )
@ -189,14 +196,15 @@ module.exports = ( fastify, opts, done ) => {
server[ key ] = request.query[ key ]
}
}
return null
})
// DELETE /server/remove_server
// DELETE /server/remove_server
// removes a gameserver from the server list
fastify.delete( '/server/remove_server',
{
config: { rateLimit: getRatelimit("REQ_PER_MINUTE__SERVER_REMOVESERVER") }, // ratelimit
schema: {
querystring: {
id: { type: "string" }
@ -208,10 +216,10 @@ module.exports = ( fastify, opts, done ) => {
// dont remove if the server doesnt exist, or the server isnt the one sending the heartbeat
if ( !server || clientIp != server.ip )
return null
RemoveGameServer( server )
return null
})
done()
}

7
shared/ratelimit.js Normal file
View File

@ -0,0 +1,7 @@
function getRatelimit(envVar) {
return { max: Number(process.env[envVar]) || (Number(process.env.REQ_PER_MINUTE__GLOBAL) || 9999) }
}
module.exports = {
getRatelimit
}