diff --git a/.changeset/api-csp-header-recommendation.md b/.changeset/api-csp-header-recommendation.md new file mode 100644 index 0000000..e5b6b26 --- /dev/null +++ b/.changeset/api-csp-header-recommendation.md @@ -0,0 +1,5 @@ +--- +"@cronn/playwright-utils": minor +--- + +Recommend `Content-Security-Policy: frame-ancestors 'none'` for API responses in `responseHeadersReport` diff --git a/data/test/validation/response-headers-report/api/recommends_all_headers_for_empty_headers.md b/data/test/validation/response-headers-report/api/recommends_all_headers_for_empty_headers.md index c5aa473..9038274 100644 --- a/data/test/validation/response-headers-report/api/recommends_all_headers_for_empty_headers.md +++ b/data/test/validation/response-headers-report/api/recommends_all_headers_for_empty_headers.md @@ -7,7 +7,7 @@ None ## Recommendations - `cache-control`: Missing, add header -- `content-security-policy`: Missing, add header +- `content-security-policy`: Missing, add `frame-ancestors 'none'` - `content-type`: Missing, add header - `cross-origin-opener-policy`: Missing, add `same-origin` - `cross-origin-resource-policy`: Missing, add `same-origin` diff --git a/data/test/validation/response-headers-report/api/recommends_changing_deviating_values.md b/data/test/validation/response-headers-report/api/recommends_changing_deviating_values.md index 5177183..b34b803 100644 --- a/data/test/validation/response-headers-report/api/recommends_changing_deviating_values.md +++ b/data/test/validation/response-headers-report/api/recommends_changing_deviating_values.md @@ -2,19 +2,20 @@ ## Detected Headers -| Header | Value | -| ------------------------------ | -------------------------------------------- | -| `cache-control` | `no-store` | -| `content-security-policy` | `default-src 'none'; frame-ancestors 'none'` | -| `content-type` | `application/json` | -| `cross-origin-opener-policy` | `unsafe-none` | -| `cross-origin-resource-policy` | `cross-origin` | -| `strict-transport-security` | `max-age=31536000; includeSubDomains` | -| `x-content-type-options` | `none` | -| `x-robots-tag` | `noindex` | +| Header | Value | +| ------------------------------ | ------------------------------------- | +| `cache-control` | `no-store` | +| `content-security-policy` | `default-src 'none'` | +| `content-type` | `application/json` | +| `cross-origin-opener-policy` | `unsafe-none` | +| `cross-origin-resource-policy` | `cross-origin` | +| `strict-transport-security` | `max-age=31536000; includeSubDomains` | +| `x-content-type-options` | `none` | +| `x-robots-tag` | `noindex` | ## Recommendations +- `content-security-policy`: Change value to `frame-ancestors 'none'` - `cross-origin-opener-policy`: Change value to `same-origin` - `cross-origin-resource-policy`: Change value to `same-origin` - `x-content-type-options`: Change value to `nosniff` diff --git a/src/security/response-headers-report.ts b/src/security/response-headers-report.ts index c9f22eb..e33caf2 100644 --- a/src/security/response-headers-report.ts +++ b/src/security/response-headers-report.ts @@ -41,33 +41,39 @@ const MIN_HSTS_MAX_AGE = 31_536_000; const PERMISSIONS_POLICY = "geolocation=(), microphone=(), camera=()"; -const RECOMMENDED_COMMON_HEADERS: Array = [ - { name: "cache-control" }, - { name: "content-security-policy" }, - { name: "content-type" }, - { name: "cross-origin-opener-policy", recommended: "same-origin" }, - { - name: "cross-origin-resource-policy", - recommended: "same-origin", - }, - { - name: "strict-transport-security", - recommended: `max-age=${MIN_HSTS_MAX_AGE}; includeSubDomains`, - isAccepted: isStrictHsts, - }, - { - name: "x-content-type-options", - recommended: "nosniff", - }, - { - name: "x-robots-tag", - }, -]; +const API_CSP = "frame-ancestors 'none'"; + +function recommendedCommonHeaders( + csp: RecommendedHeader, +): Array { + return [ + { name: "cache-control" }, + csp, + { name: "content-type" }, + { name: "cross-origin-opener-policy", recommended: "same-origin" }, + { + name: "cross-origin-resource-policy", + recommended: "same-origin", + }, + { + name: "strict-transport-security", + recommended: `max-age=${MIN_HSTS_MAX_AGE}; includeSubDomains`, + isAccepted: isStrictHsts, + }, + { + name: "x-content-type-options", + recommended: "nosniff", + }, + { + name: "x-robots-tag", + }, + ]; +} const DISCOURAGED_COMMON_HEADERS = ["access-control-allow-origin"]; const RECOMMENDED_DOCUMENT_HEADERS: Array = [ - ...RECOMMENDED_COMMON_HEADERS, + ...recommendedCommonHeaders({ name: "content-security-policy" }), { name: "permissions-policy", recommended: PERMISSIONS_POLICY, @@ -83,6 +89,13 @@ const RECOMMENDED_DOCUMENT_HEADERS: Array = [ }, ]; +const RECOMMENDED_API_HEADERS: Array = + recommendedCommonHeaders({ + name: "content-security-policy", + recommended: API_CSP, + isAccepted: hasApiCspDirectives, + }); + const DISCOURAGED_DOCUMENT_HEADERS = [ ...DISCOURAGED_COMMON_HEADERS, "x-xss-protection", @@ -93,7 +106,7 @@ const RECOMMENDED_HEADERS: Record< Array > = { document: RECOMMENDED_DOCUMENT_HEADERS, - api: RECOMMENDED_COMMON_HEADERS, + api: RECOMMENDED_API_HEADERS, }; const DISCOURAGED_HEADERS: Record> = { @@ -251,6 +264,18 @@ function isStrictHsts(value: string): boolean { ); } +/** + * Accepts a policy if it contains the recommended directives, so stricter + * policies with further directives like `default-src 'none'` are accepted as + * well. + */ +function hasApiCspDirectives(value: string): boolean { + const actualDirectives = normalizeValue(value).split("; "); + return normalizeValue(API_CSP) + .split("; ") + .every((directive) => actualDirectives.includes(directive)); +} + /** * Accepts a policy if it restricts at least the recommended permissions, so * stricter policies restricting further permissions are accepted as well. diff --git a/tests/response-headers-report.spec.ts b/tests/response-headers-report.spec.ts index faac55e..87164ab 100644 --- a/tests/response-headers-report.spec.ts +++ b/tests/response-headers-report.spec.ts @@ -105,6 +105,7 @@ test.describe("api", () => { await testResponseHeadersReport( { ...COMPLIANT_API_HEADERS, + "content-security-policy": "default-src 'none'", "cross-origin-opener-policy": "unsafe-none", "cross-origin-resource-policy": "cross-origin", "x-content-type-options": "none",