@growth-labs/conformance 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.ts CHANGED
@@ -1,21 +1,80 @@
1
- import type { Audience, HeaderMap, RenderMode } from './schema.js'
1
+ import type { AstroConfig } from 'astro'
2
+ import {
3
+ type Audience,
4
+ type HeaderMap,
5
+ type InlineStyleAttributeInventory,
6
+ inlineStyleAttributeInventorySchema,
7
+ type RenderMode,
8
+ } from './schema.js'
2
9
 
3
10
  export const REQUIRED_SECURITY_HEADERS = [
4
11
  'content-security-policy',
5
12
  'strict-transport-security',
13
+ 'cross-origin-opener-policy',
14
+ 'cross-origin-resource-policy',
6
15
  'x-frame-options',
7
16
  'x-content-type-options',
8
17
  'referrer-policy',
9
18
  'permissions-policy',
10
19
  ] as const
11
20
 
21
+ export type CrossOriginOpenerPolicy = 'same-origin' | 'same-origin-allow-popups' | 'unsafe-none'
22
+ export type CrossOriginResourcePolicy = 'same-origin' | 'same-site' | 'cross-origin'
23
+
24
+ export interface StrictTransportSecurityOptions {
25
+ maxAgeSeconds?: number
26
+ includeSubDomains?: boolean
27
+ preload?: boolean
28
+ }
29
+
30
+ export interface ContentSecurityPolicyOptions {
31
+ nonce?: string
32
+ reportUri?: string
33
+ reportTo?: string
34
+ }
35
+
12
36
  export interface HeaderPolicyOptions {
13
37
  renderMode: RenderMode
14
38
  audience: Audience
15
39
  nonce?: string
40
+ cspReportUri?: string
41
+ cspReportTo?: string
42
+ contentSecurityPolicy?: false | ContentSecurityPolicyOptions
43
+ strictTransportSecurity?: StrictTransportSecurityOptions
44
+ crossOriginOpenerPolicy?: CrossOriginOpenerPolicy
45
+ crossOriginResourcePolicy?: CrossOriginResourcePolicy
16
46
  markdownTwinUrl?: string
17
47
  }
18
48
 
