diff --git a/backend/.env.development.example b/backend/.env.development.example index 2f36cf9..2d7ea4d 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -9,7 +9,14 @@ # docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다. # ========================================== -# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. +# JWT 서명 키 — 액세스·리프레시 토큰을 서명한다. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. +# 반드시 **무작위로 만든다.** 사람이 지은 문장·단어 조합·회사명·날짜 등은 쓰지 않는다 +# (토큰 하나만 손에 넣으면 오프라인에서 키를 대입해 볼 수 있고, 맞히면 모든 직원의 토큰을 위조할 수 있다). +# 만드는 법: openssl rand -base64 48 +# (openssl 이 없으면) node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +# 운영은 32자 이상이 아니면 기동하지 않는다 (src/shared/config/env.validation.ts). JWT_SECRET=change-me-generate-a-long-random-string +# 만료 — 액세스 5분~1시간, 리프레시(미사용 만료) 30일 이하이며 액세스보다 길어야 한다 (env 검증). +# 세션 절대 만료(로그인 후 30일)는 코드 상수다. JWT_ACCESS_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=14d diff --git a/backend/.env.production.example b/backend/.env.production.example index 71068cd..c90c8ee 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -9,7 +9,14 @@ # docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다. # ========================================== -# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. +# JWT 서명 키 — 액세스·리프레시 토큰을 서명한다. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. +# 반드시 **무작위로 만든다.** 사람이 지은 문장·단어 조합·회사명·날짜 등은 쓰지 않는다 +# (토큰 하나만 손에 넣으면 오프라인에서 키를 대입해 볼 수 있고, 맞히면 모든 직원의 토큰을 위조할 수 있다). +# 만드는 법: openssl rand -base64 48 +# (openssl 이 없으면) node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +# 운영은 32자 이상이 아니면 기동하지 않는다 (src/shared/config/env.validation.ts). JWT_SECRET=change-me-generate-a-long-random-string +# 만료 — 액세스 5분~1시간, 리프레시(미사용 만료) 30일 이하이며 액세스보다 길어야 한다 (env 검증). +# 세션 절대 만료(로그인 후 30일)는 코드 상수다. JWT_ACCESS_EXPIRES_IN=1h JWT_REFRESH_EXPIRES_IN=14d diff --git a/backend/package-lock.json b/backend/package-lock.json index f62dd97..d6a5489 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,8 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.6", @@ -22,11 +24,15 @@ "cache-manager": "^6.4.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "cookie-parser": "^1.4.7", "helmet": "^8.1.0", "nest-winston": "^1.10.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sanitize-html": "^2.18.0", "typeorm": "^0.3.28", "winston": "^3.17.0" }, @@ -38,10 +44,13 @@ "@nestjs/testing": "^11.0.1", "@swc/cli": "^0.8.1", "@swc/core": "^1.15.24", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/multer": "^2.2.0", - "@types/node": "^22.10.7", + "@types/node": "^24.19.2", + "@types/passport-jwt": "^4.0.1", + "@types/sanitize-html": "^2.16.2", "@types/supertest": "^6.0.2", "chokidar": "^5.0.0", "eslint": "^9.18.0", @@ -209,7 +218,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -315,7 +323,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -375,6 +382,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2675,6 +2683,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2705,6 +2714,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -2871,6 +2881,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -2917,6 +2928,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", "license": "MIT", + "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -2951,6 +2963,19 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", @@ -2971,11 +2996,22 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.2.5", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.5.tgz", "integrity": "sha512-R3LSHqgPQo7ZTdYqA39lvdHS2RBnVJUePKIpL+cr1oB3HtYj9qPdhdmS307eLDc1VeTPFXAnSuVfcG9lMDEFSw==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3124,7 +3160,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -3322,6 +3357,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -3465,6 +3501,7 @@ "integrity": "sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@xhmikosr/bin-wrapper": "^14.0.0", @@ -3537,6 +3574,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" @@ -3920,6 +3958,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -3962,6 +4010,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4040,6 +4089,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/luxon": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", @@ -4053,6 +4112,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/multer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", @@ -4064,13 +4129,45 @@ } }, "node_modules/@types/node": { - "version": "22.19.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", - "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", - "devOptional": true, + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" } }, "node_modules/@types/qs": { @@ -4087,6 +4184,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/sanitize-html": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.2.tgz", + "integrity": "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^12.0" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -4213,6 +4320,7 @@ "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", @@ -4975,16 +5083,16 @@ } }, "node_modules/@xhmikosr/decompress": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/@xhmikosr/decompress/-/decompress-11.1.3.tgz", - "integrity": "sha512-NiyhJq6z7ERsYghcnXZUI6ooDXgZtoB+G9eUsYhfSM4VLp2rKx9UxhKI1NEf1PqosrNPxG3bnSsr2UBVbNurlg==", + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress/-/decompress-11.1.4.tgz", + "integrity": "sha512-ZbYL7SAfY37/TMpopqBR3mQiuQ76kI/RNpN4q82YHSw/UxktZNy8P4wgiKPSrTImMAWRaUG8UK1pgEa56YdLaw==", "dev": true, "license": "MIT", "dependencies": { - "@xhmikosr/decompress-tar": "^9.0.1", - "@xhmikosr/decompress-tarbz2": "^9.0.1", + "@xhmikosr/decompress-tar": "^9.0.2", + "@xhmikosr/decompress-tarbz2": "^9.0.2", "@xhmikosr/decompress-targz": "^9.0.1", - "@xhmikosr/decompress-unzip": "^8.1.1", + "@xhmikosr/decompress-unzip": "^8.2.1", "graceful-fs": "^4.2.11", "strip-dirs": "^3.0.0" }, @@ -4993,13 +5101,13 @@ } }, "node_modules/@xhmikosr/decompress-tar": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-tar/-/decompress-tar-9.0.1.tgz", - "integrity": "sha512-4AkVR1SoqTxYY22IRRYKDeLirPIDGqMqYsqgjKYuwhgRcBb+yDP4t5Xph33UCzL/nahK/aADmlMEjTNstbX7kw==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@xhmikosr/decompress-tar/-/decompress-tar-9.0.2.tgz", + "integrity": "sha512-8nPZ6lZ3ExhsSxi/X/PMB3K+Vtsuxk43HowxYpxw4AsCHTYqFBXwC8B3Y+M/meaUOGOVm+2tFNUAfWGRjBt+Ww==", "dev": true, "license": "MIT", "dependencies": { - "file-type": "^21.3.0", + "file-type": "^21.3.4", "is-stream": "^4.0.1", "tar-stream": "3.1.7" }, @@ -5119,6 +5227,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5168,6 +5277,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5876,6 +5986,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", @@ -5938,6 +6049,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5983,6 +6100,7 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-6.4.3.tgz", "integrity": "sha512-VV5eq/QQ5rIVix7/aICO4JyvSeEv9eIQuKL5iFwgM2BrcYoE0A/D1mNsAHJAsB0WEbNdBlKkn6Tjz6fKzh/cKQ==", "license": "MIT", + "peer": true, "dependencies": { "keyv": "^5.3.3" } @@ -6154,6 +6272,7 @@ "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^5.0.0" }, @@ -6201,13 +6320,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6516,6 +6637,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -6615,9 +6755,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.21", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", - "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", "license": "MIT" }, "node_modules/debug": { @@ -6678,7 +6818,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6764,6 +6903,73 @@ "node": ">=0.3.1" } }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", @@ -6823,6 +7029,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6884,6 +7099,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -6966,7 +7193,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -6981,6 +7207,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7041,6 +7268,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8104,13 +8332,13 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -8214,10 +8442,32 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, @@ -8489,6 +8739,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -8653,6 +8912,7 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -9573,11 +9833,55 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -9598,6 +9902,15 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/launder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz", + "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.23" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -9690,6 +10003,42 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -9704,6 +10053,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -10123,6 +10478,24 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -10516,6 +10889,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -10525,6 +10904,43 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10601,6 +11017,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -10613,6 +11034,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -10701,7 +11123,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -10728,9 +11149,9 @@ } }, "node_modules/piscina": { - "version": "4.9.3", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.9.3.tgz", - "integrity": "sha512-3e3ka9QCE8RJ5I9uszdAADZnkcYi21cqmF3gxox3u884N72qpFHCsIVhHt8cEQ9t3Auq/NqoiCEuhxlxxQuDWA==", + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.9.4.tgz", + "integrity": "sha512-RyBDr2VheQ8ZfH3N8SzQZHztqVyOtadTwLnUYof6gdj5161/eu2wNhCbGl5GHnNKDpnkicJYpKtL2J+y/gk6XA==", "dev": true, "license": "MIT", "optionalDependencies": { @@ -10825,6 +11246,34 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -10880,6 +11329,7 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10949,9 +11399,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -10959,6 +11409,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/punycode": { @@ -11089,7 +11543,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -11208,6 +11663,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11247,6 +11703,24 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.0.tgz", + "integrity": "sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.1.0", + "launder": "^1.7.2", + "parse-srcset": "^1.0.2", + "postcss": "^8.5.27" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -11294,7 +11768,6 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11581,6 +12054,15 @@ "node": ">= 12" } }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -12071,6 +12553,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12453,6 +12936,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12623,6 +13107,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz", "integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.3.1", @@ -12838,6 +13323,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12920,10 +13406,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, "node_modules/unicorn-magic": { @@ -13043,6 +13528,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", @@ -13228,7 +13722,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -13247,7 +13740,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -13261,7 +13753,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13276,7 +13767,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -13286,8 +13776,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -13295,7 +13784,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -13369,6 +13857,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", diff --git a/backend/package.json b/backend/package.json index ccc5784..f5dcc51 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --max-warnings 0", "lint:fix": "eslint \"{src,apps,libs,test,scripts}/**/*.ts\" --fix", + "type-check": "tsc --noEmit -p tsconfig.json", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", @@ -32,6 +33,8 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.6", @@ -41,11 +44,15 @@ "cache-manager": "^6.4.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", + "cookie-parser": "^1.4.7", "helmet": "^8.1.0", "nest-winston": "^1.10.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sanitize-html": "^2.18.0", "typeorm": "^0.3.28", "winston": "^3.17.0" }, @@ -57,10 +64,13 @@ "@nestjs/testing": "^11.0.1", "@swc/cli": "^0.8.1", "@swc/core": "^1.15.24", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/multer": "^2.2.0", - "@types/node": "^22.10.7", + "@types/node": "^24.19.2", + "@types/passport-jwt": "^4.0.1", + "@types/sanitize-html": "^2.16.2", "@types/supertest": "^6.0.2", "chokidar": "^5.0.0", "eslint": "^9.18.0", @@ -93,12 +103,27 @@ }, "testRegex": ".*\\.spec\\.ts$", "transform": { + "/node_modules/(htmlparser2|domhandler|domutils|entities|dom-serializer|domelementtype)/.+\\.js$": [ + "ts-jest", + { + "tsconfig": { + "allowJs": true, + "module": "commonjs", + "target": "es2022", + "esModuleInterop": true, + "isolatedModules": true + } + } + ], "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": [ "**/*.(t|j)s" ], "coverageDirectory": "../coverage", - "testEnvironment": "node" + "testEnvironment": "node", + "transformIgnorePatterns": [ + "/node_modules/(?!(htmlparser2|domhandler|domutils|entities|dom-serializer|domelementtype)/)" + ] } } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d84c929..d751f9a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,13 +1,16 @@ -import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; -import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ThrottlerModule } from '@nestjs/throttler'; import { CacheModule } from '@nestjs/cache-manager'; import { APP_GUARD } from '@nestjs/core'; -import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; +import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard'; +import { OriginGuard } from '@/common/guards/origin.guard'; +import { AuthModule } from '@/modules/auth/auth.module'; import { HealthModule } from '@/modules/health/health.module'; +import { MailModule } from '@/modules/mail/mail.module'; import { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; import { parsePositiveInt } from '@/shared/utils/env'; @@ -58,20 +61,27 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 }), }), HealthModule, + // 직원 로그인(쿠키 JWT) — 자격 확인은 MailModule 의 메일 엔진 포트로 + AuthModule, + // 웹메일 API (Stalwart JMAP 어댑터) + MailModule, UploadModule, UserModule, ], providers: [ ShutdownLogger, + // 전역 가드는 등록 순서대로 돈다 — 다른 사이트에서 온 상태 변경 요청은 요청 수를 세기 전에 막는다 { provide: APP_GUARD, - useClass: ThrottlerGuard, + useClass: OriginGuard, + }, + // 요청 수 한도 — ThrottlerGuard 그대로, 클라이언트 키만 IPv6 /64 로 묶는다 (두 번째 limiter 가 아니다) + { + provide: APP_GUARD, + useClass: ClientIpThrottlerGuard, }, ], }) -export class AppModule implements NestModule { - // 모든 요청에 추적 ID 를 부여한다 (로그·에러 응답 traceId 와 동일 값) - configure(consumer: MiddlewareConsumer): void { - consumer.apply(RequestIdMiddleware).forRoutes('*'); - } -} +// 요청 추적 ID 는 app.setup.ts 의 configureApp() 이 **맨 앞** express 미들웨어로 붙인다 — +// 본문 파서 오류(413·400·415)에도 traceId 가 남도록 Nest 미들웨어(본문 파서 뒤)로 두지 않는다 +export class AppModule {} diff --git a/backend/src/app.setup.ts b/backend/src/app.setup.ts new file mode 100644 index 0000000..14844ff --- /dev/null +++ b/backend/src/app.setup.ts @@ -0,0 +1,62 @@ +import { RequestMethod, ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; + +import { HttpExceptionFilter } from '@/common/filters/http-exception.filter'; +import { TransformInterceptor } from '@/common/interceptors/transform.interceptor'; +import { requestIdMiddleware } from '@/common/middleware/request-id.middleware'; +import { resolveTrustProxy } from '@/shared/utils/trust-proxy'; + +/** + * JSON 본문 최대 크기. + * 메일 발송 본문은 100,000자까지 받는다 — 한글은 UTF-8 로 3바이트라 약 300KB 다 (기본값 100KB 로는 잘린다). + */ +export const JSON_BODY_LIMIT = '1mb'; + +/** + * HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다 + * (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다). + * + * 앱은 `{ bodyParser: false }` 로 만들어야 한다 — 본문 파서를 여기서 직접 등록한다. + * CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다. + */ +export function configureApp(app: NestExpressApplication): void { + // 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도 + // 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다 + app.use(requestIdMiddleware); + + // 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY). + // 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1 + app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY)); + + // 글로벌 prefix — 모든 라우트가 /api/* (클라이언트 baseURL 과 일치) + app.setGlobalPrefix('api', { + exclude: [{ path: '/', method: RequestMethod.GET }], + }); + + // 보안 헤더 + app.use(helmet()); + + // 인증 쿠키(access_token·refresh_token)를 읽는다 + app.use(cookieParser()); + + // JSON 만 받는다 — urlencoded 파서를 등록하지 않으면 다른 사이트의
으로 로그인·발송을 시도하는 + // 요청(CSRF)은 본문이 비어 VAL_001 이 된다. JSON 요청은 CORS 사전 확인을 거치므로 화이트리스트가 막는다. + // inflate=false: 압축된 요청 본문을 받지 않는다 (브라우저는 보내지 않는다 — 압축 폭탄·body-parser DoS 경로 차단) + app.useBodyParser('json', { limit: JSON_BODY_LIMIT, inflate: false }); + + // 전역 DTO 검증 + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, // DTO 에 없는 값은 버린다 + forbidNonWhitelisted: true, // DTO 에 없는 필드가 오면 400 + transform: true, // DTO·기본 타입으로 변환 + }), + ); + + // 표준 실패·성공 응답 + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector))); +} diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..34c56c4 --- /dev/null +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -0,0 +1,34 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { Request } from 'express'; + +import { BusinessException } from '@/common/errors/business.exception'; + +/** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */ +export interface AuthenticatedUser { + /** 직원 메일 주소 (= 메일 계정) */ + readonly email: string; + /** 표시 이름 */ + readonly name: string; +} + +/** + * 가드(`JwtAuthGuard`)가 검증해 `req.user` 에 넣은 직원 정보를 꺼낸다. + * 가드 없이 쓰면 사용자가 없으므로 AUTH_001 로 막는다 (가드 누락이 조용히 통과하지 않게). + * + * 사용 예: + * @UseGuards(JwtAuthGuard) + * me(@CurrentUser() user: AuthenticatedUser) { ... } + */ +export const CurrentUser = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthenticatedUser => { + const request = context.switchToHttp().getRequest(); + const user = request.user as AuthenticatedUser | undefined; + + if (!user) { + throw new BusinessException('AUTH_001', { + context: { reason: 'current-user-without-guard' }, + }); + } + return user; + }, +); diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index b92f411..89ea48b 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -46,7 +46,7 @@ describe('에러 코드 레지스트리', () => { it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => { const clientOnlyCodes = Object.entries(ERROR_CODES) - .filter(([, definition]) => definition.clientOnly) + .filter(([, definition]) => 'clientOnly' in definition) .map(([code]) => code); expect(clientOnlyCodes).toContain('AUTH_002'); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b742aac..b037087 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -40,6 +40,11 @@ const COMMON_ERROR_CODES = { status: HttpStatus.FORBIDDEN, message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.', }, + // 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md) + AUTH_004: { + status: HttpStatus.UNAUTHORIZED, + message: '이메일 또는 비밀번호가 올바르지 않습니다.', + }, VAL_001: { status: HttpStatus.BAD_REQUEST, message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', @@ -73,6 +78,40 @@ const DOMAIN_ERROR_CODES = { status: HttpStatus.PAYLOAD_TOO_LARGE, message: '파일 크기가 허용 범위를 초과했습니다.', }, + // 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다 + MAIL_001: { + status: HttpStatus.SERVICE_UNAVAILABLE, + message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.', + }, + MAIL_002: { + status: HttpStatus.BAD_REQUEST, + message: '받는 사람 주소를 다시 확인해 주세요.', + }, + MAIL_003: { + status: HttpStatus.BAD_REQUEST, + message: + '메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.', + }, + // 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id. + // 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절) + MAIL_004: { + status: HttpStatus.BAD_REQUEST, + message: + '첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.', + }, + // 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다. + // 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다 + MAIL_005: { + status: HttpStatus.CONFLICT, + message: + '이미 보낸 메일과 내용이 달라 보내지 않았습니다. 보낸편지함을 확인한 뒤 다시 보내 주세요.', + }, + // 첨부 개수 한도 초과 — 업로드한 파일과 전달하는 원본 첨부를 함께 센다. + // 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다) + MAIL_006: { + status: HttpStatus.BAD_REQUEST, + message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/common/filters/http-exception.filter.spec.ts b/backend/src/common/filters/http-exception.filter.spec.ts index 8979b3b..08c095f 100644 --- a/backend/src/common/filters/http-exception.filter.spec.ts +++ b/backend/src/common/filters/http-exception.filter.spec.ts @@ -107,6 +107,51 @@ describe('HttpExceptionFilter', () => { expect(JSON.stringify(result.body)).not.toContain('File too large'); }); + it('Express 미들웨어의 4xx(http-errors)는 상태로 역매핑한다 — 본문 크기 초과가 SYS_001 로 보이면 안 된다', () => { + // body-parser 는 한도를 넘으면 { status: 413, expose: true } 인 오류를 next(err) 로 넘긴다 + const tooLarge = Object.assign(new Error('request entity too large'), { + status: 413, + statusCode: 413, + expose: true, + type: 'entity.too.large', + }); + + const result = runFilter(tooLarge); + + expect(result.status).toBe(HttpStatus.PAYLOAD_TOO_LARGE); + expect(result.body.error.code).toBe('FILE_002'); + expect(JSON.stringify(result.body)).not.toContain('entity too large'); + }); + + it('expose 가 없거나 5xx 인 오류 객체는 SYS_001 로 수렴한다', () => { + const notExposed = Object.assign(new Error('내부'), { status: 400 }); + const serverSide = Object.assign(new Error('내부'), { + status: 502, + expose: true, + }); + + expect(runFilter(notExposed).body.error.code).toBe('SYS_001'); + expect(runFilter(serverSide).status).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + }); + + it('5xx BusinessException 도 context 를 로그에 남긴다 (응답에는 싣지 않는다)', () => { + const errorLog = jest.spyOn(Logger.prototype, 'error'); + errorLog.mockClear(); + + const result = runFilter( + new BusinessException('MAIL_001', { + context: { reason: 'timeout', jmapType: 'serverFail' }, + }), + ); + + expect(result.status).toBe(HttpStatus.SERVICE_UNAVAILABLE); + expect(JSON.stringify(result.body)).not.toContain('serverFail'); + expect(errorLog).toHaveBeenCalledWith( + expect.stringContaining('"jmapType":"serverFail"'), + expect.anything(), + ); + }); + it('미정의 예외는 내부 정보 없이 SYS_001 로 수렴한다', () => { const result = runFilter(new Error('DB 커넥션 풀 고갈: host=10.0.0.5')); diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 722b943..2fdbc59 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -21,6 +21,9 @@ import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware'; /** 이 값 이상이면 서버 책임 오류로 보고 error 레벨로 남긴다. */ const SERVER_ERROR_STATUS_THRESHOLD = 500; +/** 클라이언트 오류(4xx)의 시작 */ +const CLIENT_ERROR_STATUS_THRESHOLD = 400; + /** * 전역 Exception Filter — 모든 실패 응답을 표준 포맷으로 변환한다. * 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며, @@ -81,7 +84,19 @@ export class HttpExceptionFilter implements ExceptionFilter { }; } - // 3. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다. + // 3. Express 미들웨어(body-parser 등)가 만든 http-errors 객체 — 클라이언트 책임(4xx)이면 상태로 역매핑한다. + // (본문 크기 초과 413 이 SYS_001(500)로 보이면 원인을 오해하게 된다) + const clientStatus = this.extractClientErrorStatus(exception); + if (clientStatus !== null) { + const code = resolveCodeByStatus(clientStatus); + return { + status: clientStatus, + code, + message: getErrorDefinition(code).message, + }; + } + + // 4. 미정의 예외 — 내부 정보가 새지 않도록 항상 SYS_001 로 수렴시킨다. return { status: HttpStatus.INTERNAL_SERVER_ERROR, code: FALLBACK_ERROR_CODE, @@ -89,6 +104,25 @@ export class HttpExceptionFilter implements ExceptionFilter { }; } + /** + * http-errors 형식(`status` + `expose: true`)의 클라이언트 오류면 상태 코드를 꺼낸다. + * `expose` 는 "메시지를 클라이언트에 보여도 되는 4xx" 에만 true 이므로, 5xx·임의 객체는 걸러진다. + */ + private extractClientErrorStatus(exception: unknown): number | null { + if (!(exception instanceof Error)) return null; + + const { status, expose } = exception as Error & { + status?: unknown; + expose?: unknown; + }; + const isClientError = + typeof status === 'number' && + status >= CLIENT_ERROR_STATUS_THRESHOLD && + status < SERVER_ERROR_STATUS_THRESHOLD; + + return isClientError && expose === true ? status : null; + } + /** 예외 본문에 error code 가 실려 있으면 꺼낸다. */ private extractCode(body: unknown): ErrorCode | null { if (typeof body === 'object' && body !== null && 'code' in body) { @@ -108,19 +142,20 @@ export class HttpExceptionFilter implements ExceptionFilter { ): void { const where = `${request.method} ${request.url}`; const prefix = `[${code}]${traceId ? `[${traceId}]` : ''} ${where}`; + // context 는 로그 전용 개발자 정보다 — 5xx(예: 외부 메일 엔진 장애 MAIL_001)도 원인을 알 수 있게 함께 남긴다 + const context = + exception instanceof BusinessException && exception.context + ? ` ${JSON.stringify(exception.context)}` + : ''; if (status >= SERVER_ERROR_STATUS_THRESHOLD) { this.logger.error( - prefix, + `${prefix}${context}`, exception instanceof Error ? exception.stack : String(exception), ); return; } - const context = - exception instanceof BusinessException && exception.context - ? ` ${JSON.stringify(exception.context)}` - : ''; this.logger.warn(`${prefix}${context}`); } } diff --git a/backend/src/common/guards/client-ip-throttler.guard.spec.ts b/backend/src/common/guards/client-ip-throttler.guard.spec.ts new file mode 100644 index 0000000..23a99bf --- /dev/null +++ b/backend/src/common/guards/client-ip-throttler.guard.spec.ts @@ -0,0 +1,44 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; + +import { ClientIpThrottlerGuard } from './client-ip-throttler.guard'; + +/** getTracker 만 확인한다 — 한도 계산은 throttler 의 몫이다 */ +function trackerOf(req: Record): Promise { + const guard = Object.create( + ClientIpThrottlerGuard.prototype, + ) as ClientIpThrottlerGuard; + return ( + guard as unknown as { + getTracker: (r: Record) => Promise; + } + ).getTracker(req); +} + +describe('ClientIpThrottlerGuard', () => { + it('ThrottlerGuard 를 그대로 쓰고 세는 키만 바꾼다 (두 번째 limiter 가 아니다)', () => { + expect(Object.getPrototypeOf(ClientIpThrottlerGuard.prototype)).toBe( + ThrottlerGuard.prototype, + ); + }); + + it('IPv6 는 /64 로 묶는다 — 로그인 실패 한도와 같은 키', async () => { + await expect(trackerOf({ ip: '2001:db8:1:2::5' })).resolves.toBe( + '2001:db8:1:2::/64', + ); + await expect(trackerOf({ ip: '2001:db8:1:2:aaaa::9' })).resolves.toBe( + '2001:db8:1:2::/64', + ); + }); + + it('IPv4-mapped 주소는 IPv4 로, IPv4 는 그대로', async () => { + await expect(trackerOf({ ip: '::ffff:203.0.113.7' })).resolves.toBe( + '203.0.113.7', + ); + await expect(trackerOf({ ip: '203.0.113.7' })).resolves.toBe('203.0.113.7'); + }); + + it('IP 를 알 수 없으면 한 묶음(unknown)으로 센다', async () => { + await expect(trackerOf({})).resolves.toBe('unknown'); + await expect(trackerOf({ ip: '' })).resolves.toBe('unknown'); + }); +}); diff --git a/backend/src/common/guards/client-ip-throttler.guard.ts b/backend/src/common/guards/client-ip-throttler.guard.ts new file mode 100644 index 0000000..191842c --- /dev/null +++ b/backend/src/common/guards/client-ip-throttler.guard.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; + +import { clientIpKey } from '@/shared/utils/client-ip'; + +/** + * 전역 요청 수 한도(APP_GUARD) — `@nestjs/throttler` 의 ThrottlerGuard 에서 **클라이언트를 세는 키만** 바꾼다. + * 두 번째 limiter 가 아니다 (한도·저장소·`@Throttle`/`@SkipThrottle` 은 그대로 throttler 가 맡는다). + * + * 키는 로그인 실패 한도와 같은 `clientIpKey()` — IPv6 는 /64 로 묶고, IPv4-mapped 주소는 IPv4 로 본다. + * 그래서 같은 회선에서 IPv6 주소를 바꿔 가며 요청 수 한도를 피하지 못한다. + */ +@Injectable() +export class ClientIpThrottlerGuard extends ThrottlerGuard { + protected override getTracker(req: Record): Promise { + const ip = typeof req.ip === 'string' && req.ip !== '' ? req.ip : 'unknown'; + return Promise.resolve(clientIpKey(ip)); + } +} diff --git a/backend/src/common/guards/jwt-auth.guard.spec.ts b/backend/src/common/guards/jwt-auth.guard.spec.ts new file mode 100644 index 0000000..048a692 --- /dev/null +++ b/backend/src/common/guards/jwt-auth.guard.spec.ts @@ -0,0 +1,71 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +const user = { email: 'ara.kim@arasystem.test', name: '김아라' }; + +/** 가드가 던진 예외의 에러 코드를 꺼낸다 */ +function codeOf(run: () => unknown): string | undefined { + try { + run(); + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } + return undefined; +} + +describe('JwtAuthGuard.handleRequest', () => { + const guard = new JwtAuthGuard(); + + it('검증된 사용자는 그대로 통과시킨다', () => { + expect(guard.handleRequest(null, user, undefined)).toBe(user); + }); + + it('토큰이 없으면 AUTH_001 이다', () => { + expect( + codeOf(() => + guard.handleRequest(null, false, new Error('No auth token')), + ), + ).toBe('AUTH_001'); + }); + + it('만료·위조 토큰도 원인을 구분하지 않고 AUTH_001 이다', () => { + for (const message of [ + 'jwt expired', + 'invalid signature', + 'jwt malformed', + ]) { + expect( + codeOf(() => guard.handleRequest(null, false, new Error(message))), + ).toBe('AUTH_001'); + } + }); + + it('예상치 못한 오류도 코드 없는 실패로 새지 않고 AUTH_001 이다', () => { + expect( + codeOf(() => guard.handleRequest(new Error('boom'), false, undefined)), + ).toBe('AUTH_001'); + }); + + it('실패 사유는 로그 context 에만 남긴다', () => { + try { + guard.handleRequest(null, false, new Error('jwt expired')); + } catch (error) { + expect((error as BusinessException).context).toEqual({ + reason: 'jwt expired', + }); + expect((error as BusinessException).getResponse()).not.toHaveProperty( + 'reason', + ); + } + }); + + it('전략이 코드와 함께 거부한 예외는 그대로 전달한다', () => { + const rejected = new BusinessException('AUTH_001', { + context: { reason: 'wrong-token-type' }, + }); + + expect(() => guard.handleRequest(rejected, false, undefined)).toThrow( + rejected, + ); + }); +}); diff --git a/backend/src/common/guards/jwt-auth.guard.ts b/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..6edc954 --- /dev/null +++ b/backend/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +import { BusinessException } from '@/common/errors/business.exception'; + +/** passport 전략 이름 — `modules/auth/jwt.strategy.ts` 가 이 이름으로 등록한다 */ +export const JWT_STRATEGY = 'jwt'; + +/** + * 액세스 토큰 쿠키로 보호하는 가드. + * 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다. + * (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다) + * + * 사용 예: + * @UseGuards(JwtAuthGuard) + * @ApiCookieAuth() + */ +@Injectable() +export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) { + handleRequest( + error: unknown, + user: unknown, + info: unknown, + ): TUser { + // 전략의 validate() 가 코드와 함께 거부한 경우 그대로 전달한다 + if (error instanceof BusinessException) throw error; + + if (error || !user) { + throw new BusinessException('AUTH_001', { + context: { reason: describeFailure(error ?? info) }, + }); + } + return user as TUser; + } +} + +/** 토큰 원문은 남기지 않고 실패 종류만 남긴다 (예: 'No auth token', 'jwt expired') */ +function describeFailure(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return 'unauthenticated'; +} diff --git a/backend/src/common/guards/origin.guard.spec.ts b/backend/src/common/guards/origin.guard.spec.ts new file mode 100644 index 0000000..0fdfce1 --- /dev/null +++ b/backend/src/common/guards/origin.guard.spec.ts @@ -0,0 +1,120 @@ +import type { ExecutionContext } from '@nestjs/common'; +import type { ConfigService } from '@nestjs/config'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { OriginGuard } from './origin.guard'; + +function guardWith(env: Record): OriginGuard { + const config = { + get: (key: string) => env[key], + } as unknown as ConfigService; + return new OriginGuard(config); +} + +function httpContext( + method: string, + headers: Record = {}, +): ExecutionContext { + const request = { + method, + header: (name: string) => headers[name.toLowerCase()], + }; + return { + getType: () => 'http', + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; +} + +function codeOf(run: () => unknown): string | undefined { + try { + run(); + } catch (error) { + return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; + } + return undefined; +} + +describe('OriginGuard', () => { + const production = guardWith({ + NODE_ENV: 'production', + CORS_ORIGIN: 'https://mail.arasystem.it.kr', + }); + + it('조회(GET·HEAD·OPTIONS)는 검사하지 않는다', () => { + for (const method of ['GET', 'HEAD', 'OPTIONS']) { + expect( + production.canActivate( + httpContext(method, { 'sec-fetch-site': 'cross-site' }), + ), + ).toBe(true); + } + }); + + it.each(['POST', 'PUT', 'PATCH', 'DELETE'])( + '%s 는 다른 사이트에서 오면 403 AUTH_003', + (method) => { + expect( + codeOf(() => + production.canActivate( + httpContext(method, { + 'sec-fetch-site': 'cross-site', + origin: 'https://evil.test', + }), + ), + ), + ).toBe('AUTH_003'); + }, + ); + + it('같은 주소의 화면에서 온 요청은 통과한다', () => { + expect( + production.canActivate( + httpContext('POST', { + 'sec-fetch-site': 'same-origin', + origin: 'https://mail.arasystem.it.kr', + }), + ), + ).toBe(true); + }); + + it('운영에서는 localhost 출처를 허용하지 않는다 (CORS_ORIGIN 만)', () => { + expect( + codeOf(() => + production.canActivate( + httpContext('POST', { origin: 'http://localhost:5173' }), + ), + ), + ).toBe('AUTH_003'); + }); + + it('개발에서는 vite dev 서버 출처를 허용한다', () => { + const development = guardWith({ NODE_ENV: 'development' }); + expect( + development.canActivate( + httpContext('POST', { + 'sec-fetch-site': 'same-site', + origin: 'http://localhost:5173', + }), + ), + ).toBe(true); + }); + + it('출처 정보가 없는 요청(브라우저가 아닌 클라이언트)은 통과한다', () => { + expect(production.canActivate(httpContext('POST'))).toBe(true); + }); + + it('거부 사유는 로그 context 에만 남긴다', () => { + try { + production.canActivate( + httpContext('DELETE', { origin: 'https://evil.test' }), + ); + fail('예외가 발생해야 한다'); + } catch (error) { + expect((error as BusinessException).context).toEqual({ + reason: 'cross-site-request', + secFetchSite: null, + origin: 'https://evil.test', + }); + } + }); +}); diff --git a/backend/src/common/guards/origin.guard.ts b/backend/src/common/guards/origin.guard.ts new file mode 100644 index 0000000..1ae1375 --- /dev/null +++ b/backend/src/common/guards/origin.guard.ts @@ -0,0 +1,57 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Request } from 'express'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + buildAllowedOrigins, + checkRequestOrigin, +} from '@/shared/utils/request-origin'; + +/** 상태를 바꾸는 메서드 — 이 요청만 출처를 검사한다 (조회는 다른 사이트가 응답을 읽지 못한다) */ +const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * 출처 검사(CSRF 방어) — 전역 가드(APP_GUARD, app.module.ts). + * + * 쿠키 인증이라 다른 사이트가 사용자의 브라우저로 요청을 보내면 쿠키가 함께 간다. + * SameSite 쿠키와 JSON 전용 본문 파서가 1차로 막고, 이 가드가 브라우저가 붙이는 출처 정보로 한 번 더 막는다. + * 규칙은 `shared/utils/request-origin.ts` (docs/mail/api-contract.md 머리말) — 거부하면 403 AUTH_003. + * + * 가드는 인터셉터보다 먼저 돌므로 거부된 업로드는 파일을 읽기 전에 끝난다. + * 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 이 가드에 예외 표시를 따로 만든다. + */ +@Injectable() +export class OriginGuard implements CanActivate { + private readonly allowedOrigins: ReadonlySet; + + constructor(config: ConfigService) { + this.allowedOrigins = new Set( + buildAllowedOrigins((key) => config.get(key)), + ); + } + + canActivate(context: ExecutionContext): boolean { + if (context.getType() !== 'http') return true; + + const request = context.switchToHttp().getRequest(); + if (!STATE_CHANGING_METHODS.has(request.method.toUpperCase())) return true; + + const secFetchSite = request.header('sec-fetch-site'); + const origin = request.header('origin'); + if ( + checkRequestOrigin({ secFetchSite, origin }, this.allowedOrigins) !== + 'rejected' + ) { + return true; + } + + throw new BusinessException('AUTH_003', { + context: { + reason: 'cross-site-request', + secFetchSite: secFetchSite ?? null, + origin: origin ?? null, + }, + }); + } +} diff --git a/backend/src/common/interceptors/delete-uploaded-file.interceptor.spec.ts b/backend/src/common/interceptors/delete-uploaded-file.interceptor.spec.ts new file mode 100644 index 0000000..2daea8a --- /dev/null +++ b/backend/src/common/interceptors/delete-uploaded-file.interceptor.spec.ts @@ -0,0 +1,63 @@ +import type { CallHandler, ExecutionContext } from '@nestjs/common'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { lastValueFrom, of, throwError } from 'rxjs'; + +import { DeleteUploadedFileInterceptor } from './delete-uploaded-file.interceptor'; + +function contextWith(file?: { path: string }): ExecutionContext { + const request = { file }; + return { + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; +} + +/** finalize 의 비동기 삭제가 끝날 때까지 잠깐 기다린다 */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + +describe('DeleteUploadedFileInterceptor', () => { + let dir: string; + let file: string; + const interceptor = new DeleteUploadedFileInterceptor(); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'upload-cleanup-')); + file = join(dir, 'upload.bin'); + writeFileSync(file, 'temp'); + }); + + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('핸들러가 성공하면 임시 파일을 지운다', async () => { + const next: CallHandler = { handle: () => of({ id: 'blob' }) }; + + await expect( + lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)), + ).resolves.toEqual({ id: 'blob' }); + await settle(); + + expect(existsSync(file)).toBe(false); + }); + + it('핸들러가 실패해도 임시 파일을 지운다', async () => { + const next: CallHandler = { + handle: () => throwError(() => new Error('engine down')), + }; + + await expect( + lastValueFrom(interceptor.intercept(contextWith({ path: file }), next)), + ).rejects.toThrow('engine down'); + await settle(); + + expect(existsSync(file)).toBe(false); + }); + + it('파일이 없는 요청은 그냥 통과한다', async () => { + const next: CallHandler = { handle: () => of(null) }; + + await expect( + lastValueFrom(interceptor.intercept(contextWith(), next)), + ).resolves.toBeNull(); + }); +}); diff --git a/backend/src/common/interceptors/delete-uploaded-file.interceptor.ts b/backend/src/common/interceptors/delete-uploaded-file.interceptor.ts new file mode 100644 index 0000000..3c7dfa4 --- /dev/null +++ b/backend/src/common/interceptors/delete-uploaded-file.interceptor.ts @@ -0,0 +1,46 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import type { Request } from 'express'; +import { rm } from 'fs/promises'; +import { Observable, finalize } from 'rxjs'; + +import { traceTag } from '@/shared/logger/request-context'; + +/** + * 임시 업로드 파일 정리 — 핸들러가 끝나면(성공·실패 모두) multer 가 디스크에 받아 둔 파일을 지운다. + * + * 파일을 계속 보관하는 업로드(modules/upload)가 아니라, 받은 파일을 다른 곳(메일 엔진)으로 넘기고 + * 버리는 엔드포인트에 쓴다. 반드시 FileInterceptor **뒤에** 둔다 (그래야 req.file 이 채워져 있다): + * + * @UseInterceptors(FileInterceptor('file'), DeleteUploadedFileInterceptor) + * + * multer 자체의 실패(크기 초과·연결 끊김)는 multer 가 받던 파일을 지운다 — 이 인터셉터까지 오지 않는다. + */ +@Injectable() +export class DeleteUploadedFileInterceptor implements NestInterceptor { + private readonly logger = new Logger(DeleteUploadedFileInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + return next.handle().pipe(finalize(() => void this.remove(request))); + } + + private async remove(request: Request): Promise { + const path = request.file?.path; + if (!path) return; + + try { + await rm(path, { force: true }); + } catch (error) { + // 지우지 못해도 응답은 이미 정해졌다 — 남은 파일은 기동 시 정리가 지운다 + this.logger.warn( + `[SYS_001]${traceTag()} 임시 업로드 파일을 지우지 못했습니다: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/backend/src/common/middleware/request-id.middleware.spec.ts b/backend/src/common/middleware/request-id.middleware.spec.ts new file mode 100644 index 0000000..840731f --- /dev/null +++ b/backend/src/common/middleware/request-id.middleware.spec.ts @@ -0,0 +1,76 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { currentRequestId } from '@/shared/logger/request-context'; +import { + REQUEST_ID_HEADER, + requestIdMiddleware, + resolveRequestId, +} from './request-id.middleware'; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +describe('resolveRequestId', () => { + it('영문·숫자·.·_·- 1~128자면 이어받는다 (nginx $request_id 등)', () => { + for (const value of [ + '3f1c0a2e0f1b4f2a9a1e6c9f0a1b2c3d', + 'req_2026-10-26.001', + 'a'.repeat(128), + ]) { + expect(resolveRequestId(value)).toBe(value); + } + }); + + it.each([ + ['없음', undefined], + ['빈 값', ''], + ['공백', 'has space'], + ['따옴표·꺾쇠', '">')).toBe('

