diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 28f8d7b..e06ee35 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -622,6 +622,18 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcrypt@6.0.0': resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} @@ -2023,6 +2035,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -3445,6 +3460,27 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + '@types/bcrypt@6.0.0': dependencies: '@types/node': 20.19.30 @@ -5140,6 +5176,8 @@ snapshots: node-gyp-build@4.8.4: {} + node-int64@0.4.0: {} + node-releases@2.0.27: {} normalize-path@3.0.0: {} diff --git a/api/src/audit/audit.interceptor.ts b/api/src/audit/audit.interceptor.ts index c15d234..0cce800 100644 --- a/api/src/audit/audit.interceptor.ts +++ b/api/src/audit/audit.interceptor.ts @@ -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 = { "POST /auth/login": "login", @@ -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( diff --git a/api/src/common/ip-mask.spec.ts b/api/src/common/ip-mask.spec.ts new file mode 100644 index 0000000..bae90ab --- /dev/null +++ b/api/src/common/ip-mask.spec.ts @@ -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}$/) + }) +}) diff --git a/api/src/common/ip-mask.ts b/api/src/common/ip-mask.ts new file mode 100644 index 0000000..87c6412 --- /dev/null +++ b/api/src/common/ip-mask.ts @@ -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(":") +} diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 2d9b2fd..43617d5 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -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 diff --git a/api/src/middleware/request-logger.middleware.ts b/api/src/middleware/request-logger.middleware.ts index c19cae8..d1d9fde 100644 --- a/api/src/middleware/request-logger.middleware.ts +++ b/api/src/middleware/request-logger.middleware.ts @@ -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/] @@ -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 =