49
+ export interface AstroCspPolicyOptions {
50
+ scriptSources?: readonly string[]
51
+ styleSources?: readonly string[]
52
+ inlineStyleAttributes?: InlineStyleAttributeInventory
53
+ imgSources?: readonly string[]
54
+ fontSources?: readonly string[]
55
+ connectSources?: readonly string[]
56
+ mediaSources?: readonly string[]
57
+ frameSources?: readonly string[]
58
+ }
59
+
60
+ type AstroNativeCspConfig = Exclude<
61
+ NonNullable<NonNullable<AstroConfig['security']>['csp']>,
62
+ boolean
63
+ >
64
+
65
+ type AstroCspDirective = NonNullable<AstroNativeCspConfig['directives']>[number]
66
+
67
+ export type AstroCspResource = NonNullable<
68
+ NonNullable<AstroNativeCspConfig['styleDirective']>['resources']
69
+ >[number]
70
+
71
+ export interface AstroCspConfig {
72
+ algorithm: 'SHA-256'
73
+ directives: AstroCspDirective[]
74
+ scriptDirective: { resources: string[] }
75
+ styleDirective: { resources: AstroCspResource[] }
76
+ }
77
+
19
78
  export interface CachePolicyOptions {
20
79
  renderMode: RenderMode
21
80
  audience: Audience
@@ -25,8 +84,9 @@ export function buildSharedPublicationHeaders(
25
84
  options: HeaderPolicyOptions,
26
85
  ): Record<string, string> {
27
86
  const headers: Record<string, string> = {
28
- 'Content-Security-Policy': buildContentSecurityPolicy(options.nonce),
29
- 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
87
+ 'Strict-Transport-Security': buildStrictTransportSecurity(options.strictTransportSecurity),
88
+ 'Cross-Origin-Opener-Policy': options.crossOriginOpenerPolicy ?? 'same-origin',
89
+ 'Cross-Origin-Resource-Policy': options.crossOriginResourcePolicy ?? 'same-origin',
30
90
  'X-Frame-Options': 'DENY',
31
91
  'X-Content-Type-Options': 'nosniff',
32
92
  'Referrer-Policy': 'strict-origin-when-cross-origin',
@@ -34,6 +94,15 @@ export function buildSharedPublicationHeaders(
34
94
  'accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()',
35
95
  'Cache-Control': buildCacheControl(options),
36
96
  }
97
+ if (options.contentSecurityPolicy !== false) {
98
+ headers['Content-Security-Policy'] = buildContentSecurityPolicy(
99
+ options.contentSecurityPolicy ?? {
100
+ nonce: options.nonce,
101
+ reportUri: options.cspReportUri,
102
+ reportTo: options.cspReportTo,
103
+ },
104
+ )
105
+ }
37
106
 
38
107
  if (options.markdownTwinUrl) {
39
108
  headers.Link = `<${options.markdownTwinUrl}>; rel="alternate"; type="text/markdown"`
@@ -42,15 +111,107 @@ export function buildSharedPublicationHeaders(
42
111
  return headers
43
112
  }
44
113
 
45
- export function buildContentSecurityPolicy(nonce?: string): string {
114
+ /**
115
+ * Build framework-owned CSP configuration for Astro 7.1+.
116
+ * Astro hashes every script and style it emits, while this helper keeps the
117
+ * consumer's external source inventory exact and fail-closed.
118
+ */
119
+ export function buildAstroCspConfig(options: AstroCspPolicyOptions = {}): AstroCspConfig {
120
+ const inlineStyleAttributes = options.inlineStyleAttributes
121
+ ? inlineStyleAttributeInventorySchema.parse(options.inlineStyleAttributes)
122
+ : undefined
123
+ const scriptSources = exactCspSources(options.scriptSources)
124
+ const styleSources = exactCspSources(options.styleSources)
125
+ const imgSources = exactCspSources(options.imgSources)
126
+ const fontSources = exactCspSources(options.fontSources)
127
+ const connectSources = exactCspSources(options.connectSources)
128
+ const mediaSources = exactCspSources(options.mediaSources)
129
+ const frameSources = exactCspSources(options.frameSources)
130
+
131
+ return {
132
+ algorithm: 'SHA-256',
133
+ directives: [
134
+ "default-src 'self'",
135
+ cspDirective('img-src', ["'self'", 'data:', 'blob:', ...imgSources]),
136
+ cspDirective('font-src', ["'self'", 'data:', ...fontSources]),
137
+ cspDirective('connect-src', ["'self'", ...connectSources]),
138
+ cspDirective('media-src', ["'self'", 'blob:', ...mediaSources]),
139
+ cspDirective('frame-src', ["'self'", ...frameSources]),
140
+ "object-src 'none'",
141
+ "base-uri 'self'",
142
+ "frame-ancestors 'none'",
143
+ "form-action 'self'",
144
+ 'upgrade-insecure-requests',
145
+ ],
146
+ scriptDirective: {
147
+ resources: uniqueCspSources(["'self'", ...scriptSources]),
148
+ },
149
+ styleDirective: {
150
+ resources: inlineStyleAttributes
151
+ ? [
152
+ ...uniqueCspSources(["'self'", ...styleSources]).map((resource) => ({
153
+ resource,
154
+ kind: 'element' as const,
155
+ })),
156
+ { resource: "'unsafe-inline'", kind: 'attribute' as const },
157
+ ]
158
+ : uniqueCspSources(["'self'", ...styleSources]),
159
+ },
160
+ } satisfies AstroNativeCspConfig
161
+ }
162
+
163
+ function uniqueCspSources(sources: readonly string[]): string[] {
164
+ return [...new Set(sources)]
165
+ }
166
+
167
+ function cspDirective(name: string, sources: readonly string[]): AstroCspDirective {
168
+ return `${name} ${[...new Set(sources)].join(' ')}` as AstroCspDirective
169
+ }
170
+
171
+ function exactCspSources(sources: readonly string[] | undefined): string[] {
172
+ return [...new Set(sources ?? [])].map((source) => {
173
+ const normalized = source.trim()
174
+ if (normalized === "'self'") return normalized
175
+ if (
176
+ normalized === '' ||
177
+ normalized.includes('*') ||
178
+ normalized === 'https:' ||
179
+ normalized === 'http:' ||
180
+ normalized.startsWith("'unsafe-")
181
+ ) {
182
+ throw new Error(`Astro CSP sources must be exact and cannot use ${source}`)
183
+ }
184
+
185
+ let parsed: URL
186
+ try {
187
+ parsed = new URL(normalized)
188
+ } catch {
189
+ throw new Error(`Astro CSP sources must be exact HTTPS/WSS origins: ${source}`)
190
+ }
191
+ if (
192
+ (parsed.protocol !== 'https:' && parsed.protocol !== 'wss:') ||
193
+ parsed.username !== '' ||
194
+ parsed.password !== '' ||
195
+ parsed.search !== '' ||
196
+ parsed.hash !== '' ||
197
+ (parsed.pathname !== '' && parsed.pathname !== '/')
198
+ ) {
199
+ throw new Error(`Astro CSP sources must be exact HTTPS/WSS origins: ${source}`)
200
+ }
201
+ return parsed.origin
202
+ })
203
+ }
204
+
205
+ export function buildContentSecurityPolicy(input?: string | ContentSecurityPolicyOptions): string {
206
+ const options = typeof input === 'string' ? { nonce: input } : (input ?? {})
46
207
  const scriptSources = ["'self'"]
47
208
  const styleSources = ["'self'"]
48
- if (nonce) {
49
- scriptSources.push(`'nonce-${nonce}'`)
50
- styleSources.push(`'nonce-${nonce}'`)
209
+ if (options.nonce) {
210
+ scriptSources.push(`'nonce-${options.nonce}'`)
211
+ styleSources.push(`'nonce-${options.nonce}'`)
51
212
  }
52
213
 
53
- return [
214
+ const directives = [
54
215
  "default-src 'self'",
55
216
  `script-src ${scriptSources.join(' ')}`,
56
217
  `style-src ${styleSources.join(' ')}`,
@@ -61,7 +222,28 @@ export function buildContentSecurityPolicy(nonce?: string): string {
61
222
  "base-uri 'self'",
62
223
  "frame-ancestors 'none'",
63
224
  'upgrade-insecure-requests',
64
- ].join('; ')
225
+ ]
226
+ if (options.reportTo) directives.push(`report-to ${options.reportTo}`)
227
+ if (options.reportUri) directives.push(`report-uri ${options.reportUri}`)
228
+
229
+ return directives.join('; ')
230
+ }
231
+
232
+ export function buildStrictTransportSecurity(options: StrictTransportSecurityOptions = {}): string {
233
+ const maxAgeSeconds = options.maxAgeSeconds ?? 31536000
234
+ if (!Number.isInteger(maxAgeSeconds) || maxAgeSeconds < 31536000) {
235
+ throw new Error('Strict-Transport-Security maxAgeSeconds must be at least 31536000')
236
+ }
237
+ if (options.preload === true && options.includeSubDomains !== true) {
238
+ throw new Error(
239
+ 'Strict-Transport-Security preload requires includeSubDomains after host inventory proof',
240
+ )
241
+ }
242
+
243
+ const directives = [`max-age=${maxAgeSeconds}`]
244
+ if (options.includeSubDomains === true) directives.push('includeSubDomains')
245
+ if (options.preload === true) directives.push('preload')
246
+ return directives.join('; ')
65
247
  }
66
248
 
67
249
  export function buildCacheControl({ renderMode, audience }: CachePolicyOptions): string {
@@ -73,14 +255,23 @@ export function buildCacheControl({ renderMode, audience }: CachePolicyOptions):
73
255
  }
74
256
 
75
257
  export function normalizeHeaders(headers: HeaderMap | undefined): Record<string, string> {
258
+ const entries = collectHeaders(headers)
76
259
  const normalized: Record<string, string> = {}
77
- if (!headers) return normalized
260
+ for (const [name, values] of Object.entries(entries)) {
261
+ normalized[name] = values.join(', ')
262
+ }
263
+ return normalized
264
+ }
78
265
 
266
+ export function collectHeaders(headers: HeaderMap | undefined): Record<string, string[]> {
267
+ const normalized: Record<string, string[]> = {}
268
+ if (!headers) return normalized
79
269
  for (const [name, value] of Object.entries(headers)) {
270
+ const normalizedName = name.toLowerCase()
80
271
  if (typeof value === 'string') {
81
- normalized[name.toLowerCase()] = value
272
+ normalized[normalizedName] = [...(normalized[normalizedName] ?? []), value]
82
273
  } else if (Array.isArray(value)) {
83
- normalized[name.toLowerCase()] = value.join(', ')
274
+ normalized[normalizedName] = [...(normalized[normalizedName] ?? []), ...value]
84
275
  }
85
276
  }
86
277