본문

'); + expect( + clean('안녕'), + ).toBe('안녕'); + }); + + it('이벤트 핸들러 속성을 모두 지운다', () => { + expect( + clean('

a

'), + ).toBe('

a

'); + expect(clean('
본문
')).toBe( + '
본문
', + ); + }); + + it('onerror 를 노리는 이미지는 태그째 사라진다', () => { + expect(clean('글자')).toBe('글자'); + }); + }); + + describe('링크', () => { + it('http·https·mailto 링크는 새 창으로 연다', () => { + expect(clean('공지')).toBe( + '공지', + ); + expect(clean('메일')).toBe( + '메일', + ); + }); + + it('원문의 target·rel 은 버리고 서버 값으로 덮는다 (window.opener 악용 방지)', () => { + expect( + clean('x'), + ).toBe( + 'x', + ); + }); + + it('javascript: 등 위험한 스킴은 주소만 지우고 글자는 남긴다', () => { + for (const href of [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'java script:alert(1)', + 'javascript:alert(1)', + ' javascript:alert(1)', + 'vbscript:msgbox(1)', + 'data:text/html,', + 'file:///etc/passwd', + ]) { + expect(clean(`눌러`)).toBe('눌러'); + } + }); + + it('상대 주소·프로토콜 상대 주소도 링크로 남기지 않는다', () => { + expect(clean('x')).toBe('x'); + expect(clean('x')).toBe('x'); + }); + }); + + describe('스타일', () => { + it('style 태그는 내용까지 지운다', () => { + expect( + clean( + '

