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
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
7 changes: 3 additions & 4 deletions api/src/middleware/request-logger.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Injectable, NestMiddleware } from "@nestjs/common"
import { Request, Response, NextFunction } from "express"
import { env } from "../config/env"
import { getRequestIp, maskRequestIp } from "../common/ip-mask"

const SENSITIVE_PATH_PATTERNS: RegExp[] = [/^\/auth\b/]

Expand All @@ -11,10 +13,7 @@ export class RequestLoggerMiddleware implements NestMiddleware {
const start = process.hrtime.bigint()
const isSensitive = SENSITIVE_PATH_PATTERNS.some((re) => re.test(req.path))
const userId = req.user?.id ?? null
const ip =
(req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ??
req.ip ??
null
const ip = maskRequestIp(getRequestIp(req), env.LOG_IP_MASKING)

res.on("finish", () => {
const durationMs =
Expand Down
Loading