Skip to content

Commit c71cdef

Browse files
committed
feat: remove helmet-csp in favor of inline implementation
Credit goes to the original implementation. However, the published package contains broken exports making it hard to use the package
1 parent 6086453 commit c71cdef

File tree

6 files changed

+268
-21
lines changed

6 files changed

+268
-21
lines changed

package.json

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,20 @@
5555
"@japa/runner": "^4.4.0",
5656
"@poppinss/ts-exec": "^1.4.1",
5757
"@release-it/conventional-changelog": "^10.0.1",
58-
"@types/node": "^24.3.1",
58+
"@types/node": "^24.5.2",
5959
"c8": "^10.1.3",
6060
"copyfiles": "^2.4.1",
6161
"cross-env": "^10.0.0",
62-
"del-cli": "^6.0.0",
62+
"del-cli": "^7.0.0",
6363
"edge.js": "^6.3.0",
64-
"eslint": "^9.35.0",
64+
"eslint": "^9.36.0",
6565
"prettier": "^3.6.2",
66-
"release-it": "^19.0.4",
66+
"release-it": "^19.0.5",
6767
"tsup": "^8.5.0",
6868
"typescript": "^5.9.2"
6969
},
7070
"dependencies": {
71-
"@poppinss/utils": "^7.0.0-next.3",
72-
"csrf": "^3.1.0",
73-
"helmet-csp": "^4.0.0"
71+
"csrf": "^3.1.0"
7472
},
7573
"peerDependencies": {
7674
"@adonisjs/core": "^7.0.0-next.0",

src/guards/csp/keywords.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import type { ServerResponse, IncomingMessage } from 'node:http'
1111

1212
import type { ValueOf } from '../../types.ts'
13-
import type { ContentSecurityPolicyOptions } from '../../helmet-csp.cts'
13+
import type { ContentSecurityPolicyOptions } from '../../helmet_csp.ts'
1414

1515
/**
1616
* A collection of CSP keywords that are resolved to actual values

src/guards/csp/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { type HttpContext } from '@adonisjs/core/http'
1616
import { noop } from '../../noop.ts'
1717
import { cspKeywords } from './keywords.ts'
1818
import type { CspOptions } from '../../types.ts'
19-
import { helmetMiddleware } from '../../helmet-csp.cts'
19+
import helmetMiddleware from '../../helmet_csp.ts'
2020

2121
/**
2222
* Registering nonce keyword

src/helmet-csp.cts

Lines changed: 0 additions & 11 deletions
This file was deleted.

src/helmet_csp.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*
2+
* @adonisjs/shield
3+
*
4+
* (c) AdonisJS
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
/**
11+
* CREDITS
12+
* https://github.com/helmetjs/helmet/blob/main/middlewares/content-security-policy/index.ts
13+
*/
14+
15+
import type { IncomingMessage, ServerResponse } from 'node:http'
16+
17+
type ContentSecurityPolicyDirectiveValueFunction = (
18+
req: IncomingMessage,
19+
res: ServerResponse
20+
) => string
21+
22+
type ContentSecurityPolicyDirectiveValue = string | ContentSecurityPolicyDirectiveValueFunction
23+
24+
export interface ContentSecurityPolicyOptions {
25+
useDefaults?: boolean
26+
directives?: Record<
27+
string,
28+
null | Iterable<ContentSecurityPolicyDirectiveValue> | typeof dangerouslyDisableDefaultSrc
29+
>
30+
reportOnly?: boolean
31+
}
32+
33+
type NormalizedDirectives = Map<string, Iterable<ContentSecurityPolicyDirectiveValue>>
34+
35+
interface ContentSecurityPolicy {
36+
(
37+
options?: Readonly<ContentSecurityPolicyOptions>
38+
): (req: IncomingMessage, res: ServerResponse, next: (err?: Error) => void) => void
39+
getDefaultDirectives: typeof getDefaultDirectives
40+
dangerouslyDisableDefaultSrc: typeof dangerouslyDisableDefaultSrc
41+
}
42+
43+
const dangerouslyDisableDefaultSrc = Symbol('dangerouslyDisableDefaultSrc')
44+
45+
const SHOULD_BE_QUOTED: ReadonlySet<string> = new Set([
46+
'none',
47+
'self',
48+
'strict-dynamic',
49+
'report-sample',
50+
'inline-speculation-rules',
51+
'unsafe-inline',
52+
'unsafe-eval',
53+
'unsafe-hashes',
54+
'wasm-unsafe-eval',
55+
])
56+
57+
const getDefaultDirectives = (): Record<string, Iterable<ContentSecurityPolicyDirectiveValue>> => ({
58+
'default-src': ["'self'"],
59+
'base-uri': ["'self'"],
60+
'font-src': ["'self'", 'https:', 'data:'],
61+
'form-action': ["'self'"],
62+
'frame-ancestors': ["'self'"],
63+
'img-src': ["'self'", 'data:'],
64+
'object-src': ["'none'"],
65+
'script-src': ["'self'"],
66+
'script-src-attr': ["'none'"],
67+
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
68+
'upgrade-insecure-requests': [],
69+
})
70+
71+
const dashify = (str: string): string =>
72+
str.replace(/[A-Z]/g, (capitalLetter) => '-' + capitalLetter.toLowerCase())
73+
74+
const assertDirectiveValueIsValid = (directiveName: string, directiveValue: string): void => {
75+
if (/;|,/.test(directiveValue)) {
76+
throw new Error(
77+
`Content-Security-Policy received an invalid directive value for ${JSON.stringify(
78+
directiveName
79+
)}`
80+
)
81+
}
82+
}
83+
84+
const assertDirectiveValueEntryIsValid = (
85+
directiveName: string,
86+
directiveValueEntry: string
87+
): void => {
88+
if (
89+
SHOULD_BE_QUOTED.has(directiveValueEntry) ||
90+
directiveValueEntry.startsWith('nonce-') ||
91+
directiveValueEntry.startsWith('sha256-') ||
92+
directiveValueEntry.startsWith('sha384-') ||
93+
directiveValueEntry.startsWith('sha512-')
94+
) {
95+
throw new Error(
96+
`Content-Security-Policy received an invalid directive value for ${JSON.stringify(
97+
directiveName
98+
)}. ${JSON.stringify(directiveValueEntry)} should be quoted`
99+
)
100+
}
101+
}
102+
103+
function normalizeDirectives(
104+
options: Readonly<ContentSecurityPolicyOptions>
105+
): NormalizedDirectives {
106+
const defaultDirectives = getDefaultDirectives()
107+
108+
const { useDefaults = true, directives: rawDirectives = defaultDirectives } = options
109+
110+
const result: NormalizedDirectives = new Map()
111+
const directiveNamesSeen = new Set<string>()
112+
const directivesExplicitlyDisabled = new Set<string>()
113+
114+
for (const rawDirectiveName in rawDirectives) {
115+
if (!Object.hasOwn(rawDirectives, rawDirectiveName)) {
116+
continue
117+
}
118+
119+
if (rawDirectiveName.length === 0 || /[^a-zA-Z0-9-]/.test(rawDirectiveName)) {
120+
throw new Error(
121+
`Content-Security-Policy received an invalid directive name ${JSON.stringify(
122+
rawDirectiveName
123+
)}`
124+
)
125+
}
126+
127+
const directiveName = dashify(rawDirectiveName)
128+
129+
if (directiveNamesSeen.has(directiveName)) {
130+
throw new Error(
131+
`Content-Security-Policy received a duplicate directive ${JSON.stringify(directiveName)}`
132+
)
133+
}
134+
directiveNamesSeen.add(directiveName)
135+
136+
const rawDirectiveValue = rawDirectives[rawDirectiveName]
137+
let directiveValue: Iterable<ContentSecurityPolicyDirectiveValue>
138+
if (rawDirectiveValue === null) {
139+
if (directiveName === 'default-src') {
140+
throw new Error(
141+
'Content-Security-Policy needs a default-src but it was set to `null`. If you really want to disable it, set it to `contentSecurityPolicy.dangerouslyDisableDefaultSrc`.'
142+
)
143+
}
144+
directivesExplicitlyDisabled.add(directiveName)
145+
continue
146+
} else if (typeof rawDirectiveValue === 'string') {
147+
directiveValue = [rawDirectiveValue]
148+
} else if (!rawDirectiveValue) {
149+
throw new Error(
150+
`Content-Security-Policy received an invalid directive value for ${JSON.stringify(
151+
directiveName
152+
)}`
153+
)
154+
} else if (rawDirectiveValue === dangerouslyDisableDefaultSrc) {
155+
if (directiveName === 'default-src') {
156+
directivesExplicitlyDisabled.add('default-src')
157+
continue
158+
} else {
159+
throw new Error(
160+
`Content-Security-Policy: tried to disable ${JSON.stringify(
161+
directiveName
162+
)} as if it were default-src; simply omit the key`
163+
)
164+
}
165+
} else {
166+
directiveValue = rawDirectiveValue
167+
}
168+
169+
for (const element of directiveValue) {
170+
if (typeof element !== 'string') continue
171+
assertDirectiveValueIsValid(directiveName, element)
172+
assertDirectiveValueEntryIsValid(directiveName, element)
173+
}
174+
175+
result.set(directiveName, directiveValue)
176+
}
177+
178+
if (useDefaults) {
179+
Object.entries(defaultDirectives).forEach(([defaultDirectiveName, defaultDirectiveValue]) => {
180+
if (
181+
!result.has(defaultDirectiveName) &&
182+
!directivesExplicitlyDisabled.has(defaultDirectiveName)
183+
) {
184+
result.set(defaultDirectiveName, defaultDirectiveValue)
185+
}
186+
})
187+
}
188+
189+
if (!result.size) {
190+
throw new Error(
191+
'Content-Security-Policy has no directives. Either set some or disable the header'
192+
)
193+
}
194+
if (!result.has('default-src') && !directivesExplicitlyDisabled.has('default-src')) {
195+
throw new Error(
196+
'Content-Security-Policy needs a default-src but none was provided. If you really want to disable it, set it to `contentSecurityPolicy.dangerouslyDisableDefaultSrc`.'
197+
)
198+
}
199+
200+
return result
201+
}
202+
203+
function getHeaderValue(
204+
req: IncomingMessage,
205+
res: ServerResponse,
206+
normalizedDirectives: Readonly<NormalizedDirectives>
207+
): string | Error {
208+
const result: string[] = []
209+
210+
for (const [directiveName, rawDirectiveValue] of normalizedDirectives) {
211+
let directiveValue = ''
212+
for (const element of rawDirectiveValue) {
213+
if (typeof element === 'function') {
214+
const newElement = element(req, res)
215+
assertDirectiveValueEntryIsValid(directiveName, newElement)
216+
directiveValue += ' ' + newElement
217+
} else {
218+
directiveValue += ' ' + element
219+
}
220+
}
221+
222+
if (directiveValue) {
223+
assertDirectiveValueIsValid(directiveName, directiveValue)
224+
result.push(`${directiveName}${directiveValue}`)
225+
} else {
226+
result.push(directiveName)
227+
}
228+
}
229+
230+
return result.join(';')
231+
}
232+
233+
const contentSecurityPolicy: ContentSecurityPolicy = function contentSecurityPolicy(
234+
options: Readonly<ContentSecurityPolicyOptions> = {}
235+
): (req: IncomingMessage, res: ServerResponse, next: (err?: Error) => void) => void {
236+
const headerName = options.reportOnly
237+
? 'Content-Security-Policy-Report-Only'
238+
: 'Content-Security-Policy'
239+
240+
const normalizedDirectives = normalizeDirectives(options)
241+
242+
return function contentSecurityPolicyMiddleware(
243+
req: IncomingMessage,
244+
res: ServerResponse,
245+
next: (error?: Error) => void
246+
) {
247+
const result = getHeaderValue(req, res, normalizedDirectives)
248+
if (result instanceof Error) {
249+
next(result)
250+
} else {
251+
res.setHeader(headerName, result)
252+
next()
253+
}
254+
}
255+
}
256+
contentSecurityPolicy.getDefaultDirectives = getDefaultDirectives
257+
contentSecurityPolicy.dangerouslyDisableDefaultSrc = dangerouslyDisableDefaultSrc
258+
259+
export default contentSecurityPolicy
260+
export { dangerouslyDisableDefaultSrc, getDefaultDirectives }

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
import type { HttpContext } from '@adonisjs/core/http'
1111
import type { CookieOptions } from '@adonisjs/core/types/http'
12-
import type { ContentSecurityPolicyOptions } from './helmet-csp.cts'
12+
import type { ContentSecurityPolicyOptions } from './helmet_csp.ts'
1313

1414
/**
1515
* Utility type that extracts the values of all properties in an object type.

0 commit comments

Comments
 (0)