본문

', + ), + ).toBe('

본문

'); + }); + + it('인라인 style 의 외부 url() 은 지우고 외부 이미지 차단으로 표시한다', () => { + const result = sanitizeMailHtml( + '
a
', + ); + + expect(result.html).toBe('
a
'); + expect(result.hasBlockedImages).toBe(true); + }); + + it('url() 은 따옴표·프로토콜 상대·data: 어떤 형태든 남기지 않는다', () => { + for (const style of [ + "background:url('//t.example/p.gif')", + 'background: url( "http://t.example/p.gif" )', + 'background:url(data:image/png;base64,AAAA)', + 'list-style:url(https://t.example/b.png)', + 'background:u\\72l(https://t.example/p.gif)', + 'background:image-set("https://t.example/p.gif" 1x)', + ]) { + expect(clean(`

a

`)).toBe('

a

'); + } + }); + + it('expression()·behavior 같은 스크립트성 CSS 를 지운다', () => { + expect(clean('

a

')).toBe( + '

a

', + ); + expect(clean('

a

')).toBe( + '

a

', + ); + }); + + it('위치를 바꿔 화면을 덮는 속성은 남기지 않는다', () => { + expect( + clean( + '
a
', + ), + ).toBe('
a
'); + }); + + it('일반적인 메일 서식은 남긴다 (색 함수·한글 글꼴·여백)', () => { + expect( + clean( + `

