Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions api/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion api/src/audit/audit.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
import { Observable, tap } from "rxjs"
import { Request } from "express"
import { AuditService } from "./audit.service"
import { env } from "../config/env"
import { getRequestIp, maskRequestIp } from "../common/ip-mask"

const SENSITIVE_ACTIONS: Record<string, string> = {
"POST /auth/login": "login",
Expand All @@ -28,7 +30,7 @@ export class AuditInterceptor implements NestInterceptor {

if (!action) return next.handle()

const ip = (req.headers["x-forwarded-for"] as string) ?? req.ip ?? ""
const ip = maskRequestIp(getRequestIp(req), env.LOG_IP_MASKING) ?? ""
const userId = (req as Request & { user?: { id: number } }).user?.id ?? null

return next.handle().pipe(
Expand Down
7 changes: 2 additions & 5 deletions api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as bcrypt from "bcrypt"
import { AuthService } from "./auth.service"
import { User, UsersRepository } from "./users.repository"
import { TokenDenylistService } from "./token-denylist.service"
import { PasswordResetService } from "./password-reset.service"

jest.mock("bcrypt", () => ({
hash: jest.fn(),
Expand Down Expand Up @@ -58,10 +59,6 @@ function mockPasswordResetService(): MockPasswordResetService {
}
}

interface MockJwtDecodedPayload {
exp?: number
}

function makeService(
jwt: MockJwtService,
users: MockUsersRepository,
Expand All @@ -71,7 +68,7 @@ function makeService(
return new AuthService(
jwt as unknown as JwtService,
users as unknown as UsersRepository,
passwordReset as unknown as any,
passwordReset as unknown as PasswordResetService,
tokenDenylist as unknown as TokenDenylistService,
)
}
Expand Down
33 changes: 33 additions & 0 deletions api/src/common/ip-mask.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { maskRequestIp } from "./ip-mask"

describe("maskRequestIp", () => {
it("returns null when the input is null", () => {
expect(maskRequestIp(null, "last-octet")).toBeNull()
})

it("returns the original IP when mode is none", () => {
expect(maskRequestIp("192.168.1.42", "none")).toBe("192.168.1.42")
expect(maskRequestIp("2001:db8::1", "none")).toBe("2001:db8::1")
})

it("masks the last octet for IPv4 addresses", () => {
expect(maskRequestIp("192.168.1.42", "last-octet")).toBe("192.168.1.0")
})

it("masks IPv4-mapped IPv6 addresses by zeroing the IPv4 octet", () => {
expect(maskRequestIp("::ffff:192.168.1.42", "last-octet")).toBe("::ffff:192.168.1.0")
})

it("masks the last 64 bits of IPv6 addresses", () => {
expect(maskRequestIp("2001:db8:85a3::8a2e:370:7334", "last-octet")).toBe(
"2001:db8:85a3:0:0:0:0:0",
)
expect(maskRequestIp("2001:db8::1", "last-octet")).toBe(
"2001:db8:0:0:0:0:0:0",
)
})

it("hashes the IP for full-hash mode", () => {
expect(maskRequestIp("192.168.1.42", "full-hash")).toMatch(/^sha256:[0-9a-f]{64}$/)
})
})
78 changes: 78 additions & 0 deletions api/src/common/ip-mask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createHash } from "crypto"
import { isIP } from "net"
import type { Request } from "express"

export type LogIpMasking = "none" | "last-octet" | "full-hash"

export function getRequestIp(req: Request): string | null {
return (
(req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ??
req.ip ??
null
)
}

export function maskRequestIp(
ip: string | null | undefined,
mode: LogIpMasking,
): string | null {
if (ip == null) return null
if (mode === "none") return ip
if (mode === "full-hash") return hashIp(ip)
return maskIpLastOctet(ip)
}

function hashIp(ip: string): string {
return `sha256:${createHash("sha256").update(ip).digest("hex")}`
}

function maskIpLastOctet(ip: string): string {
const trimmed = ip.trim()
if (!trimmed) return trimmed

const v4Candidate = trimmed.split("/")[0]
if (isIP(v4Candidate) === 4) {
return maskIpv4(v4Candidate)
}

if (isIpv4MappedIpv6(trimmed)) {
const mapped = trimmed.substring(trimmed.lastIndexOf(":") + 1)
return `::ffff:${maskIpv4(mapped)}`
}

if (isIP(trimmed) === 6) {
return maskIpv6Last64(trimmed)
}

return trimmed
}

function maskIpv4(ip: string): string {
const parts = ip.split(".")
if (parts.length !== 4) return ip
parts[3] = "0"
return parts.join(".")
}

function isIpv4MappedIpv6(ip: string): boolean {
return /^::ffff:(\d{1,3}\.){3}\d{1,3}$/i.test(ip)
}

function maskIpv6Last64(ip: string): string {
const normalized = expandIpv6(ip)
const blocks = normalized.split(":")
return `${blocks.slice(0, 4).join(":")}:0:0:0:0`
}

function expandIpv6(ip: string): string {
if (!ip.includes("::")) {
return ip
}

const [left, right] = ip.split("::")
const leftBlocks = left ? left.split(":").filter(Boolean) : []
const rightBlocks = right ? right.split(":").filter(Boolean) : []
const missing = 8 - leftBlocks.length - rightBlocks.length
const zeros = Array(Math.max(0, missing)).fill("0")
return [...leftBlocks, ...zeros, ...rightBlocks].join(":")
}
3 changes: 3 additions & 0 deletions api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const envSchema = z.object({
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
STREAM_API_KEY: z.string().min(1, "STREAM_API_KEY is required"),
LOG_IP_MASKING: z
.enum(["none", "last-octet", "full-hash"])
.default("last-octet"),
})

export type Env = z.infer<typeof envSchema>
Expand Down
31 changes: 17 additions & 14 deletions api/src/gateways/streams.gateway.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Test } from "@nestjs/testing"
import { JwtService } from "@nestjs/jwt"
import type { Server } from "socket.io"
import { StreamsGateway } from "./streams.gateway"
import { STREAM_EVENTS } from "./stream-events"

Expand Down Expand Up @@ -36,6 +37,8 @@ function makeSocket(overrides: Partial<FakeSocket> = {}): FakeSocket {
}
}

type TestSocket = FakeSocket & { data: { userId?: string | number } }

function makeServer(): {
server: FakeServer
events: Array<{ room: string; event: string; payload: unknown }>
Expand Down Expand Up @@ -74,7 +77,7 @@ describe("StreamsGateway", () => {
})
jwtService.verifyAsync.mockResolvedValue({ sub: 42 })

await gateway.handleConnection(socket as unknown as any)
await gateway.handleConnection(socket as TestSocket)

expect(jwtService.verifyAsync).toHaveBeenCalledWith("valid-token")
expect(socket.data.userId).toBe(42)
Expand All @@ -86,7 +89,7 @@ describe("StreamsGateway", () => {
const socket = makeSocket({ handshake: { auth: { token: "bad-token" } } })
jwtService.verifyAsync.mockRejectedValue(new Error("jwt malformed"))

await gateway.handleConnection(socket as unknown as any)
await gateway.handleConnection(socket as TestSocket)

expect(socket.emit).toHaveBeenCalledWith(
STREAM_EVENTS.ERROR,
Expand All @@ -101,7 +104,7 @@ describe("StreamsGateway", () => {
it("rejects a client with no token", async () => {
const socket = makeSocket({ handshake: { auth: {} } })

await gateway.handleConnection(socket as unknown as any)
await gateway.handleConnection(socket as TestSocket)

expect(socket.emit).toHaveBeenCalledWith(
STREAM_EVENTS.ERROR,
Expand All @@ -123,7 +126,7 @@ describe("StreamsGateway", () => {
})
jwtService.verifyAsync.mockResolvedValue({ sub: "user-99" })

await gateway.handleConnection(socket as unknown as any)
await gateway.handleConnection(socket as TestSocket)

expect(jwtService.verifyAsync).toHaveBeenCalledWith("header-token")
expect(socket.emit).toHaveBeenCalledWith("connected", {
Expand All @@ -135,7 +138,7 @@ describe("StreamsGateway", () => {
describe("stream room lifecycle", () => {
it("allows an authenticated client to subscribe", () => {
const socket = makeSocket({ data: { userId: 55 } })
const result = gateway.handleSubscribe(socket as unknown as any, {
const result = gateway.handleSubscribe(socket as TestSocket, {
streamId: "abc",
})

Expand All @@ -145,7 +148,7 @@ describe("StreamsGateway", () => {

it("rejects an unauthenticated client from subscribing", () => {
const socket = makeSocket({ data: {} })
const result = gateway.handleSubscribe(socket as unknown as any, {
const result = gateway.handleSubscribe(socket as TestSocket, {
streamId: "abc",
})

Expand All @@ -155,15 +158,15 @@ describe("StreamsGateway", () => {

it("rejects an authenticated client from subscribing without a streamId", () => {
const socket = makeSocket({ data: { userId: 55 } })
const result = gateway.handleSubscribe(socket as unknown as any, {})
const result = gateway.handleSubscribe(socket as TestSocket, {})

expect(result).toEqual({ ok: false, error: "streamId required" })
expect(socket.join).not.toHaveBeenCalled()
})

it("allows an authenticated client to unsubscribe", () => {
const socket = makeSocket({ data: { userId: 55 } })
const result = gateway.handleUnsubscribe(socket as unknown as any, {
const result = gateway.handleUnsubscribe(socket as TestSocket, {
streamId: "abc",
})

Expand All @@ -173,7 +176,7 @@ describe("StreamsGateway", () => {

it("rejects an unauthenticated client from unsubscribing", () => {
const socket = makeSocket({ data: {} })
const result = gateway.handleUnsubscribe(socket as unknown as any, {
const result = gateway.handleUnsubscribe(socket as TestSocket, {
streamId: "abc",
})

Expand All @@ -183,16 +186,16 @@ describe("StreamsGateway", () => {

it("rejects unsubscribe calls without a streamId", () => {
const socket = makeSocket({ data: { userId: 55 } })
const result = gateway.handleUnsubscribe(socket as unknown as any, {})
const result = gateway.handleUnsubscribe(socket as TestSocket, {})

expect(result).toEqual({ ok: false, error: "streamId required" })
expect(socket.leave).not.toHaveBeenCalled()
})

it("supports duplicate subscriptions without failure", () => {
const socket = makeSocket({ data: { userId: 55 } })
gateway.handleSubscribe(socket as unknown as any, { streamId: "abc" })
const second = gateway.handleSubscribe(socket as unknown as any, {
gateway.handleSubscribe(socket as TestSocket, { streamId: "abc" })
const second = gateway.handleSubscribe(socket as TestSocket, {
streamId: "abc",
})

Expand All @@ -204,7 +207,7 @@ describe("StreamsGateway", () => {
describe("emit helpers", () => {
it("broadcasts only to the correct stream room", () => {
const { server, events } = makeServer()
gateway.server = server as unknown as any
gateway.server = server as unknown as Server

gateway.emitStarted({
streamId: "1",
Expand Down Expand Up @@ -260,7 +263,7 @@ describe("StreamsGateway", () => {
it("does not throw when a socket disconnects", () => {
const socket = makeSocket({ data: { userId: 99 } })
expect(() =>
gateway.handleDisconnect(socket as unknown as any),
gateway.handleDisconnect(socket as TestSocket),
).not.toThrow()
})
})
Expand Down
15 changes: 13 additions & 2 deletions api/src/middleware/request-logger.middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { Response } from "express"
import { RequestLoggerMiddleware } from "./request-logger.middleware"
import type { AuthedRequest } from "./request-logger.middleware"

let RequestLoggerMiddleware: typeof import("./request-logger.middleware").RequestLoggerMiddleware

beforeAll(async () => {
process.env.DATABASE_URL ??= "postgres://localhost/test"
process.env.JWT_SECRET ??= "test-jwt-secret"
process.env.STREAM_API_KEY ??= "test-stream-api-key"
process.env.LOG_IP_MASKING ??= "last-octet"
jest.resetModules()
const module = await import("./request-logger.middleware")
RequestLoggerMiddleware = module.RequestLoggerMiddleware
})

type FakeResOptions = {
statusCode?: number
contentLength?: string | number | null
Expand Down Expand Up @@ -98,7 +109,7 @@ describe("RequestLoggerMiddleware", () => {
path: "/streams",
statusCode: 201,
userId: 42,
ip: "1.2.3.4",
ip: "1.2.3.0",
bodyRedacted: false,
contentLength: "128",
})
Expand Down
Loading
Loading