You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/store-server/test/edge-guards.test.ts

87 lines
4.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
isLanIPv4, isLanAddress, parsePrinterPorts, checkPrintEnvelope, checkPrintAddress,
DEFAULT_PRINTER_PORTS, MAX_PRINT_BYTES,
} from '../src/print-guard'
import { createRateLimiter } from '../src/rate-limit'
import { isSessionExpired, DEFAULT_SESSION_TTL } from '../src/session-policy'
/**
* Pure guards behind the P1 edge hardening (red-team H1, M2, M4): the LAN/port/byte print
* validator, the sliding-window rate limiter, and the session-TTL check. Clock-injectable, so
* they test without timers or sockets. The HTTP-surface behaviour lives in edge.test.ts.
*/
// ---- H1: /api/print SSRF allowlist ----------------------------------------------------------
describe('print-guard: LAN address classification (H1)', () => {
it('accepts loopback + RFC-1918 private ranges', () => {
for (const ip of ['127.0.0.1', '127.255.255.254', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.100']) {
expect(isLanIPv4(ip)).toBe(true)
}
})
it('rejects public IPs, link-local, and the 172.16/12 edges outside the block', () => {
for (const ip of ['8.8.8.8', '1.1.1.1', '169.254.1.1', '172.15.255.255', '172.32.0.1', '0.0.0.0', '255.255.255.255', 'not-an-ip', '']) {
expect(isLanIPv4(ip)).toBe(false)
}
})
it('treats IPv6 loopback ::1 as LAN, other IPv6 as off-LAN', () => {
expect(isLanAddress('::1')).toBe(true)
expect(isLanAddress('2001:4860:4860::8888')).toBe(false)
})
})
describe('print-guard: port allowlist + payload cap (H1)', () => {
it('defaults to {9100,9101,9102} and parses SIMS_PRINTER_PORTS', () => {
expect([...parsePrinterPorts(undefined)].sort()).toEqual([...DEFAULT_PRINTER_PORTS])
expect([...parsePrinterPorts('')].sort()).toEqual([...DEFAULT_PRINTER_PORTS])
expect([...parsePrinterPorts('abc,,-1,70000')].sort()).toEqual([...DEFAULT_PRINTER_PORTS]) // all invalid → default
expect([...parsePrinterPorts('9100, 9200')].sort()).toEqual([9100, 9200])
})
it('checkPrintEnvelope enforces byte cap and port allowlist', () => {
const ports = new Set(DEFAULT_PRINTER_PORTS)
expect(checkPrintEnvelope(100, 9100, ports)).toEqual({ ok: true })
expect(checkPrintEnvelope(MAX_PRINT_BYTES + 1, 9100, ports)).toMatchObject({ ok: false, code: 'E-2201' })
expect(checkPrintEnvelope(100, 80, ports)).toMatchObject({ ok: false, code: 'E-2202' })
})
it('checkPrintAddress rejects a public IP, accepts a private one', () => {
expect(checkPrintAddress('127.0.0.1')).toEqual({ ok: true })
expect(checkPrintAddress('8.8.8.8')).toMatchObject({ ok: false, code: 'E-2203' })
})
})
// ---- Rate limiter (M2/M4) -------------------------------------------------------------------
describe('rate limiter: sliding window (M2/M4)', () => {
it('allows up to max within the window then rejects, and slides', () => {
const rl = createRateLimiter({ windowMs: 1000, max: 2 })
expect(rl.hit('ip', 0)).toBe(true)
expect(rl.hit('ip', 100)).toBe(true)
expect(rl.hit('ip', 200)).toBe(false) // 3rd within window blocked
expect(rl.remaining('ip', 200)).toBe(0)
expect(rl.hit('ip', 1101)).toBe(true) // first attempt (t=0) aged out → allowed again
})
it('keys are independent', () => {
const rl = createRateLimiter({ windowMs: 1000, max: 1 })
expect(rl.hit('a', 0)).toBe(true)
expect(rl.hit('b', 0)).toBe(true)
expect(rl.hit('a', 0)).toBe(false)
})
})
// ---- Session TTL (M4) -----------------------------------------------------------------------
describe('session TTL check (M4)', () => {
const ttl = { absoluteMs: 1000, idleMs: 100 }
it('is live within both bounds', () => {
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 0 }, 50, ttl)).toBe(false)
})
it('expires past the idle timeout even with recent creation', () => {
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 0 }, 150, ttl)).toBe(true)
})
it('expires past the absolute lifetime even when recently seen', () => {
expect(isSessionExpired({ createdMs: 0, lastSeenMs: 990 }, 1001, ttl)).toBe(true)
})
it('ships sane defaults (12h absolute / 30m idle)', () => {
expect(DEFAULT_SESSION_TTL.absoluteMs).toBe(12 * 60 * 60 * 1000)
expect(DEFAULT_SESSION_TTL.idleMs).toBe(30 * 60 * 1000)
})
})