a

`, + ), + ).toBe( + `

a

`, + ); + }); + }); + + describe('이미지', () => { + it('외부 이미지는 지우고 hasBlockedImages 를 켠다 (추적 픽셀 차단)', () => { + const result = sanitizeMailHtml( + '

안녕

', + ); + + expect(result.html).toBe('

안녕

'); + expect(result.hasBlockedImages).toBe(true); + }); + + it('프로토콜 상대 주소·srcset 의 외부 이미지도 차단한다', () => { + expect( + sanitizeMailHtml('').hasBlockedImages, + ).toBe(true); + expect( + sanitizeMailHtml('') + .hasBlockedImages, + ).toBe(true); + }); + + it('표 배경(background 속성)의 외부 이미지도 지우고 표시한다', () => { + const result = sanitizeMailHtml( + '
a
', + ); + + expect(result.html).toBe('
a
'); + expect(result.hasBlockedImages).toBe(true); + }); + + it('cid: 이미지는 준비된 data: URI 로 바꾼다 (대소문자·꺾쇠·퍼센트 인코딩 무관)', () => { + const inline = new Map([['logo@arasystem.it.kr', PNG_DATA_URI]]); + + expect( + clean('로고', inline), + ).toBe(`로고`); + expect(clean('', inline)).toBe( + ``, + ); + }); + + it('준비되지 않은(큰·형식 불일치) cid: 이미지는 지우되 외부 이미지로 표시하지 않는다', () => { + const result = sanitizeMailHtml('

a

'); + + expect(result.html).toBe('

a

'); + expect(result.hasBlockedImages).toBe(false); + }); + + it('원문의 data: 이미지는 png·jpeg·gif·webp 만 남긴다 (svg 제외)', () => { + expect(clean(``)).toBe( + ``, + ); + expect( + clean(''), + ).toBe(''); + }); + + it('외부 이미지가 없으면 hasBlockedImages 는 false 다', () => { + expect(sanitizeMailHtml('

텍스트만

').hasBlockedImages).toBe(false); + }); + }); + + describe('위험한 요소', () => { + it.each([ + [ + 'form 의 입력 요소', + '', + ], + ['iframe', ''], + ['object', '대체 글자'], + ['embed', ''], + ['svg', '글자'], + ['math', '글자'], + ['video', ''], + [ + 'base·meta·link', + '', + ], + ])('%s 은(는) 남지 않는다', (_name, html) => { + const output = clean(`

본문

${html}`); + + expect(output).toBe('

본문

'); + }); + + it('깊게 중첩한 iframe 도 스택 넘침 없이 통째로 버린다 — GHSA-c9rx-x947-fp3h(2.18.0) 에 닿지 않는 설정', () => { + // sanitize-html 2.18.0 기본 설정은 버린 iframe 의 대체 내용을 다시 정화하다 1만 단계쯤에서 스택이 넘친다(확인함). + // 우리는 iframe 을 내용째 버리므로 그 경로를 타지 않는다 — iframe 을 허용하거나 내용을 남기게 바꾸면 이 테스트가 막는다 + const depth = 20_000; + const nested = `${''.repeat(depth)}`; + + expect(clean(`

본문

${nested}`)).toBe('

본문

'); + }); + + it('sanitize-html 보안 권고 4건의 재현 입력이 통하지 않는다 (svg·textarea·xmp·meta·noscript 비허용 — 버전을 올려도 유지)', () => { + for (const payload of [ + '', + '', + '', + '', + '', + '