diff --git a/README.md b/README.md index 472f48b..d85cb02 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ import { analyzeHeaders } from '@hailbytes/security-headers'; const report = analyzeHeaders({ 'strict-transport-security': 'max-age=31536000; includeSubDomains', - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', @@ -121,7 +121,7 @@ interface HeaderFinding { | Header | Max Points | Key Checks | |---|---|---| | Strict-Transport-Security | 20 | max-age ≥ 1 year, includeSubDomains, preload | -| Content-Security-Policy | 30 | presence, no unsafe-inline/eval, no wildcards | +| Content-Security-Policy | 30 | presence, no unsafe-inline/eval, no wildcards, form-action set | | X-Frame-Options | 15 | DENY or SAMEORIGIN (or CSP frame-ancestors) | | X-Content-Type-Options | 10 | nosniff | | Referrer-Policy | 10 | strict values only | diff --git a/package-lock.json b/package-lock.json index 9c2d379..fa596dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", - "@vitest/coverage-v8": "^1.6.0", + "@vitest/coverage-v8": "^4.1.7", "typescript": "^5.4.0", "vitest": "^4.1.7" }, @@ -21,24 +21,10 @@ "node": ">=18.0.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -46,9 +32,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -56,13 +42,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -72,25 +58,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@emnapi/core": { "version": "1.10.0", @@ -126,27 +115,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -522,31 +490,34 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", - "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.1", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.4", + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.4", - "istanbul-reports": "^3.1.6", - "magic-string": "^0.30.5", - "magicast": "^0.3.3", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "test-exclude": "^6.0.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "1.6.1" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { @@ -672,22 +643,16 @@ "node": ">=12" } }, - "node_modules/balanced-match": { + "node_modules/ast-v8-to-istanbul": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" } }, "node_modules/chai": { @@ -700,13 +665,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -714,24 +672,6 @@ "dev": true, "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -787,13 +727,6 @@ } } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -809,28 +742,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -848,25 +759,6 @@ "dev": true, "license": "MIT" }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -892,21 +784,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -922,9 +799,9 @@ } }, "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, @@ -1200,15 +1077,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -1227,26 +1104,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "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": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1277,26 +1134,6 @@ ], "license": "MIT" }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1388,9 +1225,9 @@ } }, "node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -1425,25 +1262,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, - "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -1457,21 +1281,6 @@ "node": ">=8" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1480,9 +1289,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", - "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", "dev": true, "license": "MIT", "engines": { @@ -1713,13 +1522,6 @@ } } }, - "node_modules/vitest/node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1736,13 +1538,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" } } } diff --git a/package.json b/package.json index c884ac5..bd1362e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ }, "devDependencies": { "@types/node": "^20.0.0", - "@vitest/coverage-v8": "^1.6.0", + "@vitest/coverage-v8": "^4.1.7", "typescript": "^5.4.0", "vitest": "^4.1.7" }, diff --git a/src/fetch.ts b/src/fetch.ts index 240b94b..22dc35c 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -7,9 +7,14 @@ export async function fetchHeaders(url: string, options?: FetchOptions): Promise const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: controller.signal }); + // Use GET rather than HEAD: many sites (and CDNs/edge workers) emit security + // headers — notably Content-Security-Policy — only on full responses, so a + // HEAD request systematically under-reports them. We only need the headers, + // so the response body is discarded without being read. + const res = await fetch(url, { method: 'GET', redirect: 'follow', signal: controller.signal }); const headers: Record = {}; res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; }); + try { await res.body?.cancel(); } catch { /* body may be absent or already closed */ } return headers; } finally { clearTimeout(timer); diff --git a/src/rules.ts b/src/rules.ts index 98b7451..45c315e 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -10,6 +10,30 @@ function getHeader(headers: RawHeaders, name: string): string | undefined { return undefined; } +/** + * Returns the source tokens of a CSP directive, or undefined if the directive + * is absent. e.g. extractCspDirective("default-src 'self'; frame-ancestors 'none'", 'frame-ancestors') + * => ["'none'"]. + */ +function extractCspDirective(csp: string, directive: string): string[] | undefined { + const lower = directive.toLowerCase(); + for (const part of csp.split(';')) { + const tokens = part.trim().split(/\s+/); + if (tokens.length && tokens[0].toLowerCase() === lower) { + return tokens.slice(1); + } + } + return undefined; +} + +/** + * A source token offers no host restriction if it is a bare wildcard (`*`) or a + * scheme-only source (`https:`, `http:`, `data:`, etc.) that matches any host. + */ +function isPermissiveSource(token: string): boolean { + return token === '*' || /^[a-z][a-z0-9+.-]*:$/i.test(token); +} + export function checkHSTS(headers: RawHeaders): HeaderFinding { const raw = getHeader(headers, 'strict-transport-security'); if (!raw) return { @@ -26,31 +50,56 @@ export function checkHSTS(headers: RawHeaders): HeaderFinding { const maxAge = m ? parseInt(m[1], 10) : 0; if (maxAge >= 31536000) { score += 5; - } else { + } else if (maxAge > 0) { findings.push(`max-age=${maxAge} is below recommended 31536000 (1 year)`); recommendations.push('Set max-age=31536000'); - if (maxAge > 0) score += 2; + score += 2; + } else { + // max-age=0 (or absent) explicitly revokes HSTS — the browser purges the + // host from its HSTS cache and stops enforcing HTTPS. + findings.push('max-age=0 revokes HSTS — HTTPS enforcement is disabled'); + recommendations.push('Set max-age=31536000 to enforce HTTPS'); + } + // includeSubDomains / preload only add protection when HSTS is actually + // enforced; awarding their bonuses under max-age=0 would mask a revocation. + if (maxAge > 0) { + if (/includesubdomains/i.test(raw)) { score += 3; } + else { findings.push('includeSubDomains not set'); recommendations.push('Add includeSubDomains directive'); } + if (/preload/i.test(raw)) score += 2; } - if (/includesubdomains/i.test(raw)) { score += 3; } - else { findings.push('includeSubDomains not set'); recommendations.push('Add includeSubDomains directive'); } - if (/preload/i.test(raw)) score += 2; return { header: 'Strict-Transport-Security', score, maxScore: 20, status: score >= 15 ? 'good' : 'warning', raw, findings, recommendations }; } export function checkCSP(headers: RawHeaders): HeaderFinding { const raw = getHeader(headers, 'content-security-policy'); - if (!raw) return { - header: 'Content-Security-Policy', score: 0, maxScore: 30, status: 'missing', - findings: ['CSP header not present — XSS attacks are not mitigated'], - recommendations: ["Add a Content-Security-Policy header. Start with: default-src 'self'"], - }; + if (!raw) { + // A report-only policy is the standard incremental-rollout pattern. It does + // not enforce anything, so it can't earn full credit, but it is materially + // different from having no CSP at all and deserves targeted feedback. + const reportOnly = getHeader(headers, 'content-security-policy-report-only'); + if (reportOnly) return { + header: 'Content-Security-Policy', score: 10, maxScore: 30, status: 'warning', raw: reportOnly, + findings: ['CSP is report-only — violations are reported but not enforced, so it does not mitigate XSS'], + recommendations: ['Promote the policy to an enforcing Content-Security-Policy header once validated'], + }; + return { + header: 'Content-Security-Policy', score: 0, maxScore: 30, status: 'missing', + findings: ['CSP header not present — XSS attacks are not mitigated'], + recommendations: ["Add a Content-Security-Policy header. Start with: default-src 'self'"], + }; + } let score = 20; const findings: string[] = []; const recommendations: string[] = []; - if (/'unsafe-inline'/i.test(raw)) { + // 'unsafe-inline' is ignored by browsers that support 'strict-dynamic' when a + // nonce/hash is also present — that combination is the recommended Strict CSP + // pattern (the 'unsafe-inline' is a backwards-compat fallback), so don't penalize it. + const hasStrictDynamic = /'strict-dynamic'/i.test(raw); + const hasNonceOrHash = /'nonce-[^']+'/i.test(raw) || /'sha(?:256|384|512)-[^']+'/i.test(raw); + if (/'unsafe-inline'/i.test(raw) && !(hasStrictDynamic && hasNonceOrHash)) { score -= 5; findings.push("'unsafe-inline' weakens XSS protection"); recommendations.push("Remove 'unsafe-inline'; use nonces or hashes instead"); @@ -60,11 +109,27 @@ export function checkCSP(headers: RawHeaders): HeaderFinding { findings.push("'unsafe-eval' allows eval() — potential code injection"); recommendations.push("Remove 'unsafe-eval'"); } - if (/(?:default-src|script-src)\s+\*/i.test(raw)) { + // Check a wildcard (*) source anywhere in the source list of any sensitive + // fetch/navigation directive — not just as the first token of default-src/ + // script-src. img-src/style-src/font-src/media-src are intentionally omitted + // as a wildcard there is low-risk and commonly legitimate. + const wildcardDirectives = ['default-src', 'script-src', 'connect-src', 'form-action', 'frame-src', 'worker-src']; + const wildcarded = wildcardDirectives.filter(d => { + const sources = extractCspDirective(raw, d); + return sources !== undefined && sources.includes('*'); + }); + if (wildcarded.length > 0) { score -= 5; - findings.push('Wildcard (*) in default-src or script-src allows any origin'); + findings.push(`Wildcard (*) source in ${wildcarded.join(', ')} allows any origin`); recommendations.push('Replace wildcards with specific trusted domains'); } + // form-action does NOT inherit from default-src, so its absence leaves form + // submissions unrestricted even under a strict default-src 'self'. + if (extractCspDirective(raw, 'form-action') === undefined) { + score -= 3; + findings.push('No form-action directive — form submissions are unrestricted (form-action does not inherit from default-src)'); + recommendations.push("Add form-action 'self' (or 'none') to restrict where forms can submit"); + } score = Math.max(5, score); // at least 5 for having any CSP return { header: 'Content-Security-Policy', score, maxScore: 30, status: findings.length === 0 ? 'good' : 'warning', raw, findings, recommendations }; @@ -73,16 +138,31 @@ export function checkCSP(headers: RawHeaders): HeaderFinding { export function checkXFrameOptions(headers: RawHeaders): HeaderFinding { const raw = getHeader(headers, 'x-frame-options'); const csp = getHeader(headers, 'content-security-policy'); - const cspFrameAncestors = csp && /frame-ancestors/i.test(csp); + const frameAncestors = csp ? extractCspDirective(csp, 'frame-ancestors') : undefined; + const hasFrameAncestors = frameAncestors !== undefined; + // A frame-ancestors directive only protects if it actually restricts origins. + // `frame-ancestors *` / `frame-ancestors https:` allow embedding by any origin. + const frameAncestorsProtective = + hasFrameAncestors && frameAncestors!.length > 0 && !frameAncestors!.some(isPermissiveSource); - if (!raw && !cspFrameAncestors) return { + if (!raw && !hasFrameAncestors) return { header: 'X-Frame-Options', score: 0, maxScore: 15, status: 'missing', findings: ['Site may be embeddable in iframes — clickjacking risk'], recommendations: ['Add X-Frame-Options: DENY or SAMEORIGIN, or use CSP frame-ancestors'], }; - if (cspFrameAncestors) { + if (frameAncestorsProtective) { return { header: 'X-Frame-Options', score: 15, maxScore: 15, status: 'good', raw: raw ?? '(set via CSP frame-ancestors)', findings: [], recommendations: [] }; } + // frame-ancestors present but permissive (e.g. `*`). Per CSP spec it takes + // precedence over X-Frame-Options, so it cannot be relied on for protection. + if (hasFrameAncestors && !frameAncestorsProtective) { + return { + header: 'X-Frame-Options', score: 8, maxScore: 15, status: 'warning', + raw: raw ?? `(CSP frame-ancestors ${frameAncestors!.join(' ') || ''})`, + findings: ['CSP frame-ancestors allows embedding by any origin — no clickjacking protection'], + recommendations: ["Restrict frame-ancestors to 'none', 'self', or specific trusted origins"], + }; + } const val = (raw ?? '').toUpperCase().trim(); const score = (val === 'DENY' || val === 'SAMEORIGIN') ? 15 : 8; return { header: 'X-Frame-Options', score, maxScore: 15, status: score === 15 ? 'good' : 'warning', raw, @@ -110,7 +190,10 @@ export function checkReferrerPolicy(headers: RawHeaders): HeaderFinding { findings: ['Referrer-Policy not set — browser default may leak URLs in Referer header'], recommendations: ['Add Referrer-Policy: strict-origin-when-cross-origin'], }; - const strongValues = ['no-referrer', 'strict-origin', 'strict-origin-when-cross-origin', 'no-referrer-when-downgrade', 'same-origin']; + // no-referrer-when-downgrade is intentionally excluded: it sends the full URL + // (path + query) to every cross-origin HTTPS destination. It was the historical + // browser default precisely because it was the least restrictive option. + const strongValues = ['no-referrer', 'strict-origin', 'strict-origin-when-cross-origin', 'same-origin']; const isStrong = strongValues.includes(raw.toLowerCase().trim()); const score = isStrong ? 10 : 5; return { header: 'Referrer-Policy', score, maxScore: 10, status: isStrong ? 'good' : 'warning', raw, @@ -138,7 +221,7 @@ export function checkPermissionsPolicy(headers: RawHeaders): HeaderFinding { status: isGood ? "good" : "warning", raw, findings: isGood ? [] : ["Permissions-Policy does not restrict at least camera, microphone, and geolocation"], - recommendations: ["Set Permissions-Policy to camera=(), microphone=(), geolocation=(), and any other features needed by your app"] + recommendations: isGood ? [] : ["Set Permissions-Policy to camera=(), microphone=(), geolocation=(), and any other features needed by your app"] }; } @@ -146,19 +229,38 @@ export function checkCrossOriginPolicies(headers: RawHeaders): HeaderFinding { const coep = getHeader(headers, 'cross-origin-embedder-policy'); const coop = getHeader(headers, 'cross-origin-opener-policy'); const corp = getHeader(headers, 'cross-origin-resource-policy'); - const count = [coep, coop, corp].filter(Boolean).length; - const score = Math.min(count * 2, 5); - const missing = [ - !coep && 'Cross-Origin-Embedder-Policy', - !coop && 'Cross-Origin-Opener-Policy', - !corp && 'Cross-Origin-Resource-Policy', - ].filter(Boolean) as string[]; + const norm = (v?: string) => v?.toLowerCase().trim() ?? ''; + // Only restrictive values provide isolation. The defaults (unsafe-none / the + // permissive cross-origin) explicitly opt out and earn no credit. + const coepOk = ['require-corp', 'credentialless'].includes(norm(coep)); + const coopOk = ['same-origin', 'same-origin-allow-popups'].includes(norm(coop)); + const corpOk = ['same-origin', 'same-site'].includes(norm(corp)); + + const protective = [coepOk, coopOk, corpOk].filter(Boolean).length; + const score = Math.min(protective * 2, 5); + + const findings: string[] = []; + const recommendations: string[] = []; + const consider = (val: string | undefined, ok: boolean, name: string, recommended: string) => { + if (!val) { + findings.push(`${name} not set`); + recommendations.push(`Add ${name}: ${recommended}`); + } else if (!ok) { + findings.push(`${name}: '${val}' provides no cross-origin isolation`); + recommendations.push(`Set ${name}: ${recommended}`); + } + }; + consider(coep, coepOk, 'Cross-Origin-Embedder-Policy', 'require-corp'); + consider(coop, coopOk, 'Cross-Origin-Opener-Policy', 'same-origin'); + consider(corp, corpOk, 'Cross-Origin-Resource-Policy', 'same-origin'); + + const anyPresent = Boolean(coep || coop || corp); return { header: 'Cross-Origin Policies', score, maxScore: 5, - status: score >= 4 ? 'good' : score > 0 ? 'warning' : 'missing', + status: score >= 4 ? 'good' : (score > 0 || anyPresent) ? 'warning' : 'missing', raw: [coep && `COEP: ${coep}`, coop && `COOP: ${coop}`, corp && `CORP: ${corp}`].filter(Boolean).join('; ') || undefined, - findings: missing.map(h => `${h} not set`), - recommendations: missing.map(h => `Add ${h}`), + findings, + recommendations, }; } diff --git a/test/analyzer.test.ts b/test/analyzer.test.ts index b777526..ddf88f8 100644 --- a/test/analyzer.test.ts +++ b/test/analyzer.test.ts @@ -8,7 +8,7 @@ import { const STRONG_HEADERS = { 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', - 'content-security-policy': "default-src 'self'; img-src *", + 'content-security-policy': "default-src 'self'; img-src *; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', @@ -58,7 +58,13 @@ describe('analyze convenience function', () => { it('analyze with object returns same result as analyzeHeaders', async () => { const direct = analyzeHeaders(STRONG_HEADERS); const viaAnalyze = await analyze(STRONG_HEADERS); - expect(viaAnalyze).toEqual(direct); + // analyzedAt is wall-clock and is computed independently in each call, so + // compare everything else and assert both timestamps are valid ISO strings. + const { analyzedAt: directAt, ...directRest } = direct; + const { analyzedAt: viaAt, ...viaRest } = viaAnalyze; + expect(viaRest).toEqual(directRest); + expect(directAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(viaAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); it('analyze with empty object returns grade F', async () => { @@ -101,6 +107,13 @@ describe('checkHSTS', () => { expect(r.score).toBe(20); }); + it('max-age=0 is a revocation: warning, no includeSubDomains/preload bonus', () => { + const r = checkHSTS({ 'strict-transport-security': 'max-age=0; includeSubDomains; preload' }); + expect(r.score).toBe(10); + expect(r.status).toBe('warning'); + expect(r.findings.some(f => /revoke/i.test(f))).toBe(true); + }); + it('max-age between 1 and 31536000 gives partial credit', () => { const r = checkHSTS({ 'strict-transport-security': 'max-age=86400; includeSubDomains' }); expect(r.score).toBeGreaterThan(10); @@ -113,6 +126,22 @@ describe('checkCSP', () => { expect(checkCSP({}).score).toBe(0); }); + it('report-only CSP gets partial credit and a warning, not missing', () => { + const r = checkCSP({ 'content-security-policy-report-only': "default-src 'self'" }); + expect(r.status).toBe('warning'); + expect(r.score).toBe(10); + expect(r.findings.some(f => /report-only/i.test(f))).toBe(true); + }); + + it('enforcing CSP takes precedence over report-only', () => { + const r = checkCSP({ + 'content-security-policy': "default-src 'self'; form-action 'self'", + 'content-security-policy-report-only': "default-src *", + }); + expect(r.score).toBe(20); + expect(r.status).toBe('good'); + }); + it('detects unsafe-inline', () => { const r = checkCSP({ 'content-security-policy': "default-src 'self'; script-src 'unsafe-inline'" }); expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(true); @@ -135,14 +164,61 @@ describe('checkCSP', () => { expect(r.findings.some(f => f.includes('Wildcard'))).toBe(true); }); + it("does not penalize 'unsafe-inline' when 'strict-dynamic' + nonce present", () => { + const r = checkCSP({ 'content-security-policy': "script-src 'strict-dynamic' 'nonce-abc123' 'unsafe-inline' https://example.com; form-action 'self'" }); + expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(false); + expect(r.score).toBe(20); + }); + + it("still penalizes 'unsafe-inline' when 'strict-dynamic' present without nonce/hash", () => { + const r = checkCSP({ 'content-security-policy': "script-src 'strict-dynamic' 'unsafe-inline'" }); + expect(r.findings.some(f => f.includes('unsafe-inline'))).toBe(true); + }); + + it('detects wildcard in connect-src', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; connect-src *" }); + expect(r.findings.some(f => /Wildcard.*connect-src/i.test(f))).toBe(true); + expect(r.score).toBe(15); + }); + + it('detects wildcard in form-action', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action *" }); + expect(r.findings.some(f => /Wildcard.*form-action/i.test(f))).toBe(true); + }); + + it("detects mid-policy wildcard (default-src 'self' *)", () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self' *; form-action 'self'" }); + expect(r.findings.some(f => /Wildcard/i.test(f))).toBe(true); + expect(r.score).toBe(15); + }); + + it('does not flag a wildcard in low-risk img-src', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'; img-src *" }); + expect(r.findings.some(f => /Wildcard/i.test(f))).toBe(false); + expect(r.score).toBe(20); + }); + it('clean CSP returns score 20', () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'self'" }); + expect(r.score).toBe(20); + }); + + it('flags missing form-action directive', () => { const r = checkCSP({ 'content-security-policy': "default-src 'self'" }); + expect(r.findings.some(f => /form-action/i.test(f))).toBe(true); + expect(r.status).toBe('warning'); + expect(r.score).toBe(17); + }); + + it("form-action 'none' satisfies the form-action check", () => { + const r = checkCSP({ 'content-security-policy': "default-src 'self'; form-action 'none'" }); + expect(r.findings.some(f => /form-action/i.test(f))).toBe(false); expect(r.score).toBe(20); }); it('CSP with both unsafe-inline and unsafe-eval scores 10', () => { // 20 - 5 - 5 = 10, which is above the floor of 5 - const r = checkCSP({ 'content-security-policy': "default-src 'unsafe-inline' 'unsafe-eval'" }); + const r = checkCSP({ 'content-security-policy': "default-src 'unsafe-inline' 'unsafe-eval'; form-action 'self'" }); expect(r.score).toBe(10); }); @@ -188,6 +264,25 @@ describe('checkXFrameOptions', () => { expect(r.status).toBe('good'); }); + it("CSP frame-ancestors 'self' with specific origins is protective", () => { + const r = checkXFrameOptions({ 'content-security-policy': "frame-ancestors 'self' https://trusted.example" }); + expect(r.score).toBe(15); + expect(r.status).toBe('good'); + }); + + it('CSP frame-ancestors * is not protective', () => { + const r = checkXFrameOptions({ 'content-security-policy': 'frame-ancestors *' }); + expect(r.score).toBe(8); + expect(r.status).toBe('warning'); + expect(r.findings.some(f => /any origin/i.test(f))).toBe(true); + }); + + it('CSP frame-ancestors with bare scheme (https:) is not protective', () => { + const r = checkXFrameOptions({ 'content-security-policy': 'frame-ancestors https:' }); + expect(r.score).toBe(8); + expect(r.status).toBe('warning'); + }); + it('case-insensitive header name matching', () => { const r = checkXFrameOptions({ 'X-Frame-Options': 'DENY' }); expect(r.score).toBe(15); @@ -242,9 +337,10 @@ describe('checkReferrerPolicy', () => { expect(r.score).toBe(10); }); - it('no-referrer-when-downgrade is strong', () => { + it('no-referrer-when-downgrade is not strong (leaks full URL cross-origin)', () => { const r = checkReferrerPolicy({ 'referrer-policy': 'no-referrer-when-downgrade' }); - expect(r.score).toBe(10); + expect(r.score).toBe(5); + expect(r.status).toBe('warning'); }); it('unsafe-url returns score 5', () => { @@ -273,22 +369,40 @@ describe('checkPermissionsPolicy', () => { }); it('falls back to feature-policy header', () => { - const r = checkPermissionsPolicy({ 'feature-policy': 'camera *' }); + const r = checkPermissionsPolicy({ 'feature-policy': 'camera=(), microphone=(), geolocation=()' }); expect(r.score).toBe(10); expect(r.status).toBe('good'); }); it('permissions-policy takes precedence over feature-policy', () => { const r = checkPermissionsPolicy({ - 'permissions-policy': 'camera=()', + 'permissions-policy': 'camera=(), microphone=(), geolocation=()', 'feature-policy': 'camera *', }); expect(r.score).toBe(10); - expect(r.raw).toBe('camera=()'); + expect(r.raw).toBe('camera=(), microphone=(), geolocation=()'); + }); + + it('partial policy (camera only) returns warning, not full score', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=()' }); + expect(r.score).toBe(5); + expect(r.status).toBe('warning'); + }); + + it('good policy emits no recommendations', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=(), microphone=(), geolocation=()' }); + expect(r.status).toBe('good'); + expect(r.findings).toEqual([]); + expect(r.recommendations).toEqual([]); + }); + + it('warning policy still emits a recommendation', () => { + const r = checkPermissionsPolicy({ 'permissions-policy': 'camera=()' }); + expect(r.recommendations.length).toBeGreaterThan(0); }); it('case-insensitive header name matching', () => { - const r = checkPermissionsPolicy({ 'Permissions-Policy': 'camera=()' }); + const r = checkPermissionsPolicy({ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()' }); expect(r.score).toBe(10); }); }); @@ -325,6 +439,31 @@ describe('checkCrossOriginPolicies', () => { expect(r.status).toBe('good'); }); + it('permissive values (unsafe-none / cross-origin) earn no credit', () => { + const r = checkCrossOriginPolicies({ + 'cross-origin-embedder-policy': 'unsafe-none', + 'cross-origin-opener-policy': 'unsafe-none', + 'cross-origin-resource-policy': 'cross-origin', + }); + expect(r.score).toBe(0); + expect(r.status).toBe('warning'); + expect(r.findings.every(f => /no cross-origin isolation/i.test(f))).toBe(true); + }); + + it('mixed: only protective values count', () => { + const r = checkCrossOriginPolicies({ + 'cross-origin-opener-policy': 'same-origin', // protective + 'cross-origin-resource-policy': 'cross-origin', // permissive + }); + expect(r.score).toBe(2); + expect(r.status).toBe('warning'); + }); + + it('COOP same-origin-allow-popups counts as protective', () => { + const r = checkCrossOriginPolicies({ 'cross-origin-opener-policy': 'same-origin-allow-popups' }); + expect(r.score).toBe(2); + }); + it('includes raw values in output', () => { const r = checkCrossOriginPolicies({ 'cross-origin-opener-policy': 'same-origin' }); expect(r.raw).toContain('COOP: same-origin'); @@ -340,11 +479,11 @@ describe('grade boundaries', () => { it('A+ at 90%', () => { const headers = { 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', - 'permissions-policy': 'camera=()', + 'permissions-policy': 'camera=(), microphone=(), geolocation=()', 'cross-origin-embedder-policy': 'require-corp', 'cross-origin-opener-policy': 'same-origin', 'cross-origin-resource-policy': 'same-origin', @@ -359,7 +498,7 @@ describe('grade boundaries', () => { // Let's use stricter combo: missing permissions-policy too const headers = { 'strict-transport-security': 'max-age=31536000; includeSubDomains', - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin', @@ -375,7 +514,7 @@ describe('grade boundaries', () => { it('B at 60%', () => { const headers = { 'strict-transport-security': 'max-age=31536000; includeSubDomains', - 'content-security-policy': "default-src 'self'", + 'content-security-policy': "default-src 'self'; form-action 'self'", 'x-frame-options': 'DENY', 'x-content-type-options': 'nosniff', 'referrer-policy': 'strict-origin-when-cross-origin',