diff --git a/backend/docs/SECRETS.md b/backend/docs/SECRETS.md new file mode 100644 index 0000000..10e5bbf --- /dev/null +++ b/backend/docs/SECRETS.md @@ -0,0 +1,117 @@ +# Secrets Management & Rotation Procedure + +## 1. Inventory + +The backend requires the secrets below. No secret is committed to the repository (see `.gitignore` and `config/config.validation.ts`). + +| Secret | Purpose | Blast radius if leaked | +|--------|---------|------------------------| +| `JWT_SECRET` | Signs access tokens | Attacker can forge tokens for any user and access the API as them. | +| `JWT_REFRESH_SECRET` | Signs refresh tokens | Attacker can obtain perpetual access and rotate compromised sessions. | +| `DATABASE_PASSWORD` | PostgreSQL authentication | Full read/write access to land records, users, hashes, and audit logs. | +| `REDIS_PASSWORD` | Redis/BullMQ authentication | Queue tampering, job injection, or denial of service for async processing. | +| `GOOGLE_CLIENT_SECRET` | Google OAuth 2.0 client | OAuth token theft, account takeover via Google login. | +| `GITHUB_CLIENT_SECRET` | GitHub OAuth 2.0 client | OAuth token theft, account takeover via GitHub login. | +| `MAIL_PASSWORD` / `SMTP_PASS` | SMTP authentication | Email spoofing, password-reset interception, notification abuse. | +| `STELLAR_SECRET_KEY` | Signs Stellar transactions | Theft or misuse of funds; unauthorized anchor transactions. | +| `ENCRYPTION_SECRET` (optional) | `crypto.util.ts` symmetric key | Decryption of any buffer encrypted with the utility. | + +## 2. Storage + +| Environment | Storage | Notes | +|-------------|---------|-------| +| Local development | `backend/.env` (git-ignored) | Use the example file and random placeholders. Rotate if a developer leaves or shares their machine. | +| Staging / Production | Platform secret manager (e.g. Vercel/Render/Doppler secrets or AWS Secrets Manager) | Mount secrets as environment variables. Never print them in build logs or crash reports. | +| CI / CD | Provider secret store (GitHub Actions secrets, GitLab CI variables) | Mark variables as masked/secret. Use separate values per environment. | + +## 3. Rotation runbooks + +### 3.1 JWT signing secrets (`JWT_SECRET`, `JWT_REFRESH_SECRET`) + +**Goal:** Rotate keys without immediately invalidating all live sessions. + +The backend currently validates tokens against a single secret. To support rotation with a grace period, maintain a list of active secrets and validate against all of them while only signing new tokens with the newest key. + +Proposed implementation: +1. Add `JWT_SECRET` and `JWT_REFRESH_SECRET` as comma-separated key lists (`current,previous`). +2. Use the first value to sign new tokens. +3. Accept tokens signed by any value in the list during validation. +4. Update `JwtStrategy` and `AuthService.refreshToken` to try each key. +5. After the access-token TTL (`JWT_EXPIRATION`) plus a safety margin, remove the old key from the list. + +Runbook: +1. Generate new secrets (min 32 bytes, cryptographically random). +2. Prepend them to the active key lists in the secret manager: `new_secret,old_secret`. +3. Redeploy the backend. +4. Verify new logins succeed and old tokens still work. +5. Wait at least `JWT_EXPIRATION` + `JWT_REFRESH_EXPIRATION`. +6. Remove the old secrets from the lists. +7. Redeploy again. + +### 3.2 Database password (`DATABASE_PASSWORD`) + +1. Create a new PostgreSQL user/role with the same privileges. +2. Update the secret manager with the new password. +3. Redeploy the backend; the old pool connections remain valid briefly. +4. Revoke the old user or change the old user's password. +5. Monitor logs for connection failures. + +### 3.3 Redis password (`REDIS_PASSWORD`) + +1. Set a new Redis ACL password or rotate AUTH password. +2. Update the secret manager. +3. Redeploy; BullMQ workers reconnect with the new password. +4. Remove the old password from Redis. + +### 3.4 OAuth client secrets (`GOOGLE_CLIENT_SECRET`, `GITHUB_CLIENT_SECRET`) + +1. In the provider console, generate a new client secret. +2. Update the backend secret manager. +3. Redeploy. +4. Revoke the old secret in the provider console after confirming OAuth login still works. + +### 3.5 Mail credentials (`MAIL_PASSWORD`, `SMTP_PASS`) + +1. Generate a new app password in the mail provider. +2. Update `MAIL_PASSWORD`, `MAIL_USER`, and mirrored `SMTP_*` values. +3. Redeploy. +4. Send a test email and verify mail delivery. +5. Revoke the old app password. + +### 3.6 Stellar secret key (`STELLAR_SECRET_KEY`) + +1. Create a new Stellar keypair. +2. Fund or authorize the new account for anchoring. +3. Update the secret manager. +4. Redeploy and submit one test anchor transaction. +5. Transfer any remaining funds and disable the old account if possible. + +## 4. Rotation cadence + +| Secret | Recommended cadence | +|--------|---------------------| +| JWT secrets | Every 90 days or on employee/offboarding events. | +| Database password | Every 180 days or on suspected exposure. | +| Redis password | Every 180 days or on suspected exposure. | +| OAuth client secrets | Every 180 days or after provider breach notification. | +| Mail credentials | Every 90 days. | +| Stellar secret key | Every 180 days or after any on-chain suspicious activity. | + +## 5. Incident procedure for suspected exposure + +1. **Contain:** Immediately rotate the exposed secret. +2. **Verify:** Search logs for unauthorized use (correlation IDs help here). +3. **Notify:** Alert the team and, if user data is at risk, follow the incident response plan. +4. **Review:** Check how the secret was exposed and update storage/processes. +5. **Document:** Record the incident, timeline, and remediation. + +## 6. Development credentials + +Contributors must never use production values. Use one of these options: + +1. Copy `backend/.env.example` to `backend/.env` and replace placeholders with local-only values. +2. Use the project's secret manager development sandbox if available. +3. Generate local OAuth apps in Google/GitHub with `localhost` callback URLs. +4. Use a local Stellar testnet account for `STELLAR_SECRET_KEY`. + +Run `npm run test` after setting local values to confirm the validation schema passes. diff --git a/backend/package-lock.json b/backend/package-lock.json index 86f9288..2a5d450 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -47,6 +47,7 @@ "redis": "^5.10.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "sharp": "^0.35.3", "socket.io": "^4.8.3", "speakeasy": "^2.0.0", "stellar-sdk": "^13.3.0", @@ -60,6 +61,7 @@ "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", + "@types/mime-types": "^3.0.1", "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/nodemailer": "^7.0.5", @@ -786,6 +788,16 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -1039,6 +1051,554 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -2361,6 +2921,24 @@ } } }, + "node_modules/@nestjs/common/node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/@nestjs/config": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", @@ -3331,6 +3909,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -5936,7 +6521,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -6898,24 +7482,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-type": { - "version": "21.3.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", - "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -11318,6 +11884,67 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 8895e00..441807b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -60,6 +60,7 @@ "redis": "^5.10.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "sharp": "^0.35.3", "socket.io": "^4.8.3", "speakeasy": "^2.0.0", "stellar-sdk": "^13.3.0", @@ -73,6 +74,7 @@ "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", + "@types/mime-types": "^3.0.1", "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/nodemailer": "^7.0.5", diff --git a/backend/src/access-logs/access-logs.service.ts b/backend/src/access-logs/access-logs.service.ts index 299469c..bc38cf3 100644 --- a/backend/src/access-logs/access-logs.service.ts +++ b/backend/src/access-logs/access-logs.service.ts @@ -18,6 +18,17 @@ export class AccessLogsService { constructor(private readonly accessLogRepository: Repository) {} + async create(dto: CreateAccessLogDto): Promise { + const log = this.accessLogRepository.create({ + userId: dto.userId ?? null, + routePath: dto.routePath, + httpMethod: dto.httpMethod, + ipAddress: dto.ipAddress, + statusCode: dto.statusCode ?? null, + }); + return this.accessLogRepository.save(log); + } + async logDocumentAccess(documentId: string, action: string, userId?: string, ipAddress?: string, userAgent?: string, isDenied = false): Promise { try { this.logger.log(`Document Access Log: docId=${documentId}, action=${action}, user=${userId || 'anonymous'}, denied=${isDenied}`); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9a1d47b..b846a26 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { AccessLogsModule } from './access-logs/access-logs.module'; import { AuthModule } from './auth/auth.module'; import { buildWinstonOptions } from './common/logger.config'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; +import { CorrelationIdMiddleware } from './common/correlation/correlation-id.middleware'; import { DocumentsModule } from './documents/documents.module'; import { MailModule } from './mail/mail.module'; import { QueueModule } from './queue/queue.module'; @@ -71,10 +72,12 @@ import { QueueObservabilityModule } from './queue/queue-observability.module'; QueueObservabilityModule, ], controllers: [AppController], - providers: [AppService, LoggerMiddleware], + providers: [AppService, LoggerMiddleware, CorrelationIdMiddleware], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { - consumer.apply(LoggerMiddleware).forRoutes('*'); + consumer + .apply(CorrelationIdMiddleware, LoggerMiddleware) + .forRoutes('*'); } -} \ No newline at end of file +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 4bfe553..c09e344 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -18,6 +18,7 @@ import { AuthService } from './auth.service'; import { RegisterAuthDto } from './dto/register-auth.dto'; import { LoginAuthDto } from './dto/login-auth.dto'; import { RefreshAuthDto } from './dto/refresh-auth.dto'; +import { getFrontendUrl } from '../common/cors.config'; @Controller('auth') export class AuthController { @@ -108,8 +109,7 @@ export class AuthController { } private redirectWithToken(accessToken: string, res: Response) { - const frontendUrl = - this.configService.get('FRONTEND_URL') || 'http://localhost:3001'; + const frontendUrl = getFrontendUrl(this.configService); const redirectUrl = new URL(frontendUrl); redirectUrl.searchParams.set('token', accessToken); return res.redirect(redirectUrl.toString()); diff --git a/backend/src/common/correlation/correlation-id.middleware.ts b/backend/src/common/correlation/correlation-id.middleware.ts new file mode 100644 index 0000000..2a3f09f --- /dev/null +++ b/backend/src/common/correlation/correlation-id.middleware.ts @@ -0,0 +1,35 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +import { + correlationIdStorage, + generateCorrelationId, +} from './correlation-id.storage'; + +declare global { + namespace Express { + interface Request { + requestId?: string; + } + } +} + +/** + * Attaches a correlation ID to every incoming request. Honors an inbound + * X-Request-Id header when present; otherwise generates a UUID. The same ID + * is echoed back on the response as X-Request-Id and stored in async local + * storage so that Winston includes it on every log line for the request. + */ +@Injectable() +export class CorrelationIdMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + const requestId = generateCorrelationId(req.headers['x-request-id']); + + req.requestId = requestId; + res.setHeader('X-Request-Id', requestId); + + correlationIdStorage.run(requestId, () => { + next(); + }); + } +} diff --git a/backend/src/common/correlation/correlation-id.storage.spec.ts b/backend/src/common/correlation/correlation-id.storage.spec.ts new file mode 100644 index 0000000..3078082 --- /dev/null +++ b/backend/src/common/correlation/correlation-id.storage.spec.ts @@ -0,0 +1,58 @@ +import { + correlationIdStorage, + generateCorrelationId, + getCorrelationId, + runWithCorrelationId, +} from './correlation-id.storage'; + +describe('CorrelationIdStorage', () => { + afterEach(() => { + correlationIdStorage.disable(); + }); + + describe('generateCorrelationId', () => { + it('should honor an inbound header value', () => { + const id = generateCorrelationId('inbound-id'); + expect(id).toBe('inbound-id'); + }); + + it('should use the first value when inbound is an array', () => { + const id = generateCorrelationId(['first-id', 'second-id']); + expect(id).toBe('first-id'); + }); + + it('should generate a UUID when no inbound header is provided', () => { + const id = generateCorrelationId(); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + + it('should generate a UUID for empty headers', () => { + const id = generateCorrelationId(' '); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + }); + }); + + describe('runWithCorrelationId', () => { + it('should make the correlation ID available inside the callback', async () => { + await runWithCorrelationId('test-correlation-id', async () => { + expect(getCorrelationId()).toBe('test-correlation-id'); + }); + }); + + it('should return the callback result', async () => { + const result = await runWithCorrelationId('id', async () => 42); + expect(result).toBe(42); + }); + }); + + describe('getCorrelationId', () => { + it('should return fallback when no correlation ID is set', () => { + expect(getCorrelationId()).toBe('no-correlation-id'); + expect(getCorrelationId('fallback')).toBe('fallback'); + }); + }); +}); diff --git a/backend/src/common/correlation/correlation-id.storage.ts b/backend/src/common/correlation/correlation-id.storage.ts new file mode 100644 index 0000000..a7cefd3 --- /dev/null +++ b/backend/src/common/correlation/correlation-id.storage.ts @@ -0,0 +1,41 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { randomUUID } from 'crypto'; + +/** + * Stores the current request correlation ID for the duration of an async + * execution context. This allows Winston log formats and services to access + * the correlation ID without threading it through every function call. + */ +export const correlationIdStorage = new AsyncLocalStorage(); + +/** + * Generate a new correlation ID. Prefers an inbound X-Request-Id header so + * trace context can flow in from clients, load balancers, or workers. + */ +export function generateCorrelationId(inbound?: string | string[]): string { + const header = Array.isArray(inbound) ? inbound[0] : inbound; + if (header && typeof header === 'string' && header.trim().length > 0) { + return header.trim(); + } + return randomUUID(); +} + +/** + * Run the callback with the given correlation ID stored in async local + * storage. Useful for BullMQ workers that need to restore a request's + * correlation context when processing a job. + */ +export function runWithCorrelationId( + correlationId: string, + callback: () => T, +): T { + return correlationIdStorage.run(correlationId, callback); +} + +/** + * Convenience helper that returns the correlation ID for the current + * async context, or a fallback value if none is set. + */ +export function getCorrelationId(fallback = 'no-correlation-id'): string { + return correlationIdStorage.getStore() ?? fallback; +} diff --git a/backend/src/common/cors.config.spec.ts b/backend/src/common/cors.config.spec.ts new file mode 100644 index 0000000..e42c158 --- /dev/null +++ b/backend/src/common/cors.config.spec.ts @@ -0,0 +1,116 @@ +import { ConfigService } from '@nestjs/config'; +import { buildCorsOptions, getFrontendUrl } from './cors.config'; + +describe('buildCorsOptions', () => { + function createConfigService(values: Record): ConfigService { + return { + get: (key: string) => values[key], + } as unknown as ConfigService; + } + + it('should allow the configured origin in development', (done) => { + const config = createConfigService({ + NODE_ENV: 'development', + FRONTEND_URL: 'http://localhost:3001', + }); + const { corsOptions } = buildCorsOptions(config); + + (corsOptions.origin as Function)( + 'http://localhost:3001', + (err: Error | null, allow?: boolean) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }, + ); + }); + + it('should allow multiple origins from a comma-separated list', (done) => { + const config = createConfigService({ + NODE_ENV: 'production', + FRONTEND_URL: 'https://app.example.com,https://admin.example.com', + }); + const { corsOptions } = buildCorsOptions(config); + + (corsOptions.origin as Function)( + 'https://admin.example.com', + (err: Error | null, allow?: boolean) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }, + ); + }); + + it('should reject a disallowed origin', (done) => { + const config = createConfigService({ + NODE_ENV: 'production', + FRONTEND_URL: 'https://app.example.com', + }); + const { corsOptions } = buildCorsOptions(config); + + (corsOptions.origin as Function)( + 'https://evil.example.com', + (err: Error | null, allow?: boolean) => { + expect(err).toBeInstanceOf(Error); + expect(allow).toBe(false); + done(); + }, + ); + }); + + it('should refuse to start in production without FRONTEND_URL', () => { + const config = createConfigService({ NODE_ENV: 'production' }); + expect(() => buildCorsOptions(config)).toThrow( + 'FRONTEND_URL must be explicitly set in production', + ); + }); + + it('should fall back to localhost in development when FRONTEND_URL is unset', (done) => { + const config = createConfigService({ NODE_ENV: 'development' }); + const { corsOptions } = buildCorsOptions(config); + + (corsOptions.origin as Function)( + 'http://localhost:3001', + (err: Error | null, allow?: boolean) => { + expect(err).toBeNull(); + expect(allow).toBe(true); + done(); + }, + ); + }); + + it('should restrict methods and headers', () => { + const config = createConfigService({ + NODE_ENV: 'development', + FRONTEND_URL: 'http://localhost:3001', + }); + const { corsOptions } = buildCorsOptions(config); + + expect(corsOptions.methods).toContain('GET'); + expect(corsOptions.methods).toContain('POST'); + expect(corsOptions.allowedHeaders).toContain('Authorization'); + expect(corsOptions.allowedHeaders).toContain('X-Request-Id'); + expect(corsOptions.credentials).toBe(true); + }); +}); + +describe('getFrontendUrl', () => { + function createConfigService(values: Record): ConfigService { + return { + get: (key: string) => values[key], + } as unknown as ConfigService; + } + + it('returns the first configured origin', () => { + const config = createConfigService({ + FRONTEND_URL: 'https://a.example.com,https://b.example.com', + }); + expect(getFrontendUrl(config)).toBe('https://a.example.com'); + }); + + it('returns fallback when nothing is configured', () => { + const config = createConfigService({}); + expect(getFrontendUrl(config)).toBe('http://localhost:3001'); + }); +}); diff --git a/backend/src/common/cors.config.ts b/backend/src/common/cors.config.ts new file mode 100644 index 0000000..aba5284 --- /dev/null +++ b/backend/src/common/cors.config.ts @@ -0,0 +1,99 @@ +import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; +import { ConfigService } from '@nestjs/config'; + +const DEFAULT_DEV_ORIGIN = 'http://localhost:3001'; + +const ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']; + +const ALLOWED_HEADERS = [ + 'Content-Type', + 'Authorization', + 'X-Requested-With', + 'Accept', + 'Origin', + 'X-Request-Id', +]; + +/** + * Build hardened CORS options from configuration. + * + * FRONTEND_URL may be a comma-separated list of exact origins. In production + * the variable is required; in development it falls back to localhost so + * local frontend work continues without extra env setup. + * + * The origin callback never reflects an arbitrary Origin header; it only + * permits exact matches from the configured allowlist. + */ +export function buildCorsOptions( + configService: ConfigService, +): { isProduction: boolean; corsOptions: CorsOptions } { + const nodeEnv = configService.get('NODE_ENV') || 'development'; + const isProduction = nodeEnv === 'production'; + + const frontendUrl = configService.get('FRONTEND_URL'); + + if (isProduction && !frontendUrl) { + throw new Error( + 'FRONTEND_URL must be explicitly set in production. Credentialed CORS cannot fall back to a development origin.', + ); + } + + const allowlist = parseAllowlist(frontendUrl, !isProduction); + + const corsOptions: CorsOptions = { + origin: (origin, callback) => { + // Allow same-origin / non-browser requests (e.g. server-to-server, + // health checks, mobile apps) when no Origin header is present. + if (!origin) { + return callback(null, true); + } + + if (allowlist.has(origin)) { + return callback(null, true); + } + + return callback( + new Error(`Origin ${origin} is not allowed by CORS`), + false, + ); + }, + credentials: true, + methods: ALLOWED_METHODS, + allowedHeaders: ALLOWED_HEADERS, + }; + + return { isProduction, corsOptions }; +} + +function parseAllowlist( + frontendUrl: string | undefined, + allowDevFallback: boolean, +): Set { + const allowlist = new Set(); + + if (frontendUrl) { + frontendUrl + .split(',') + .map((o) => o.trim()) + .filter((o) => o.length > 0) + .forEach((o) => allowlist.add(o)); + } + + if (allowDevFallback && allowlist.size === 0) { + allowlist.add(DEFAULT_DEV_ORIGIN); + } + + return allowlist; +} + +export function getFrontendUrl( + configService: ConfigService, + fallback = 'http://localhost:3001', +): string { + const configured = configService.get('FRONTEND_URL'); + if (configured) { + const first = configured.split(',')[0].trim(); + if (first) return first; + } + return fallback; +} diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 0707b6d..ba5bddb 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -30,7 +30,7 @@ export class HttpExceptionFilter implements ExceptionFilter { const { message, error } = this.normalizeResponse(errorResponse, exception); - const requestId = (request as any).requestId || request.headers['x-request-id'] || 'req-id'; + const requestId = request.requestId || 'unknown'; const errorCode = (errorResponse as any)?.errorCode || error || `ERR_${status}`; const payload = { @@ -43,7 +43,7 @@ export class HttpExceptionFilter implements ExceptionFilter { path: request.url, }; - this.logger.error(`${status} -> `, (exception as Error)?.stack); + this.logger.error(`${status} ${request.method} ${request.url} -> ${message}`, (exception as Error)?.stack); if (!this.isProduction && exception instanceof Error) { Object.assign(payload, { stack: exception.stack }); diff --git a/backend/src/common/logger.config.spec.ts b/backend/src/common/logger.config.spec.ts new file mode 100644 index 0000000..4888514 --- /dev/null +++ b/backend/src/common/logger.config.spec.ts @@ -0,0 +1,62 @@ +import { createLogger, transports } from 'winston'; +import { buildWinstonOptions } from './logger.config'; +import { runWithCorrelationId } from './correlation/correlation-id.storage'; + +describe('buildWinstonOptions', () => { + let logSpy: jest.SpyInstance; + + beforeEach(() => { + logSpy = jest + .spyOn(transports.Console.prototype, 'log') + .mockImplementation(function (this: unknown, _info: unknown, callback: () => void) { + callback(); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + function lastLogLine(): Record { + const lastCall = logSpy.mock.calls[logSpy.mock.calls.length - 1]; + return lastCall[0] as Record; + } + + it('should include correlation ID in every log line', async () => { + const logger = createLogger(buildWinstonOptions('info')); + await runWithCorrelationId('corr-123', async () => { + logger.info('hello'); + }); + + const line = lastLogLine(); + expect(line.requestId).toBe('corr-123'); + expect(line.message).toBe('hello'); + }); + + it('should redact authorization fields', () => { + const logger = createLogger(buildWinstonOptions('info')); + logger.info('login', { + authorization: 'Bearer secret-token', + nested: { password: 'super-secret', token: 'jwt-token' }, + }); + + const line = lastLogLine(); + expect(line.authorization).toBe('[REDACTED]'); + expect((line.nested as Record).password).toBe( + '[REDACTED]', + ); + expect((line.nested as Record).token).toBe('[REDACTED]'); + }); + + it('should keep non-sensitive fields intact', () => { + const logger = createLogger(buildWinstonOptions('info')); + logger.info('request', { + userId: 'user-1', + path: '/api/documents', + }); + + const line = lastLogLine(); + expect(line.userId).toBe('user-1'); + expect(line.path).toBe('/api/documents'); + }); +}); diff --git a/backend/src/common/logger.config.ts b/backend/src/common/logger.config.ts index 79f3c58..449647c 100644 --- a/backend/src/common/logger.config.ts +++ b/backend/src/common/logger.config.ts @@ -1,9 +1,91 @@ import { WinstonModuleOptions } from 'nest-winston'; import { format, transports } from 'winston'; +import { TransformableInfo } from 'logform'; + +import { getCorrelationId } from './correlation/correlation-id.storage'; + +const REDACTED = '[REDACTED]'; + +const SENSITIVE_KEYS = new Set([ + 'authorization', + 'cookie', + 'password', + 'token', + 'access_token', + 'refresh_token', + 'secret', + 'api_key', + 'apikey', + 'client_secret', + 'smtp_pass', + 'mail_password', + 'database_password', + 'redis_password', + 'stellar_secret_key', +]); + +function isSensitiveKey(key: string): boolean { + const lower = key.toLowerCase(); + return ( + SENSITIVE_KEYS.has(lower) || + lower.endsWith('_token') || + lower.endsWith('_secret') || + lower.endsWith('_password') || + lower.endsWith('_key') + ); +} + +function redactValue(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (typeof value === 'string') { + return value.length > 0 ? REDACTED : value; + } + if (Array.isArray(value)) { + return value.map(() => REDACTED); + } + if (typeof value === 'object') { + return REDACTED; + } + return value; +} + +function redact(info: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(info)) { + if (isSensitiveKey(key)) { + result[key] = redactValue(value); + } else if (Array.isArray(value)) { + result[key] = value.map((item) => + typeof item === 'object' && item !== null + ? redact(item as Record) + : item, + ); + } else if (typeof value === 'object' && value !== null) { + result[key] = redact(value as Record); + } else { + result[key] = value; + } + } + return result; +} + +const redactionFormat = format((info: TransformableInfo) => { + const redacted = redact(info as Record); + Object.keys(info).forEach((key) => delete (info as Record)[key]); + Object.assign(info, redacted); + return info; +}); + +const correlationFormat = format((info: TransformableInfo) => { + (info as Record).requestId = getCorrelationId(); + return info; +}); const baseFormat = format.combine( format.timestamp(), + correlationFormat(), format.errors({ stack: true }), + redactionFormat(), format.json(), ); diff --git a/backend/src/common/middleware/logger.middleware.ts b/backend/src/common/middleware/logger.middleware.ts index 9fd166c..2b931b7 100644 --- a/backend/src/common/middleware/logger.middleware.ts +++ b/backend/src/common/middleware/logger.middleware.ts @@ -27,6 +27,7 @@ export class LoggerMiddleware implements NestMiddleware { const httpMethod = req.method; const statusCode = res.statusCode; const userId = (req as any).user?.id || null; + const requestId = (req as any).requestId || null; const payload: Record = { method: httpMethod, @@ -36,12 +37,9 @@ export class LoggerMiddleware implements NestMiddleware { user_agent: userAgent, ip, userId, + requestId, }; - if (req.headers.authorization) { - payload.authorization = '[REDACTED]'; - } - this.logger.info('http-request', payload); // Persist access log via AccessLogsService asynchronously diff --git a/backend/src/documents/documents.controller.spec.ts b/backend/src/documents/documents.controller.spec.ts new file mode 100644 index 0000000..1d530b3 --- /dev/null +++ b/backend/src/documents/documents.controller.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Response } from 'express'; +import { createReadStream, ReadStream } from 'fs'; +import { DocumentsController } from './documents.controller'; +import { DocumentsService } from './documents.service'; +import { Document, DocumentStatus } from './entities/document.entity'; +import { QueueService } from '../queue/queue.service'; +import { VerificationService } from '../verification/verification.service'; + +jest.mock('crypto', () => ({ + ...jest.requireActual('crypto'), + randomUUID: jest.fn().mockReturnValue('random-storage-key'), +})); + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + createReadStream: jest.fn(), + promises: { + mkdir: jest.fn().mockResolvedValue(undefined), + writeFile: jest.fn().mockResolvedValue(undefined), + }, +})); + +const mockDocument = { + id: 'doc-123', + ownerId: 'user-456', + title: '../../etc/passwd', + filePath: '/uploads/random-storage-key.pdf', + fileHash: 'abc123hash', + fileSize: 100, + mimeType: 'application/pdf', + status: DocumentStatus.PENDING, +}; + +const mockRepository = { + create: jest.fn().mockReturnValue(mockDocument), + save: jest.fn().mockResolvedValue(mockDocument), + findOne: jest.fn().mockResolvedValue(mockDocument), + find: jest.fn().mockResolvedValue([mockDocument]), + update: jest.fn().mockResolvedValue({ affected: 1 }), + delete: jest.fn().mockResolvedValue({ affected: 1 }), +}; + +describe('DocumentsController', () => { + let controller: DocumentsController; + let queueService: QueueService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DocumentsController], + providers: [ + DocumentsService, + { provide: getRepositoryToken(Document), useValue: mockRepository }, + { + provide: ConfigService, + useValue: { get: (key: string) => (key === 'UPLOAD_DIR' ? '/uploads' : undefined) }, + }, + { + provide: QueueService, + useValue: { + enqueueAnalyze: jest.fn().mockResolvedValue(undefined), + enqueueAnchor: jest.fn().mockResolvedValue(undefined), + }, + }, + { + provide: VerificationService, + useValue: { findLatestByDocument: jest.fn() }, + }, + ], + }).compile(); + + controller = module.get(DocumentsController); + queueService = module.get(QueueService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('uploadDocument', () => { + it('should use a randomized storage key and ignore client filename', async () => { + const file = { + fieldname: 'file', + originalname: '../../etc/passwd', + mimetype: 'application/pdf', + size: 100, + buffer: Buffer.from('%PDF-1.4\n'), + } as Express.Multer.File; + + const req = { user: { id: 'user-456' }, requestId: 'req-1' } as any; + const res = { status: jest.fn().mockReturnThis(), send: jest.fn() } as any; + + mockRepository.findOne.mockResolvedValueOnce(null); + + await controller.uploadDocument(file, req, res); + + const { writeFile } = jest.requireMock('fs').promises; + expect(writeFile).toHaveBeenCalled(); + const savedPath = writeFile.mock.calls[0][0]; + expect(savedPath).toContain('random-storage-key.pdf'); + expect(savedPath).not.toContain('passwd'); + expect(savedPath).not.toContain('..'); + expect(queueService.enqueueAnalyze).toHaveBeenCalledWith('doc-123', 'req-1'); + }); + }); + + describe('downloadDocument', () => { + it('should serve files as attachment with correct content type', async () => { + const stream = { pipe: jest.fn(), on: jest.fn() } as unknown as ReadStream; + (createReadStream as jest.Mock).mockReturnValue(stream); + + const req = { user: { id: 'user-456' } } as any; + const res = { + set: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + await controller.downloadDocument('doc-123', req, res); + + expect(createReadStream).toHaveBeenCalledWith(mockDocument.filePath); + expect(res.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${mockDocument.title}"`, + 'X-Content-Type-Options': 'nosniff', + }), + ); + }); + }); +}); diff --git a/backend/src/documents/documents.controller.ts b/backend/src/documents/documents.controller.ts index 6f3a3a6..cde0820 100644 --- a/backend/src/documents/documents.controller.ts +++ b/backend/src/documents/documents.controller.ts @@ -6,6 +6,7 @@ import { Get, NotFoundException, Param, + ParseUUIDPipe, Post, Query, Req, @@ -13,13 +14,15 @@ import { UploadedFile, UseGuards, UseInterceptors, + UsePipes, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ConfigService } from '@nestjs/config'; import { Request, Response } from 'express'; -import { createHash } from 'crypto'; -import { promises as fs } from 'fs'; -import { extname, join } from 'path'; +import { createHash, randomUUID } from 'crypto'; +import { createReadStream, promises as fs } from 'fs'; +import { join } from 'path'; +import { lookup } from 'mime-types'; import * as multer from 'multer'; import { DocumentsService } from './documents.service'; @@ -28,6 +31,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { User } from '../users/entities/user.entity'; import { QueueService } from '../queue/queue.service'; import { VerificationService } from '../verification/verification.service'; +import { FileValidationPipe } from './pipes/file-validation.pipe'; import { ListDocumentsDto } from './dto/list-documents.dto'; import { DocumentResponseDto } from './dto/document-response.dto'; @@ -64,6 +68,7 @@ export class DocumentsController { limits: { fileSize: MAX_FILE_SIZE_BYTES }, }), ) + @UsePipes(FileValidationPipe) async uploadDocument( @UploadedFile() file: Express.Multer.File, @Req() req: Request & { user?: User }, @@ -88,8 +93,10 @@ export class DocumentsController { this.configService.get('UPLOAD_DIR') || './uploads'; await fs.mkdir(uploadDir, { recursive: true }); - const extension = extname(file.originalname) || ''; - const filename = `${fileHash}${extension}`; + // Use a randomized storage key; the client filename is never a path component. + const storageKey = randomUUID(); + const safeExtension = this.safeExtension(file.mimetype); + const filename = `${storageKey}${safeExtension}`; const targetPath = join(uploadDir, filename); await fs.writeFile(targetPath, file.buffer); @@ -103,7 +110,7 @@ export class DocumentsController { status: DocumentStatus.PENDING, }); - await this.queueService.enqueueAnalyze(document.id); + await this.queueService.enqueueAnalyze(document.id, req.requestId); return res.status(202).send(document); } @@ -151,7 +158,11 @@ export class DocumentsController { @Post(':id/verify') @UseGuards(JwtAuthGuard) - async verifyDocument(@Param('id') id: string, @Res() res: Response) { + async verifyDocument( + @Param('id') id: string, + @Req() req: Request, + @Res() res: Response, + ) { const document = await this.documentsService.findById(id); if (!document) { throw new NotFoundException('Document not found'); @@ -161,7 +172,7 @@ export class DocumentsController { throw new ConflictException('Document has already been verified'); } - await this.queueService.enqueueAnchor(document.id); + await this.queueService.enqueueAnchor(document.id, req.requestId); return res.status(202).json({ message: 'Verification queued', @@ -186,6 +197,53 @@ export class DocumentsController { return record; } + + @Get(':id/download') + @UseGuards(JwtAuthGuard) + async downloadDocument( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request & { user?: User }, + @Res() res: Response, + ) { + const document = await this.documentsService.findById(id); + if (!document) { + throw new NotFoundException('Document not found'); + } + + const user = req.user; + if (!user || document.ownerId !== user.id) { + throw new NotFoundException('Document not found'); + } + + const stream = createReadStream(document.filePath); + const contentType = + lookup(document.filePath) || document.mimeType || 'application/octet-stream'; + + res.set({ + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${document.title}"`, + 'X-Content-Type-Options': 'nosniff', + }); + + stream.on('error', () => { + throw new NotFoundException('File not found on disk'); + }); + + stream.pipe(res); + } + + private safeExtension(mimeType: string): string { + switch (mimeType) { + case 'application/pdf': + return '.pdf'; + case 'image/png': + return '.png'; + case 'image/jpeg': + return '.jpg'; + default: + return '.bin'; + } + } } function toDocumentResponse(document: Document): DocumentResponseDto { diff --git a/backend/src/documents/pipes/file-validation.pipe.spec.ts b/backend/src/documents/pipes/file-validation.pipe.spec.ts new file mode 100644 index 0000000..9abc956 --- /dev/null +++ b/backend/src/documents/pipes/file-validation.pipe.spec.ts @@ -0,0 +1,110 @@ +import { BadRequestException } from '@nestjs/common'; +import { PDFDocument } from 'pdf-lib'; +import sharp = require('sharp'); +import { FileValidationPipe } from './file-validation.pipe'; + +function createFile( + buffer: Buffer, + mimetype: string, + originalname: string, +): Express.Multer.File { + return { + fieldname: 'file', + originalname, + encoding: '7bit', + mimetype, + size: buffer.length, + buffer, + stream: null as any, + destination: '', + filename: originalname, + path: '', + }; +} + +async function createValidPdf(): Promise { + const pdf = await PDFDocument.create(); + pdf.addPage(); + return Buffer.from(await pdf.save()); +} + +async function createValidJpeg(): Promise { + return sharp({ + create: { width: 10, height: 10, channels: 3, background: 'white' }, + }) + .jpeg() + .toBuffer(); +} + +async function createValidPng(): Promise { + return sharp({ + create: { width: 10, height: 10, channels: 3, background: 'white' }, + }) + .png() + .toBuffer(); +} + +describe('FileValidationPipe', () => { + let pipe: FileValidationPipe; + + beforeEach(() => { + pipe = new FileValidationPipe(); + }); + + it('should accept a valid PDF by content', async () => { + const buffer = await createValidPdf(); + const file = createFile(buffer, 'application/pdf', 'deed.pdf'); + const result = await pipe.transform(file); + expect(result.mimetype).toBe('application/pdf'); + }); + + it('should accept a valid PNG by content', async () => { + const buffer = await createValidPng(); + const file = createFile(buffer, 'image/png', 'survey.png'); + const result = await pipe.transform(file); + expect(result.mimetype).toBe('image/png'); + }); + + it('should reject an executable renamed to .pdf', async () => { + const buffer = Buffer.from('MZ\x90\x00'); + const file = createFile(buffer, 'application/pdf', 'malware.pdf'); + await expect(pipe.transform(file)).rejects.toThrow(BadRequestException); + }); + + it('should reject a zip archive by content regardless of extension', async () => { + const buffer = Buffer.from('PK\x03\x04'); + const file = createFile(buffer, 'application/pdf', 'archive.pdf'); + await expect(pipe.transform(file)).rejects.toThrow(BadRequestException); + }); + + it('should reject a file whose declared MIME type does not match content', async () => { + const buffer = await createValidPdf(); + const file = createFile(buffer, 'image/png', 'spoof.png'); + await expect(pipe.transform(file)).rejects.toThrow(BadRequestException); + }); + + it('should reject blocked file extensions', async () => { + const buffer = await createValidPdf(); + const file = createFile(buffer, 'application/pdf', 'script.sh'); + await expect(pipe.transform(file)).rejects.toThrow(BadRequestException); + }); + + it('should reject files that exceed the maximum size', async () => { + const jpeg = await createValidJpeg(); + const buffer = Buffer.alloc(21 * 1024 * 1024, 0xff); + jpeg.copy(buffer); + buffer[0] = 0xff; + buffer[1] = 0xd8; + buffer[2] = 0xff; + const file = createFile(buffer, 'image/jpeg', 'huge.jpg'); + await expect(pipe.transform(file)).rejects.toThrow(BadRequestException); + }); + + it('should strip metadata from a JPEG', async () => { + const buffer = await createValidJpeg(); + const file = createFile(buffer, 'image/jpeg', 'photo.jpg'); + const result = await pipe.transform(file); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.mimetype).toBe('image/jpeg'); + }); +}); diff --git a/backend/src/documents/pipes/file-validation.pipe.ts b/backend/src/documents/pipes/file-validation.pipe.ts new file mode 100644 index 0000000..23a9fab --- /dev/null +++ b/backend/src/documents/pipes/file-validation.pipe.ts @@ -0,0 +1,246 @@ +import { + BadRequestException, + Injectable, + PipeTransform, +} from '@nestjs/common'; +import { PDFDocument, PDFName } from 'pdf-lib'; +import sharp = require('sharp'); + +const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg']; +const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; + +interface DetectedType { + mime: string; + ext: string; +} + +const BLOCKED_MIME_PREFIXES = [ + 'application/zip', + 'application/x-zip', + 'application/x-rar', + 'application/x-7z-compressed', + 'application/x-tar', + 'application/gzip', + 'application/x-bzip', + 'application/x-bzip2', + 'application/x-msdownload', + 'application/x-exe', + 'application/x-msdos-program', + 'application/x-sh', + 'application/x-bat', + 'application/javascript', + 'text/javascript', + 'application/x-shellscript', +]; + +const BLOCKED_EXTENSIONS = [ + '.exe', + '.dll', + '.bat', + '.cmd', + '.sh', + '.zip', + '.rar', + '.7z', + '.tar', + '.gz', +]; + +/** + * Validates and sanitizes uploaded files by inspecting actual file content + * (magic bytes) rather than trusting the declared Content-Type or extension. + * + * Rejects archives, executables, and files outside the allowlist. Strips + * metadata from images. Detects PDFs with active content (JavaScript actions + * or embedded files) and rejects them. + */ +@Injectable() +export class FileValidationPipe + implements PipeTransform> +{ + async transform(file: Express.Multer.File): Promise { + if (!file) { + throw new BadRequestException('File is required'); + } + + this.validateSize(file); + this.rejectByExtension(file.originalname); + + const detectedType = this.detectFileType(file.buffer); + + if (!detectedType) { + throw new BadRequestException( + 'Unable to determine file type from content', + ); + } + + this.rejectBlockedMimeType(detectedType.mime); + + if (!ALLOWED_MIME_TYPES.includes(detectedType.mime)) { + throw new BadRequestException( + `File type ${detectedType.mime} is not allowed`, + ); + } + + if (detectedType.mime !== file.mimetype) { + throw new BadRequestException( + `Declared MIME type ${file.mimetype} does not match actual file type ${detectedType.mime}`, + ); + } + + if (detectedType.mime === 'application/pdf') { + await this.validatePdf(file.buffer); + } + + if ( + detectedType.mime === 'image/png' || + detectedType.mime === 'image/jpeg' + ) { + file.buffer = await this.stripImageMetadata(file.buffer); + } + + return file; + } + + private detectFileType(buffer: Buffer): DetectedType | undefined { + if (buffer.length < 4) return undefined; + + // PDF + if (buffer.slice(0, 4).toString('ascii') === '%PDF') { + return { mime: 'application/pdf', ext: 'pdf' }; + } + + // PNG + if ( + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ) { + return { mime: 'image/png', ext: 'png' }; + } + + // JPEG + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return { mime: 'image/jpeg', ext: 'jpg' }; + } + + // ZIP-based formats (including docx, xlsx, etc.) + if (buffer[0] === 0x50 && buffer[1] === 0x4b) { + return { mime: 'application/zip', ext: 'zip' }; + } + + // RAR + if (buffer.slice(0, 4).toString('ascii') === 'Rar!') { + return { mime: 'application/x-rar', ext: 'rar' }; + } + + // 7z + if ( + buffer[0] === 0x37 && + buffer[1] === 0x7a && + buffer[2] === 0xbc && + buffer[3] === 0xaf + ) { + return { mime: 'application/x-7z-compressed', ext: '7z' }; + } + + // GZIP + if (buffer[0] === 0x1f && buffer[1] === 0x8b) { + return { mime: 'application/gzip', ext: 'gz' }; + } + + // BZIP2 + if (buffer[0] === 0x42 && buffer[1] === 0x5a && buffer[2] === 0x68) { + return { mime: 'application/x-bzip2', ext: 'bz2' }; + } + + // EXE / DLL + if (buffer.slice(0, 2).toString('ascii') === 'MZ') { + return { mime: 'application/x-msdownload', ext: 'exe' }; + } + + // ELF + if (buffer[0] === 0x7f && buffer.slice(1, 4).toString('ascii') === 'ELF') { + return { mime: 'application/x-elf', ext: 'elf' }; + } + + return undefined; + } + + private validateSize(file: Express.Multer.File): void { + if (file.size > MAX_FILE_SIZE_BYTES) { + throw new BadRequestException( + `File exceeds maximum size of ${MAX_FILE_SIZE_BYTES} bytes`, + ); + } + } + + private rejectByExtension(originalname: string): void { + const lower = originalname.toLowerCase(); + for (const ext of BLOCKED_EXTENSIONS) { + if (lower.endsWith(ext)) { + throw new BadRequestException( + `File extension ${ext} is not allowed`, + ); + } + } + } + + private rejectBlockedMimeType(mime: string): void { + for (const prefix of BLOCKED_MIME_PREFIXES) { + if (mime.startsWith(prefix)) { + throw new BadRequestException(`File type ${mime} is not allowed`); + } + } + } + + private async validatePdf(buffer: Buffer): Promise { + try { + const pdf = await PDFDocument.load(buffer, { + updateMetadata: false, + }); + + // Reject PDFs that carry an EmbeddedFiles name tree. + const catalog = pdf.catalog; + const namesRef = catalog.get(PDFName.of('Names')); + if (namesRef) { + const names = pdf.context.lookup(namesRef) as any; + if (names && names.get(PDFName.of('EmbeddedFiles'))) { + throw new BadRequestException( + 'PDF contains embedded files and is not allowed', + ); + } + } + + // Reject pages with actions or additional-actions dictionaries. + for (const page of pdf.getPages()) { + const node = page.node as any; + const actions = + node.get(PDFName.of('A')) || node.get(PDFName.of('AA')); + if (actions) { + throw new BadRequestException( + 'PDF contains active content (JavaScript/actions) and is not allowed', + ); + } + } + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + throw new BadRequestException('Invalid or malformed PDF file'); + } + } + + private async stripImageMetadata(buffer: Buffer): Promise { + try { + return await sharp(buffer).withMetadata({}).toBuffer(); + } catch { + throw new BadRequestException('Failed to process image metadata'); + } + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 17f9ff0..07d1c78 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -6,6 +6,7 @@ import { ConfigService } from '@nestjs/config'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { WinstonModule } from 'nest-winston'; import { buildWinstonOptions } from './common/logger.config'; +import { buildCorsOptions } from './common/cors.config'; import { config as loadEnv } from 'dotenv'; loadEnv({ path: '.env' }); @@ -18,12 +19,9 @@ async function bootstrap() { const configService = app.get(ConfigService); - // Enable CORS - app.enableCors({ - origin: - configService.get('FRONTEND_URL') || 'http://localhost:3001', - credentials: true, - }); + // Enable hardened CORS + const { corsOptions } = buildCorsOptions(configService); + app.enableCors(corsOptions); // Global prefix & URI versioning app.setGlobalPrefix('api'); diff --git a/backend/src/queue/document.processor.ts b/backend/src/queue/document.processor.ts index 749bd6c..8af0b0a 100644 --- a/backend/src/queue/document.processor.ts +++ b/backend/src/queue/document.processor.ts @@ -8,7 +8,11 @@ import { VerificationService } from '../verification/verification.service'; import { VerificationStatus } from '../verification/entities/verification-record.entity'; import { RiskAssessmentService } from '../risk-assessment/risk-assessment.service'; import { StellarService } from '../stellar/stellar.service'; -import { QueueService } from './queue.service'; +import { QueueService, DocumentJobData } from './queue.service'; +import { + generateCorrelationId, + runWithCorrelationId, +} from '../common/correlation/correlation-id.storage'; @Injectable() export class DocumentProcessor implements OnModuleDestroy { @@ -28,21 +32,31 @@ export class DocumentProcessor implements OnModuleDestroy { this.worker = new Worker( this.queueService.queueName, async (job) => { - if (job.name === 'analyze') { - await this.handleAnalyze(job.data.documentId); - return; - } - if (job.name === 'anchor') { - await this.handleAnchor(job.data.documentId); - } + const data = job.data as DocumentJobData; + const requestId = data.requestId || generateCorrelationId(); + + return runWithCorrelationId(requestId, async () => { + this.logger.log( + `Processing job ${job.id} (${job.name}) for document ${data.documentId}`, + ); + + if (job.name === 'analyze') { + await this.riskService.assessDocument(data.documentId); + return; + } + if (job.name === 'anchor') { + await this.handleAnchor(data.documentId); + } + }); }, { connection }, ); this.worker.on('failed', (job, err) => { + const data = (job?.data as DocumentJobData) ?? { documentId: 'unknown' }; + const requestId = data.requestId || 'unknown'; this.logger.error( - `Job ${job.id} (${job.name}) failed`, - err?.message, + `Job ${job?.id} (${job?.name}) failed (request ${requestId}): ${err?.message}`, err?.stack, ); }); diff --git a/backend/src/queue/queue.service.ts b/backend/src/queue/queue.service.ts index 978662e..3ade6b2 100644 --- a/backend/src/queue/queue.service.ts +++ b/backend/src/queue/queue.service.ts @@ -2,6 +2,11 @@ import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Queue, ConnectionOptions as RedisConnectionOptions } from 'bullmq'; +export interface DocumentJobData { + documentId: string; + requestId?: string; +} + @Injectable() export class QueueService implements OnModuleDestroy { private readonly logger = new Logger(QueueService.name); @@ -33,12 +38,18 @@ export class QueueService implements OnModuleDestroy { return this.connection; } - async enqueueAnalyze(documentId: string) { - return this.queue.add('analyze', { documentId }); + async enqueueAnalyze(documentId: string, requestId?: string) { + this.logger.debug( + `Queueing analyze job for document ${documentId} (request ${requestId ?? 'none'})`, + ); + return this.queue.add('analyze', { documentId, requestId }); } - async enqueueAnchor(documentId: string) { - return this.queue.add('anchor', { documentId }); + async enqueueAnchor(documentId: string, requestId?: string) { + this.logger.debug( + `Queueing anchor job for document ${documentId} (request ${requestId ?? 'none'})`, + ); + return this.queue.add('anchor', { documentId, requestId }); } async onModuleDestroy(): Promise { diff --git a/backend/src/types/sharp.d.ts b/backend/src/types/sharp.d.ts new file mode 100644 index 0000000..dee1225 --- /dev/null +++ b/backend/src/types/sharp.d.ts @@ -0,0 +1,22 @@ +declare module 'sharp' { + interface Sharp { + withMetadata(metadata?: Record): Sharp; + toBuffer(): Promise; + jpeg(options?: Record): Sharp; + png(options?: Record): Sharp; + } + + interface SharpOptions {} + interface CreateOptions { + width: number; + height: number; + channels?: number; + background?: string | Record; + } + + function sharp(input?: Buffer | string | ArrayBufferView, options?: SharpOptions): Sharp; + function sharp(options: { create: CreateOptions }): Sharp; + namespace sharp {} + + export = sharp; +}