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.
87 lines
4.2 KiB
TypeScript
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)
|
|
})
|
|
})
|