VYPR
High severity7.5NVD Advisory· Published May 9, 2026· Updated May 15, 2026

CVE-2026-41893

CVE-2026-41893

Description

Signal K Server is a server application that runs on a central hub in a boat. Prior to version 2.25.0, the HTTP login endpoints (POST /login and POST /signalk/v1/auth/login) are protected by express-rate-limit (default: 100 attempts per 10-minute window, configurable via HTTP_RATE_LIMITS). The WebSocket login path — sending {login: {username, password}} messages over an established WebSocket connection — calls app.securityStrategy.login() directly without any rate limiting. An attacker can bypass HTTP rate limiting entirely by opening a WebSocket connection and attempting unlimited password guesses at the speed bcrypt allows (~20 attempts/sec with 10 salt rounds). This issue has been patched in version 2.25.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
signalk-servernpm
< 2.25.02.25.0

Patches

1
215d81eb700d

fix(security): add rate limiting to WebSocket login endpoint (#2568)

https://github.com/SignalK/signalk-serverDirk WahrheitApr 7, 2026via nvd-ref
6 files changed · +475 229
  • src/interfaces/ws.ts+36 7 modified
    @@ -24,6 +24,10 @@ import {
       InvalidTokenError,
       WithSecurityStrategy
     } from '../security'
    +import {
    +  LoginRateLimiter,
    +  LOGIN_RATE_LIMIT_MESSAGE
    +} from '../login-rate-limiter'
     import { WithConfig } from '../app'
     import {
       findRequest,
    @@ -135,6 +139,7 @@ interface SecurityStrategy {
         timeToLive?: number | null
       }>
       isDummy: () => boolean
    +  loginRateLimiter?: LoginRateLimiter
     }
     
     interface SubscriptionManager {
    @@ -668,6 +673,22 @@ function wsInterface(app: WsApp): WsApi {
         })
       }
     
    +  function getClientIp(app: WsApp, spark: Spark): string {
    +    if (
    +      app.config.settings.trustProxy &&
    +      app.config.settings.trustProxy !== 'false'
    +    ) {
    +      const forwardedFor = spark.request.headers['x-forwarded-for']
    +      if (typeof forwardedFor === 'string') {
    +        const firstIp = forwardedFor.split(',')[0].trim()
    +        if (firstIp) {
    +          return firstIp
    +        }
    +      }
    +    }
    +    return spark.request.connection.remoteAddress
    +  }
    +
       function processAccessRequest(
         app: WsApp,
         spark: Spark,
    @@ -681,13 +702,7 @@ function wsInterface(app: WsApp): WsApi {
             message: 'A request has already been submitted'
           })
         } else {
    -      const forwardedFor = spark.request.headers['x-forwarded-for']
    -      const clientIp =
    -        (app.config.settings.trustProxy &&
    -          app.config.settings.trustProxy !== 'false' &&
    -          typeof forwardedFor === 'string' &&
    -          forwardedFor) ||
    -        spark.request.connection.remoteAddress
    +      const clientIp = getClientIp(app, spark)
           requestAccess(
             app as unknown as WithSecurityStrategy & WithConfig,
             msg,
    @@ -725,6 +740,20 @@ function wsInterface(app: WsApp): WsApi {
       }
     
       function processLoginRequest(app: WsApp, spark: Spark, msg: WsMessage): void {
    +    const rateLimiter = app.securityStrategy.loginRateLimiter
    +    if (rateLimiter) {
    +      const { allowed } = rateLimiter.check(getClientIp(app, spark))
    +      if (!allowed) {
    +        spark.write({
    +          requestId: msg.requestId,
    +          state: 'COMPLETED',
    +          statusCode: 429,
    +          message: LOGIN_RATE_LIMIT_MESSAGE
    +        })
    +        return
    +      }
    +    }
    +
         app.securityStrategy
           .login(msg.login!.username, msg.login!.password)
           .then((reply) => {
    
  • src/login-rate-limiter.ts+53 0 added
    @@ -0,0 +1,53 @@
    +export const LOGIN_RATE_LIMIT_MESSAGE =
    +  'Too many login attempts from this IP, please try again later'
    +
    +export interface LoginRateLimiter {
    +  check(ip: string): { allowed: boolean; retryAfterMs: number }
    +  dispose(): void
    +}
    +
    +interface Entry {
    +  count: number
    +  resetTime: number
    +}
    +
    +export function createLoginRateLimiter(
    +  windowMs: number,
    +  max: number
    +): LoginRateLimiter {
    +  const entries = new Map<string, Entry>()
    +
    +  const cleanup = setInterval(() => {
    +    const now = Date.now()
    +    for (const [ip, entry] of entries) {
    +      if (now >= entry.resetTime) {
    +        entries.delete(ip)
    +      }
    +    }
    +  }, windowMs)
    +  cleanup.unref()
    +
    +  return {
    +    check(ip: string): { allowed: boolean; retryAfterMs: number } {
    +      const now = Date.now()
    +      let entry = entries.get(ip)
    +
    +      if (!entry || now >= entry.resetTime) {
    +        entry = { count: 0, resetTime: now + windowMs }
    +        entries.set(ip, entry)
    +      }
    +
    +      entry.count++
    +
    +      if (entry.count > max) {
    +        return { allowed: false, retryAfterMs: entry.resetTime - now }
    +      }
    +
    +      return { allowed: true, retryAfterMs: 0 }
    +    },
    +    dispose(): void {
    +      clearInterval(cleanup)
    +      entries.clear()
    +    }
    +  }
    +}
    
  • src/security.ts+4 0 modified
    @@ -32,6 +32,7 @@ import { generate } from 'selfsigned'
     import { Mode } from 'stat-mode'
     import { WithConfig } from './app'
     import { createDebug } from './debug'
    +import { LoginRateLimiter } from './login-rate-limiter'
     import dummysecurity from './dummysecurity'
     import { ICallback } from './types'
     const debug = createDebug('signalk-server:security')
    @@ -241,6 +242,9 @@ export interface SecurityStrategy {
         username: string,
         password: string
       ) => Promise<{ statusCode: number }>
    +
    +  /** Shared login rate limiter (optional - only available when token security is active) */
    +  loginRateLimiter?: LoginRateLimiter
     }
     
     export class InvalidTokenError extends Error {
    
  • src/tokensecurity.ts+16 11 modified
    @@ -28,12 +28,15 @@ import {
       Path
     } from '@signalk/server-api'
     import ms, { StringValue } from 'ms'
    -import rateLimit from 'express-rate-limit'
     import bodyParser from 'body-parser'
     import cookieParser from 'cookie-parser'
     import { createHash, randomBytes } from 'crypto'
     
     import { createDebug } from './debug'
    +import {
    +  createLoginRateLimiter,
    +  LOGIN_RATE_LIMIT_MESSAGE
    +} from './login-rate-limiter'
     import {
       InvalidTokenError,
       SecurityConfig,
    @@ -45,7 +48,6 @@ import {
       LoginStatusResponse,
       saveSecurityConfig,
       RequestStatusData,
    -  getRateLimitValidationOptions,
       ACL,
       SecurityStrategy,
       isOIDCUserIdentifier
    @@ -606,15 +608,18 @@ function tokenSecurityFactory(
           }
         }
     
    -    const loginLimiter = rateLimit({
    -      windowMs: loginWindowMs,
    -      max: loginMax,
    -      message: {
    -        message:
    -          'Too many login attempts from this IP, please try again after 10 minutes'
    -      },
    -      validate: getRateLimitValidationOptions(app)
    -    })
    +    const loginRateLimiter = createLoginRateLimiter(loginWindowMs, loginMax)
    +    strategy.loginRateLimiter = loginRateLimiter
    +
    +    const loginLimiter = (req: Request, res: Response, next: NextFunction) => {
    +      const { allowed, retryAfterMs } = loginRateLimiter.check(req.ip ?? '')
    +      if (!allowed) {
    +        res.set('Retry-After', String(Math.ceil(retryAfterMs / 1000)))
    +        res.status(429).json({ message: LOGIN_RATE_LIMIT_MESSAGE })
    +        return
    +      }
    +      next()
    +    }
     
         app.use(bodyParser.urlencoded({ extended: true }))
     
    
  • test/rate-limit.js+0 211 removed
    @@ -1,211 +0,0 @@
    -const chai = require('chai')
    -chai.Should()
    -const { startServerP } = require('./servertestutilities')
    -const { freeport } = require('./ts-servertestutilities')
    -
    -describe('Rate Limiting', () => {
    -  let server, url, port
    -
    -  before(async function () {
    -    port = await freeport()
    -    url = `http://0.0.0.0:${port}`
    -    const securityConfig = {
    -      allowNewUserRegistration: true,
    -      allowDeviceAccessRequests: true
    -    }
    -    server = await startServerP(port, true, {}, securityConfig)
    -  })
    -
    -  after(async function () {
    -    await server.stop()
    -  })
    -
    -  it('should limit login attempts', async function () {
    -    const requests = []
    -    for (let i = 0; i < 100; i++) {
    -      requests.push(
    -        fetch(`${url}/signalk/v1/auth/login`, {
    -          method: 'POST',
    -          headers: { 'Content-Type': 'application/json' },
    -          body: JSON.stringify({
    -            username: 'admin',
    -            password: 'wrongpassword'
    -          })
    -        })
    -      )
    -    }
    -
    -    await Promise.all(requests)
    -
    -    const res = await fetch(`${url}/signalk/v1/auth/login`, {
    -      method: 'POST',
    -      headers: { 'Content-Type': 'application/json' },
    -      body: JSON.stringify({
    -        username: 'admin',
    -        password: 'wrongpassword'
    -      })
    -    })
    -
    -    res.status.should.equal(429)
    -  })
    -
    -  it('should limit access requests', async function () {
    -    const requests = []
    -    for (let i = 0; i < 1000; i++) {
    -      requests.push(
    -        fetch(`${url}/signalk/v1/access/requests`, {
    -          method: 'POST',
    -          headers: { 'Content-Type': 'application/json' },
    -          body: JSON.stringify({
    -            clientId: `device-${i}`,
    -            description: `Device ${i}`
    -          })
    -        })
    -      )
    -    }
    -
    -    await Promise.all(requests)
    -
    -    const res = await fetch(`${url}/signalk/v1/access/requests`, {
    -      method: 'POST',
    -      headers: { 'Content-Type': 'application/json' },
    -      body: JSON.stringify({
    -        clientId: 'device-1001',
    -        description: 'Device 1001'
    -      })
    -    })
    -
    -    res.status.should.equal(429)
    -  })
    -
    -  it('should limit request status checks', async function () {
    -    const requests = []
    -    for (let i = 0; i < 1000; i++) {
    -      requests.push(fetch(`${url}/signalk/v1/requests/123`))
    -    }
    -
    -    await Promise.all(requests)
    -
    -    const res = await fetch(`${url}/signalk/v1/requests/123`)
    -    res.status.should.equal(429)
    -  })
    -
    -  it('should limit login status checks', async function () {
    -    const requests = []
    -    for (let i = 0; i < 1000; i++) {
    -      requests.push(fetch(`${url}/loginStatus`))
    -    }
    -
    -    await Promise.all(requests)
    -
    -    const res = await fetch(`${url}/loginStatus`)
    -    res.status.should.equal(429)
    -  })
    -})
    -
    -describe('Rate Limiting with trustProxy enabled', () => {
    -  let server, url, port
    -  let consoleErrorSpy, consoleLogSpy, capturedLogs
    -
    -  before(async function () {
    -    port = await freeport()
    -    url = `http://0.0.0.0:${port}`
    -    const securityConfig = {
    -      allowNewUserRegistration: true,
    -      allowDeviceAccessRequests: true
    -    }
    -
    -    // Capture console output to check for ERR_ERL_PERMISSIVE_TRUST_PROXY errors
    -    capturedLogs = []
    -    const originalConsoleError = console.error
    -    const originalConsoleLog = console.log
    -    consoleErrorSpy = console.error = (...args) => {
    -      capturedLogs.push(args.join(' '))
    -      originalConsoleError.apply(console, args)
    -    }
    -    consoleLogSpy = console.log = (...args) => {
    -      capturedLogs.push(args.join(' '))
    -      originalConsoleLog.apply(console, args)
    -    }
    -
    -    // Enable trustProxy: true to verify no ERR_ERL_PERMISSIVE_TRUST_PROXY errors
    -    const extraConfig = {
    -      settings: {
    -        trustProxy: true
    -      }
    -    }
    -    server = await startServerP(port, true, extraConfig, securityConfig)
    -  })
    -
    -  after(async function () {
    -    await server.stop()
    -    // Restore console methods
    -    console.error = consoleErrorSpy
    -    console.log = consoleLogSpy
    -  })
    -
    -  it('should start without rate limiter errors logged and handle requests', async function () {
    -    // Make a request to trigger rate limiting logic
    -    const res = await fetch(`${url}/signalk/v1/auth/login`, {
    -      method: 'POST',
    -      headers: { 'Content-Type': 'application/json' },
    -      body: JSON.stringify({
    -        username: 'admin',
    -        password: 'wrongpassword'
    -      })
    -    })
    -
    -    // Should get 401 (unauthorized) not a server error
    -    res.status.should.equal(401)
    -
    -    // Verify no rate limiter validation errors were logged
    -    const allLogs = capturedLogs.join('\n')
    -    allLogs.should.not.include('ERR_ERL_PERMISSIVE_TRUST_PROXY')
    -    allLogs.should.not.include('ERR_ERL_UNEXPECTED_X_FORWARDED_FOR')
    -  })
    -
    -  it('should respect X-Forwarded-For header when trustProxy is enabled', async function () {
    -    // With trustProxy enabled, rate limiting should use X-Forwarded-For for client IP
    -    // Make 2 requests from same actual client but with different X-Forwarded-For IPs
    -    // Both should succeed (not rate limited) because they appear to come from different IPs
    -
    -    const res1 = await fetch(`${url}/loginStatus`, {
    -      headers: {
    -        'X-Forwarded-For': '192.168.1.100'
    -      }
    -    })
    -    res1.status.should.be.oneOf([200, 401, 403])
    -
    -    const res2 = await fetch(`${url}/loginStatus`, {
    -      headers: {
    -        'X-Forwarded-For': '192.168.1.101'
    -      }
    -    })
    -    res2.status.should.be.oneOf([200, 401, 403])
    -
    -    // Now make 1001 requests with the SAME X-Forwarded-For IP
    -    // First 1000 should succeed, but some will be rate limited after reaching the limit
    -    const requests = []
    -    for (let i = 0; i < 1001; i++) {
    -      requests.push(
    -        fetch(`${url}/loginStatus`, {
    -          headers: {
    -            'X-Forwarded-For': '192.168.1.200'
    -          }
    -        })
    -      )
    -    }
    -
    -    const results = await Promise.all(requests)
    -
    -    // Count how many succeeded vs rate-limited
    -    const succeeded = results.filter((r) =>
    -      [200, 401, 403].includes(r.status)
    -    ).length
    -    const rateLimited = results.filter((r) => r.status === 429).length
    -
    -    // Exactly 1000 should succeed, at least 1 should be rate limited
    -    succeeded.should.equal(1000)
    -    rateLimited.should.be.at.least(1)
    -  })
    -})
    
  • test/rate-limit.ts+366 0 added
    @@ -0,0 +1,366 @@
    +import chai from 'chai'
    +import WebSocket from 'ws'
    +import { startServerP } from './servertestutilities'
    +import { freeport } from './ts-servertestutilities'
    +
    +chai.should()
    +
    +interface ServerInstance {
    +  stop: () => Promise<void>
    +}
    +
    +function wsLogin(
    +  ws: WebSocket,
    +  requestId: string,
    +  username: string,
    +  password: string
    +): Promise<{
    +  statusCode: number
    +  requestId: string
    +  state: string
    +  message?: string
    +}> {
    +  return new Promise((resolve, reject) => {
    +    function cleanup() {
    +      clearTimeout(timer)
    +      ws.removeListener('message', onMessage)
    +    }
    +    const timer = setTimeout(() => {
    +      cleanup()
    +      reject(new Error(`Timed out waiting for ${requestId} response`))
    +    }, 10000)
    +    function onMessage(data: WebSocket.Data) {
    +      const msg = JSON.parse(data.toString())
    +      if (msg.requestId === requestId) {
    +        cleanup()
    +        resolve(msg)
    +      }
    +    }
    +    ws.on('message', onMessage)
    +    ws.send(JSON.stringify({ requestId, login: { username, password } }))
    +  })
    +}
    +
    +function openWs(port: number): Promise<WebSocket> {
    +  return new Promise((resolve, reject) => {
    +    const ws = new WebSocket(
    +      `ws://0.0.0.0:${port}/signalk/v1/stream?subscribe=none`
    +    )
    +    const timer = setTimeout(
    +      () => reject(new Error('WS connection timeout')),
    +      10000
    +    )
    +    ws.on('message', function onHello(data: WebSocket.Data) {
    +      const msg = JSON.parse(data.toString())
    +      if (msg.name && msg.version) {
    +        clearTimeout(timer)
    +        ws.removeListener('message', onHello)
    +        resolve(ws)
    +      }
    +    })
    +    ws.on('error', (err) => {
    +      clearTimeout(timer)
    +      reject(err)
    +    })
    +  })
    +}
    +
    +const LOGIN_MAX = 100
    +const API_MAX = 1000
    +
    +const securityConfig = {
    +  allowNewUserRegistration: true,
    +  allowDeviceAccessRequests: true
    +}
    +
    +describe('HTTP login rate limiting', () => {
    +  let server: ServerInstance
    +  let url: string
    +
    +  before(async function () {
    +    const port = await freeport()
    +    url = `http://0.0.0.0:${port}`
    +    server = await startServerP(port, true, {}, securityConfig)
    +  })
    +
    +  after(async function () {
    +    await server.stop()
    +  })
    +
    +  it(`should return 429 after ${LOGIN_MAX} attempts`, async function () {
    +    const requests = []
    +    for (let i = 0; i < LOGIN_MAX; i++) {
    +      requests.push(
    +        fetch(`${url}/signalk/v1/auth/login`, {
    +          method: 'POST',
    +          headers: { 'Content-Type': 'application/json' },
    +          body: JSON.stringify({
    +            username: 'admin',
    +            password: 'wrongpassword'
    +          })
    +        })
    +      )
    +    }
    +    await Promise.all(requests)
    +
    +    const res = await fetch(`${url}/signalk/v1/auth/login`, {
    +      method: 'POST',
    +      headers: { 'Content-Type': 'application/json' },
    +      body: JSON.stringify({
    +        username: 'admin',
    +        password: 'wrongpassword'
    +      })
    +    })
    +
    +    res.status.should.equal(429)
    +  })
    +})
    +
    +describe('WebSocket login rate limiting', () => {
    +  let server: ServerInstance
    +  let port: number
    +
    +  before(async function () {
    +    port = await freeport()
    +    server = await startServerP(port, true, {}, securityConfig)
    +  })
    +
    +  after(async function () {
    +    await server.stop()
    +  })
    +
    +  it(`should return 429 after ${LOGIN_MAX} attempts`, async function () {
    +    this.timeout(30000)
    +    const ws = await openWs(port)
    +
    +    try {
    +      for (let i = 0; i < LOGIN_MAX; i++) {
    +        await wsLogin(ws, `ws-rate-${i}`, 'admin', 'wrongpassword')
    +      }
    +
    +      const blocked = await wsLogin(
    +        ws,
    +        'ws-rate-blocked',
    +        'admin',
    +        'wrongpassword'
    +      )
    +      blocked.statusCode.should.equal(429)
    +    } finally {
    +      ws.close()
    +    }
    +  })
    +})
    +
    +describe('Cross-protocol login rate limiting', () => {
    +  let server: ServerInstance
    +  let url: string
    +  let port: number
    +
    +  before(async function () {
    +    port = await freeport()
    +    url = `http://0.0.0.0:${port}`
    +    server = await startServerP(port, true, {}, securityConfig)
    +  })
    +
    +  after(async function () {
    +    await server.stop()
    +  })
    +
    +  it('should share the rate limit budget between HTTP and WebSocket', async function () {
    +    this.timeout(30000)
    +
    +    const half = LOGIN_MAX / 2
    +    const httpRequests = []
    +    for (let i = 0; i < half; i++) {
    +      httpRequests.push(
    +        fetch(`${url}/signalk/v1/auth/login`, {
    +          method: 'POST',
    +          headers: { 'Content-Type': 'application/json' },
    +          body: JSON.stringify({
    +            username: 'admin',
    +            password: 'wrongpassword'
    +          })
    +        })
    +      )
    +    }
    +    await Promise.all(httpRequests)
    +
    +    const ws = await openWs(port)
    +    try {
    +      for (let i = 0; i < half; i++) {
    +        await wsLogin(ws, `ws-cross-${i}`, 'admin', 'wrongpassword')
    +      }
    +
    +      const blockedWs = await wsLogin(
    +        ws,
    +        'ws-cross-blocked',
    +        'admin',
    +        'wrongpassword'
    +      )
    +      blockedWs.statusCode.should.equal(429)
    +
    +      const blockedHttp = await fetch(`${url}/signalk/v1/auth/login`, {
    +        method: 'POST',
    +        headers: { 'Content-Type': 'application/json' },
    +        body: JSON.stringify({
    +          username: 'admin',
    +          password: 'wrongpassword'
    +        })
    +      })
    +      blockedHttp.status.should.equal(429)
    +    } finally {
    +      ws.close()
    +    }
    +  })
    +})
    +
    +describe('HTTP API rate limiting', () => {
    +  let server: ServerInstance
    +  let url: string
    +
    +  before(async function () {
    +    const port = await freeport()
    +    url = `http://0.0.0.0:${port}`
    +    server = await startServerP(port, true, {}, securityConfig)
    +  })
    +
    +  after(async function () {
    +    await server.stop()
    +  })
    +
    +  it('should limit access requests', async function () {
    +    const requests = []
    +    for (let i = 0; i < API_MAX; i++) {
    +      requests.push(
    +        fetch(`${url}/signalk/v1/access/requests`, {
    +          method: 'POST',
    +          headers: { 'Content-Type': 'application/json' },
    +          body: JSON.stringify({
    +            clientId: `device-${i}`,
    +            description: `Device ${i}`
    +          })
    +        })
    +      )
    +    }
    +
    +    await Promise.all(requests)
    +
    +    const res = await fetch(`${url}/signalk/v1/access/requests`, {
    +      method: 'POST',
    +      headers: { 'Content-Type': 'application/json' },
    +      body: JSON.stringify({
    +        clientId: 'device-1001',
    +        description: 'Device 1001'
    +      })
    +    })
    +
    +    res.status.should.equal(429)
    +  })
    +
    +  it('should limit request status checks', async function () {
    +    const requests = []
    +    for (let i = 0; i < API_MAX; i++) {
    +      requests.push(fetch(`${url}/signalk/v1/requests/123`))
    +    }
    +
    +    await Promise.all(requests)
    +
    +    const res = await fetch(`${url}/signalk/v1/requests/123`)
    +    res.status.should.equal(429)
    +  })
    +
    +  it('should limit login status checks', async function () {
    +    const requests = []
    +    for (let i = 0; i < API_MAX; i++) {
    +      requests.push(fetch(`${url}/loginStatus`))
    +    }
    +
    +    await Promise.all(requests)
    +
    +    const res = await fetch(`${url}/loginStatus`)
    +    res.status.should.equal(429)
    +  })
    +})
    +
    +describe('Rate limiting with trustProxy enabled', () => {
    +  let server: ServerInstance
    +  let url: string
    +  let originalConsoleError: typeof console.error
    +  let originalConsoleLog: typeof console.log
    +  let capturedLogs: string[]
    +
    +  before(async function () {
    +    const port = await freeport()
    +    url = `http://0.0.0.0:${port}`
    +
    +    capturedLogs = []
    +    originalConsoleError = console.error
    +    originalConsoleLog = console.log
    +    console.error = (...args: unknown[]) => {
    +      capturedLogs.push(args.join(' '))
    +      originalConsoleError.apply(console, args)
    +    }
    +    console.log = (...args: unknown[]) => {
    +      capturedLogs.push(args.join(' '))
    +      originalConsoleLog.apply(console, args)
    +    }
    +
    +    const extraConfig = {
    +      settings: {
    +        trustProxy: true
    +      }
    +    }
    +    server = await startServerP(port, true, extraConfig, securityConfig)
    +  })
    +
    +  after(async function () {
    +    await server.stop()
    +    console.error = originalConsoleError
    +    console.log = originalConsoleLog
    +  })
    +
    +  it('should start without rate limiter errors logged and handle requests', async function () {
    +    const res = await fetch(`${url}/signalk/v1/auth/login`, {
    +      method: 'POST',
    +      headers: { 'Content-Type': 'application/json' },
    +      body: JSON.stringify({
    +        username: 'admin',
    +        password: 'wrongpassword'
    +      })
    +    })
    +
    +    res.status.should.equal(401)
    +
    +    const allLogs = capturedLogs.join('\n')
    +    allLogs.should.not.include('ERR_ERL_PERMISSIVE_TRUST_PROXY')
    +    allLogs.should.not.include('ERR_ERL_UNEXPECTED_X_FORWARDED_FOR')
    +  })
    +
    +  it('should use X-Forwarded-For for per-IP rate limit buckets', async function () {
    +    const requests = []
    +    for (let i = 0; i < API_MAX; i++) {
    +      requests.push(
    +        fetch(`${url}/loginStatus`, {
    +          headers: {
    +            'X-Forwarded-For': '192.168.1.200'
    +          }
    +        })
    +      )
    +    }
    +    await Promise.all(requests)
    +
    +    const blocked = await fetch(`${url}/loginStatus`, {
    +      headers: {
    +        'X-Forwarded-For': '192.168.1.200'
    +      }
    +    })
    +    blocked.status.should.equal(429)
    +
    +    const differentIp = await fetch(`${url}/loginStatus`, {
    +      headers: {
    +        'X-Forwarded-For': '192.168.1.201'
    +      }
    +    })
    +    differentIp.status.should.be.oneOf([200, 401, 403])
    +  })
    +})
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

6

News mentions

0

No linked articles in our index yet.