diff --git a/.claude/workflows/new-feature.md b/.claude/workflows/new-feature.md index acf56c5..3cbf167 100644 --- a/.claude/workflows/new-feature.md +++ b/.claude/workflows/new-feature.md @@ -13,9 +13,10 @@ Use this workflow when adding new functionality to Signalist. 4. APPROVE → Get user sign-off 5. IMPLEMENT → Build incrementally 6. TEST → Write and run tests -7. REVIEW → Code review -8. ACCEPT → Verify against original request -9. MERGE → Complete +7. SECURITY → Security checklist +8. REVIEW → Code review +9. ACCEPT → Verify against original request +10. MERGE → Complete ``` --- @@ -208,7 +209,50 @@ docker compose exec app vendor/bin/behat --suite=api # API tests --- -## Step 7: Review +## Step 7: Security + +Run this checklist against every feature before code review. Tick only what is relevant — skip rows that don't apply. + +### Input & Output +- [ ] All user-supplied input validated via InputDTO constraints (`#[Assert\*]`) +- [ ] No raw user content rendered as HTML without sanitization (DOMPurify / HTMLPurifier) +- [ ] URL fields validated against SSRF-safe constraint (`#[SsrfSafeUrl]`) if the app fetches the URL +- [ ] Article/external content stored sanitized; URLs validated as `http/https` only + +### Authentication & Authorization +- [ ] All new endpoints are behind JWT firewall (no `PUBLIC_ACCESS` unless intentional) +- [ ] State processors/providers use `if (!$user instanceof User) throw new AccessDeniedException()` — never `assert()` +- [ ] No user can access another user's resources (ownerId scope enforced in queries) + +### Sensitive Data & GDPR +- [ ] No secrets, tokens, or credentials hardcoded — use environment variables +- [ ] No personal data logged in plain text +- [ ] New entities containing personal data have `deletedAt` soft-delete column +- [ ] Data sent to external AI services is anonymized + +### API Design +- [ ] New endpoints return RFC 7807 problem details on error +- [ ] No internal error messages exposed to API consumers (generic messages only) +- [ ] Rate limiting considered if endpoint is public or auth-related + +### Dependency & Supply Chain +- [ ] Any new composer package checked: `composer audit` +- [ ] Any new npm package checked: `npm audit --audit-level=high` + +### Quick Commands +```bash +# Check PHP dependencies for known vulnerabilities +docker compose exec app composer audit + +# Check JS dependencies +cd frontend && npm audit --audit-level=high +``` + +If any checklist item raises a concern, fix it before proceeding to review. + +--- + +## Step 8: Review Use `/review` to run a self-contained code review against architecture, quality, security, and test criteria. @@ -219,7 +263,7 @@ Use `/review` to run a self-contained code review against architecture, quality, --- -## Step 8: Acceptance Check +## Step 9: Acceptance Check **Before marking complete, verify the implementation matches the original request.** @@ -272,7 +316,7 @@ Present to user for final approval: --- -## Step 9: Merge +## Step 10: Merge ### Pre-Merge Checklist - [ ] All tests passing diff --git a/.env b/.env index 8f1d1e0..e41bf37 100644 --- a/.env +++ b/.env @@ -50,7 +50,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> lexik/jwt-authentication-bundle ### JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem -JWT_PASSPHRASE=change_me +JWT_PASSPHRASE= ###< lexik/jwt-authentication-bundle ### ###> symfony/mailer ### diff --git a/.env.dev b/.env.dev index 567ac0f..8eb3eb2 100644 --- a/.env.dev +++ b/.env.dev @@ -1,4 +1,4 @@ ###> symfony/framework-bundle ### -APP_SECRET=4416dca5875954bac65fe0f2cd218c89 +APP_SECRET= ###< symfony/framework-bundle ### diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9915e1f..567da67 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build Docker images run: docker compose build --no-cache @@ -88,6 +88,11 @@ jobs: - name: Run Behat run: docker compose exec -T app vendor/bin/behat --no-interaction --colors + - name: Audit PHP dependencies + # Exit 1 = vulnerabilities (blocking), exit 2 = abandoned packages (non-blocking) + run: | + docker compose exec -T app composer audit || { code=$?; [ "$code" -eq 2 ] || exit "$code"; } + - name: Run CS Fixer (dry-run) run: docker compose exec -T app vendor/bin/php-cs-fixer fix --dry-run --diff @@ -103,6 +108,20 @@ jobs: - name: Doctrine Schema Validator run: docker compose exec -T app php bin/console -e test doctrine:schema:validate --skip-sync || true + secret-scan: + name: Secret Scan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Scan for secrets + uses: trufflesecurity/trufflehog@main + with: + extra_args: --only-verified + lint: name: Docker Lint runs-on: ubuntu-latest @@ -156,11 +175,15 @@ jobs: - name: Type check if: steps.check_frontend.outputs.exists == 'true' - run: npm run typecheck || true + run: npm run typecheck - name: Lint if: steps.check_frontend.outputs.exists == 'true' - run: npm run lint || true + run: npm run lint + + - name: Audit JS dependencies + if: steps.check_frontend.outputs.exists == 'true' + run: npm audit --audit-level=high - name: Run tests if: steps.check_frontend.outputs.exists == 'true' diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 2bef601..74369a1 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -43,3 +43,8 @@ api_platform: mapping: paths: - '%kernel.project_dir%/src/Infrastructure/ApiPlatform/Resource' + +when@prod: + api_platform: + enable_swagger_ui: false + enable_docs: false diff --git a/docker/frankenphp/Caddyfile b/docker/frankenphp/Caddyfile index d0d98c8..cf5e6d5 100644 --- a/docker/frankenphp/Caddyfile +++ b/docker/frankenphp/Caddyfile @@ -38,8 +38,8 @@ header { X-Frame-Options "SAMEORIGIN" X-Content-Type-Options "nosniff" - X-XSS-Protection "1; mode=block" Referrer-Policy "strict-origin-when-cross-origin" + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none';" -Server } diff --git a/features/api/register.feature b/features/api/register.feature index febfabb..9f76822 100644 --- a/features/api/register.feature +++ b/features/api/register.feature @@ -12,24 +12,24 @@ Feature: Authentication - Register """ { "email": "newuser@signalist.app", - "password": "securepassword" + "password": "Fixture_Only!NotAReal#Pw9" } """ Then the response status code should be 201 And the response should be JSON - And the JSON response should contain "id" + And the JSON response should contain "message" - Scenario: Registration with duplicate email + Scenario: Registration with duplicate email returns same generic response When I send a "POST" request to "/api/v1/auth/register" with body: """ { "email": "admin@signalist.app", - "password": "securepassword" + "password": "Fixture_Only!NotAReal#Pw9" } """ - Then the response status code should be 409 + Then the response status code should be 201 And the response should be JSON - And the JSON response should be a RFC 7807 problem + And the JSON response should contain "message" Scenario: Registration with missing email When I send a "POST" request to "/api/v1/auth/register" with body: @@ -58,7 +58,7 @@ Feature: Authentication - Register """ { "email": "not-an-email", - "password": "securepassword" + "password": "Fixture_Only!NotAReal#Pw9" } """ Then the response status code should be 422 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a0e4a3..4e92939 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,9 @@ "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", "@tanstack/react-query": "^5.90.20", + "@types/dompurify": "^3.0.5", "axios": "^1.13.4", + "dompurify": "^3.3.3", "i18next": "^25.8.18", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -1642,9 +1644,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1656,9 +1658,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1670,9 +1672,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1684,9 +1686,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1698,9 +1700,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1712,9 +1714,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1726,9 +1728,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1740,9 +1742,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1754,9 +1756,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1768,9 +1770,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1782,9 +1784,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -1796,9 +1798,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1810,9 +1812,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -1824,9 +1826,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1838,9 +1840,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1852,9 +1854,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1866,9 +1868,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1880,9 +1882,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1894,9 +1896,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1908,9 +1910,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -1922,9 +1924,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1936,9 +1938,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1950,9 +1952,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1964,9 +1966,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1978,9 +1980,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2185,6 +2187,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2249,6 +2260,12 @@ "@types/react": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -2448,13 +2465,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2684,9 +2701,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2777,13 +2794,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", - "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -3260,6 +3277,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3753,9 +3779,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -4583,9 +4609,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -5156,9 +5182,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -5172,31 +5198,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, diff --git a/frontend/package.json b/frontend/package.json index 9cfe212..527e118 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "typecheck": "tsc -b --noEmit", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", @@ -23,7 +24,9 @@ "@mui/icons-material": "^7.3.7", "@mui/material": "^7.3.7", "@tanstack/react-query": "^5.90.20", + "@types/dompurify": "^3.0.5", "axios": "^1.13.4", + "dompurify": "^3.3.3", "i18next": "^25.8.18", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/src/pages/ArticlePage.tsx b/frontend/src/pages/ArticlePage.tsx index c6937d1..1528d93 100644 --- a/frontend/src/pages/ArticlePage.tsx +++ b/frontend/src/pages/ArticlePage.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import DOMPurify from 'dompurify'; import { useParams, useNavigate } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; @@ -178,7 +179,7 @@ export default function ArticlePage() { lineHeight: 1.8, fontSize: '1.1rem', }} - dangerouslySetInnerHTML={{ __html: article.content }} + dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(article.content) }} /> ) : article.summary ? ( diff --git a/src/Domain/Auth/DTO/Input/RegisterInput.php b/src/Domain/Auth/DTO/Input/RegisterInput.php index d887bd0..d7cf4a2 100644 --- a/src/Domain/Auth/DTO/Input/RegisterInput.php +++ b/src/Domain/Auth/DTO/Input/RegisterInput.php @@ -15,6 +15,7 @@ public function __construct( #[Assert\NotBlank] #[Assert\Length(min: 8)] + #[Assert\PasswordStrength(minScore: Assert\PasswordStrength::STRENGTH_MEDIUM)] public string $password, ) { } diff --git a/src/Domain/Auth/Handler/RegisterHandler.php b/src/Domain/Auth/Handler/RegisterHandler.php index 59c3fd0..a5b014f 100644 --- a/src/Domain/Auth/Handler/RegisterHandler.php +++ b/src/Domain/Auth/Handler/RegisterHandler.php @@ -5,7 +5,6 @@ namespace App\Domain\Auth\Handler; use App\Domain\Auth\Command\RegisterCommand; -use App\Domain\Auth\Exception\EmailAlreadyExistsException; use App\Domain\Auth\Message\SendVerificationEmailMessage; use App\Domain\Auth\Port\UserRepositoryInterface; use App\Entity\User; @@ -21,12 +20,14 @@ public function __construct( ) { } - public function __invoke(RegisterCommand $command): string + public function __invoke(RegisterCommand $command): void { $existingUser = $this->userRepository->findByEmail($command->email); if ($existingUser instanceof User) { - throw new EmailAlreadyExistsException($command->email); + // Silently ignore — do not reveal whether the email is registered. + // The caller always receives the same generic 201 response. + return; } $user = new User(); @@ -39,7 +40,5 @@ public function __invoke(RegisterCommand $command): string userId: $user->getId()->toRfc4122(), email: $user->getEmail(), )); - - return $user->getId()->toRfc4122(); } } diff --git a/src/Domain/Feed/DTO/Input/AddFeedInput.php b/src/Domain/Feed/DTO/Input/AddFeedInput.php index 036bcb3..b0799b2 100644 --- a/src/Domain/Feed/DTO/Input/AddFeedInput.php +++ b/src/Domain/Feed/DTO/Input/AddFeedInput.php @@ -4,6 +4,7 @@ namespace App\Domain\Feed\DTO\Input; +use App\Infrastructure\Validator\SsrfSafeUrl; use Symfony\Component\Validator\Constraints as Assert; final readonly class AddFeedInput @@ -11,6 +12,7 @@ public function __construct( #[Assert\NotBlank(message: 'The feed URL is required.')] #[Assert\Url(message: 'The feed URL must be a valid URL.')] + #[SsrfSafeUrl] public string $url, #[Assert\NotBlank(message: 'The category ID is required.')] diff --git a/src/Domain/Feed/MessageHandler/CrawlFeedMessageHandler.php b/src/Domain/Feed/MessageHandler/CrawlFeedMessageHandler.php index a32d42c..22b77b1 100644 --- a/src/Domain/Feed/MessageHandler/CrawlFeedMessageHandler.php +++ b/src/Domain/Feed/MessageHandler/CrawlFeedMessageHandler.php @@ -79,7 +79,7 @@ public function __invoke(CrawlFeedMessage $message): void ]); } catch (Exception $e) { $feed->setStatus(Feed::STATUS_ERROR); - $feed->setLastError($e->getMessage()); + $feed->setLastError('Feed could not be fetched. Check the URL and try again.'); $feed->setUpdatedAt(new DateTimeImmutable()); $this->feedRepository->save($feed); diff --git a/src/Infrastructure/ApiPlatform/State/ArticleStateProcessor.php b/src/Infrastructure/ApiPlatform/State/ArticleStateProcessor.php index 75e0855..707090d 100644 --- a/src/Infrastructure/ApiPlatform/State/ArticleStateProcessor.php +++ b/src/Infrastructure/ApiPlatform/State/ArticleStateProcessor.php @@ -19,6 +19,7 @@ use function str_ends_with; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * @implements ProcessorInterface @@ -35,7 +36,10 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?ArticleResource { $user = $this->security->getUser(); - assert($user instanceof User); + + if (!$user instanceof User) { + throw new AccessDeniedException(); + } $ownerId = $user->getId()->toRfc4122(); if ($operation instanceof Patch) { diff --git a/src/Infrastructure/ApiPlatform/State/ArticleStateProvider.php b/src/Infrastructure/ApiPlatform/State/ArticleStateProvider.php index 2a9fb45..fba058f 100644 --- a/src/Infrastructure/ApiPlatform/State/ArticleStateProvider.php +++ b/src/Infrastructure/ApiPlatform/State/ArticleStateProvider.php @@ -26,6 +26,7 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * @implements ProviderInterface @@ -43,7 +44,10 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): ArticleResource|PaginatedArticlesResponse { $user = $this->security->getUser(); - assert($user instanceof User); + + if (!$user instanceof User) { + throw new AccessDeniedException(); + } $ownerId = $user->getId()->toRfc4122(); if ($operation instanceof CollectionOperationInterface) { diff --git a/src/Infrastructure/ApiPlatform/State/BookmarkStateProcessor.php b/src/Infrastructure/ApiPlatform/State/BookmarkStateProcessor.php index d4d2294..eb43f73 100644 --- a/src/Infrastructure/ApiPlatform/State/BookmarkStateProcessor.php +++ b/src/Infrastructure/ApiPlatform/State/BookmarkStateProcessor.php @@ -22,6 +22,7 @@ use function is_string; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * @implements ProcessorInterface @@ -39,7 +40,10 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?BookmarkResource { $user = $this->security->getUser(); - assert($user instanceof User); + + if (!$user instanceof User) { + throw new AccessDeniedException(); + } $ownerId = $user->getId()->toRfc4122(); if ($operation instanceof Post && $data instanceof CreateBookmarkInput) { diff --git a/src/Infrastructure/ApiPlatform/State/BookmarkStateProvider.php b/src/Infrastructure/ApiPlatform/State/BookmarkStateProvider.php index 2011948..4b3dcb9 100644 --- a/src/Infrastructure/ApiPlatform/State/BookmarkStateProvider.php +++ b/src/Infrastructure/ApiPlatform/State/BookmarkStateProvider.php @@ -20,6 +20,7 @@ use function is_string; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * @implements ProviderInterface @@ -39,7 +40,10 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookmarkResource|array { $user = $this->security->getUser(); - assert($user instanceof User); + + if (!$user instanceof User) { + throw new AccessDeniedException(); + } $ownerId = $user->getId()->toRfc4122(); if ($operation instanceof CollectionOperationInterface) { diff --git a/src/Infrastructure/ApiPlatform/State/CategoryStateProcessor.php b/src/Infrastructure/ApiPlatform/State/CategoryStateProcessor.php index 06803f7..8e81cca 100644 --- a/src/Infrastructure/ApiPlatform/State/CategoryStateProcessor.php +++ b/src/Infrastructure/ApiPlatform/State/CategoryStateProcessor.php @@ -26,6 +26,7 @@ use function is_string; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * @implements ProcessorInterface @@ -44,7 +45,10 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?CategoryResource { $user = $this->security->getUser(); - assert($user instanceof User); + + if (!$user instanceof User) { + throw new AccessDeniedException(); + } $ownerId = $user->getId()->toRfc4122(); if ($operation instanceof Post && $data instanceof CreateCategoryInput) { diff --git a/src/Infrastructure/ApiPlatform/State/CategoryStateProvider.php b/src/Infrastructure/ApiPlatform/State/CategoryStateProvider.php index f765a86..43c52e1 100644 --- a/src/Infrastructure/ApiPlatform/State/CategoryStateProvider.php +++ b/src/Infrastructure/ApiPlatform/State/CategoryStateProvider.php @@ -20,6 +20,7 @@ use function is_string; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * @implements ProviderInterface @@ -39,7 +40,10 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): CategoryResource|array { $user = $this->security->getUser(); - assert($user instanceof User); + + if (!$user instanceof User) { + throw new AccessDeniedException(); + } $ownerId = $user->getId()->toRfc4122(); if ($operation instanceof CollectionOperationInterface) { diff --git a/src/Infrastructure/ApiPlatform/State/FeedStateProcessor.php b/src/Infrastructure/ApiPlatform/State/FeedStateProcessor.php index 4e00c02..83169c1 100644 --- a/src/Infrastructure/ApiPlatform/State/FeedStateProcessor.php +++ b/src/Infrastructure/ApiPlatform/State/FeedStateProcessor.php @@ -26,6 +26,7 @@ use function is_string; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * @implements ProcessorInterface @@ -44,7 +45,10 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?FeedResource { $user = $this->security->getUser(); - assert($user instanceof User); + + if (!$user instanceof User) { + throw new AccessDeniedException(); + } $ownerId = $user->getId()->toRfc4122(); if ($operation instanceof Post && $data instanceof AddFeedInput) { diff --git a/src/Infrastructure/ApiPlatform/State/FeedStateProvider.php b/src/Infrastructure/ApiPlatform/State/FeedStateProvider.php index 6b30096..3a6928e 100644 --- a/src/Infrastructure/ApiPlatform/State/FeedStateProvider.php +++ b/src/Infrastructure/ApiPlatform/State/FeedStateProvider.php @@ -21,6 +21,7 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * @implements ProviderInterface @@ -41,7 +42,10 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): FeedResource|array { $user = $this->security->getUser(); - assert($user instanceof User); + + if (!$user instanceof User) { + throw new AccessDeniedException(); + } $ownerId = $user->getId()->toRfc4122(); if ($operation instanceof CollectionOperationInterface) { diff --git a/src/Infrastructure/Validator/SsrfSafeUrl.php b/src/Infrastructure/Validator/SsrfSafeUrl.php new file mode 100644 index 0000000..c675a3f --- /dev/null +++ b/src/Infrastructure/Validator/SsrfSafeUrl.php @@ -0,0 +1,14 @@ + + */ + private const array BLOCKED_RANGES = [ + ['0.0.0.0', '0.255.255.255'], + ['10.0.0.0', '10.255.255.255'], + ['100.64.0.0', '100.127.255.255'], + ['127.0.0.0', '127.255.255.255'], + ['169.254.0.0', '169.254.255.255'], + ['172.16.0.0', '172.31.255.255'], + ['192.0.0.0', '192.0.0.255'], + ['192.168.0.0', '192.168.255.255'], + ['198.18.0.0', '198.19.255.255'], + ['198.51.100.0', '198.51.100.255'], + ['203.0.113.0', '203.0.113.255'], + ['240.0.0.0', '255.255.255.255'], + ]; + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof SsrfSafeUrl) { + throw new UnexpectedTypeException($constraint, SsrfSafeUrl::class); + } + + if (null === $value || '' === $value) { + return; + } + + if (!is_string($value)) { + $this->addViolation($constraint, ''); + + return; + } + + $parsed = parse_url($value); + + if (false === $parsed) { + $this->addViolation($constraint, $value); + + return; + } + + $scheme = $parsed['scheme'] ?? ''; + + if (!in_array($scheme, ['http', 'https'], true)) { + $this->addViolation($constraint, $value); + + return; + } + + $host = $parsed['host'] ?? ''; + + if ('' === $host) { + $this->addViolation($constraint, $value); + + return; + } + + $ip = gethostbyname($host); + + if ($this->isPrivateOrReserved($ip)) { + $this->addViolation($constraint, $value); + } + } + + private function isPrivateOrReserved(string $ip): bool + { + $long = ip2long($ip); + + if (false === $long) { + return true; + } + + foreach (self::BLOCKED_RANGES as [$start, $end]) { + $startLong = ip2long($start); + $endLong = ip2long($end); + + if (false !== $startLong && false !== $endLong && $long >= $startLong && $long <= $endLong) { + return true; + } + } + + return false; + } + + private function addViolation(SsrfSafeUrl $constraint, string $value): void + { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ url }}', $value) + ->addViolation(); + } +} diff --git a/src/UI/Controller/RegisterController.php b/src/UI/Controller/RegisterController.php index 4f4ea12..d048f69 100644 --- a/src/UI/Controller/RegisterController.php +++ b/src/UI/Controller/RegisterController.php @@ -23,11 +23,14 @@ public function __construct( #[Route('/api/v1/auth/register', methods: [Request::METHOD_POST])] public function __invoke(#[MapRequestPayload] RegisterInput $input): JsonResponse { - $id = ($this->handler)(new RegisterCommand( + ($this->handler)(new RegisterCommand( email: $input->email, password: $input->password, )); - return new JsonResponse(['id' => $id], Response::HTTP_CREATED); + return new JsonResponse( + ['message' => 'If this email address is not already registered, you will receive a verification email shortly.'], + Response::HTTP_CREATED, + ); } } diff --git a/tests/Unit/Domain/Auth/Handler/RegisterHandlerTest.php b/tests/Unit/Domain/Auth/Handler/RegisterHandlerTest.php index 1230802..84ba0a8 100644 --- a/tests/Unit/Domain/Auth/Handler/RegisterHandlerTest.php +++ b/tests/Unit/Domain/Auth/Handler/RegisterHandlerTest.php @@ -5,7 +5,6 @@ namespace App\Tests\Unit\Domain\Auth\Handler; use App\Domain\Auth\Command\RegisterCommand; -use App\Domain\Auth\Exception\EmailAlreadyExistsException; use App\Domain\Auth\Handler\RegisterHandler; use App\Domain\Auth\Message\SendVerificationEmailMessage; use App\Domain\Auth\Port\UserRepositoryInterface; @@ -16,7 +15,6 @@ use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; -use Symfony\Component\Uid\Uuid; final class RegisterHandlerTest extends TestCase { @@ -41,7 +39,7 @@ protected function setUp(): void ); } - public function testInvokeWithValidDataCreatesUserAndReturnsId(): void + public function testInvokeWithValidDataCreatesUserAndDispatchesVerificationEmail(): void { $this->userRepository ->expects($this->once()) @@ -65,17 +63,13 @@ public function testInvokeWithValidDataCreatesUserAndReturnsId(): void ->with($this->isInstanceOf(SendVerificationEmailMessage::class)) ->willReturn(new Envelope(new stdClass())); - $command = new RegisterCommand( + ($this->handler)(new RegisterCommand( email: 'new@signalist.app', password: 'password123', - ); - - $result = ($this->handler)($command); - - $this->assertTrue(Uuid::isValid($result)); + )); } - public function testInvokeWithExistingEmailThrowsEmailAlreadyExistsException(): void + public function testInvokeWithExistingEmailReturnsSilentlyWithoutCreatingUser(): void { $existingUser = $this->createMock(User::class); @@ -85,7 +79,13 @@ public function testInvokeWithExistingEmailThrowsEmailAlreadyExistsException(): ->with('admin@signalist.app') ->willReturn($existingUser); - $this->expectException(EmailAlreadyExistsException::class); + $this->userRepository + ->expects($this->never()) + ->method('save'); + + $this->messageBus + ->expects($this->never()) + ->method('dispatch'); ($this->handler)(new RegisterCommand( email: 'admin@signalist.app', diff --git a/tests/Unit/Domain/Feed/MessageHandler/CrawlFeedMessageHandlerTest.php b/tests/Unit/Domain/Feed/MessageHandler/CrawlFeedMessageHandlerTest.php index e691517..ab46601 100644 --- a/tests/Unit/Domain/Feed/MessageHandler/CrawlFeedMessageHandlerTest.php +++ b/tests/Unit/Domain/Feed/MessageHandler/CrawlFeedMessageHandlerTest.php @@ -155,7 +155,7 @@ public function testInvokeWithFetchErrorSetsErrorStatusAndLogsError(): void ->willThrowException(new RuntimeException('Network error')); $feed->expects($this->once())->method('setStatus')->with(Feed::STATUS_ERROR); - $feed->expects($this->once())->method('setLastError')->with('Network error'); + $feed->expects($this->once())->method('setLastError')->with('Feed could not be fetched. Check the URL and try again.'); $feed->expects($this->once())->method('setUpdatedAt'); $this->feedRepository diff --git a/tests/Unit/Infrastructure/Validator/SsrfSafeUrlValidatorTest.php b/tests/Unit/Infrastructure/Validator/SsrfSafeUrlValidatorTest.php new file mode 100644 index 0000000..662a37c --- /dev/null +++ b/tests/Unit/Infrastructure/Validator/SsrfSafeUrlValidatorTest.php @@ -0,0 +1,126 @@ +validator = new SsrfSafeUrlValidator(); + $this->context = $this->createMock(ExecutionContext::class); + $this->validator->initialize($this->context); + } + + public function testValidateWithNullValueDoesNothing(): void + { + $this->context->expects($this->never())->method('buildViolation'); + + $this->validator->validate(null, new SsrfSafeUrl()); + } + + public function testValidateWithEmptyStringDoesNothing(): void + { + $this->context->expects($this->never())->method('buildViolation'); + + $this->validator->validate('', new SsrfSafeUrl()); + } + + public function testValidateWithPublicHttpsUrlAddsNoViolation(): void + { + $this->context->expects($this->never())->method('buildViolation'); + + // Use a public IP directly to avoid DNS resolution in test environment + $this->validator->validate('https://8.8.8.8/rss', new SsrfSafeUrl()); + } + + public function testValidateWithPublicHttpUrlAddsNoViolation(): void + { + $this->context->expects($this->never())->method('buildViolation'); + + // Use a public IP directly to avoid DNS resolution in test environment + $this->validator->validate('http://1.1.1.1/rss', new SsrfSafeUrl()); + } + + public function testValidateWithFileSchemeAddsViolation(): void + { + $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class); + $violationBuilder->method('setParameter')->willReturnSelf(); + $violationBuilder->expects($this->once())->method('addViolation'); + + $this->context + ->expects($this->once()) + ->method('buildViolation') + ->willReturn($violationBuilder); + + $this->validator->validate('file:///etc/passwd', new SsrfSafeUrl()); + } + + public function testValidateWithLocalhostAddsViolation(): void + { + $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class); + $violationBuilder->method('setParameter')->willReturnSelf(); + $violationBuilder->expects($this->once())->method('addViolation'); + + $this->context + ->expects($this->once()) + ->method('buildViolation') + ->willReturn($violationBuilder); + + $this->validator->validate('http://127.0.0.1/internal', new SsrfSafeUrl()); + } + + public function testValidateWithPrivateIpRangeAddsViolation(): void + { + $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class); + $violationBuilder->method('setParameter')->willReturnSelf(); + $violationBuilder->expects($this->once())->method('addViolation'); + + $this->context + ->expects($this->once()) + ->method('buildViolation') + ->willReturn($violationBuilder); + + $this->validator->validate('http://192.168.1.1/feed', new SsrfSafeUrl()); + } + + public function testValidateWithCloudMetadataIpAddsViolation(): void + { + $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class); + $violationBuilder->method('setParameter')->willReturnSelf(); + $violationBuilder->expects($this->once())->method('addViolation'); + + $this->context + ->expects($this->once()) + ->method('buildViolation') + ->willReturn($violationBuilder); + + $this->validator->validate('http://169.254.169.254/latest/meta-data/', new SsrfSafeUrl()); + } + + public function testValidateWithInternalNetworkRangeAddsViolation(): void + { + $violationBuilder = $this->createMock(ConstraintViolationBuilderInterface::class); + $violationBuilder->method('setParameter')->willReturnSelf(); + $violationBuilder->expects($this->once())->method('addViolation'); + + $this->context + ->expects($this->once()) + ->method('buildViolation') + ->willReturn($violationBuilder); + + $this->validator->validate('http://10.0.0.1/feed', new SsrfSafeUrl()); + } +}