Skip to content

Commit 1279d4a

Browse files
authored
WIP: rewrite using classes
Signed-off-by: GitHub <[email protected]>
1 parent 932f18b commit 1279d4a

16 files changed

+553
-77
lines changed

packages/http-helmet/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
"import": "./dist/react.js",
4040
"require": "./dist/react.cjs"
4141
},
42+
"./v2": {
43+
"import": "./dist/v2.js",
44+
"require": "./dist/v2.cjs"
45+
},
4246
"./package.json": "./package.json"
4347
},
4448
"files": [
@@ -47,7 +51,9 @@
4751
"package.json"
4852
],
4953
"dependencies": {
54+
"content-security-policy-parser": "^0.6.0",
5055
"change-case": "^5.4.4",
56+
"ts-extras": "^0.14.0",
5157
"type-fest": "^4.41.0"
5258
},
5359
"peerDependencies": {
@@ -72,7 +78,6 @@
7278
"@types/react": "^19.1.8",
7379
"@types/react-dom": "^19.1.6",
7480
"@vitest/coverage-v8": "catalog:",
75-
"content-security-policy-parser": "^0.6.0",
7681
"happy-dom": "^18.0.1",
7782
"react": "^19.1.0",
7883
"react-dom": "^19.1.0",

packages/http-helmet/src/index.spec.ts

Lines changed: 0 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
createContentSecurityPolicy,
33
createSecureHeaders,
44
HASH,
5-
mergeHeaders,
65
NONCE,
76
NONE,
87
REPORT_SAMPLE,
@@ -164,66 +163,6 @@ it("throws an error if the value is reserved", () => {
164163
);
165164
});
166165

167-
describe("mergeHeaders", () => {
168-
it("merges headers", () => {
169-
let secureHeaders = createSecureHeaders({
170-
"Content-Security-Policy": { "default-src": ["'self'"] },
171-
});
172-
173-
let responseHeaders = new Headers({
174-
"Content-Type": "text/html",
175-
"x-foo": "bar",
176-
});
177-
178-
let merged = mergeHeaders(responseHeaders, secureHeaders);
179-
180-
expect(merged.get("Content-Type")).toBe("text/html");
181-
expect(merged.get("x-foo")).toBe("bar");
182-
expect(merged.get("Content-Security-Policy")).toBe("default-src 'self'");
183-
});
184-
185-
it("throws if the argument is not an object", () => {
186-
// @ts-expect-error
187-
expect(() => mergeHeaders("foo")).toThrowErrorMatchingInlineSnapshot(
188-
`[TypeError: All arguments must be of type object]`,
189-
);
190-
});
191-
192-
it("overrides existing headers", () => {
193-
let secureHeaders = createSecureHeaders({
194-
"Content-Security-Policy": { "default-src": ["'self'"] },
195-
});
196-
197-
let responseHeaders = new Headers({
198-
"Content-Security-Policy": "default-src 'none'",
199-
});
200-
201-
let merged1 = mergeHeaders(responseHeaders, secureHeaders);
202-
let merged2 = mergeHeaders(secureHeaders, responseHeaders);
203-
204-
expect(merged1.get("Content-Security-Policy")).toBe("default-src 'self'");
205-
expect(merged2.get("Content-Security-Policy")).toBe("default-src 'none'");
206-
});
207-
208-
it('keeps all "Set-Cookie" headers', () => {
209-
let headers1 = new Headers({ "Set-Cookie": "foo=bar" });
210-
let headers2 = new Headers({ "Set-Cookie": "baz=qux" });
211-
212-
let merged = mergeHeaders(headers1, headers2);
213-
214-
expect(merged.getSetCookie()).toStrictEqual(["foo=bar", "baz=qux"]);
215-
});
216-
217-
it('merged different cased "Set-Cookie" headers"', () => {
218-
let headers1 = new Headers({ "set-cookie": "foo=bar" });
219-
let headers2 = new Headers({ "Set-Cookie": "baz=qux" });
220-
221-
let merged = mergeHeaders(headers1, headers2);
222-
223-
expect(merged.getSetCookie()).toStrictEqual(["foo=bar", "baz=qux"]);
224-
});
225-
});
226-
227166
it("allows mixing camel and kebab case for CSP keys", () => {
228167
let secureHeaders = createSecureHeaders({
229168
"Content-Security-Policy": {

packages/http-helmet/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export {
1313
mergeHeaders,
1414
} from "./utils";
1515

16+
export type {
17+
Algorithm,
18+
HashSource,
19+
NonceSource,
20+
QuotedSource,
21+
} from "./utils.ts";
22+
1623
export {
1724
createContentSecurityPolicy,
1825
createPermissionsPolicy,

packages/http-helmet/src/rules/content-security-policy.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import type { KebabCasedProperties, LiteralUnion } from "type-fest";
33
import type { QuotedSource } from "../utils.js";
44
import { isQuoted } from "../utils.js";
55

6-
type CspSetting = Array<LiteralUnion<QuotedSource, string> | undefined>;
6+
export type CspSetting = Array<LiteralUnion<QuotedSource, string> | undefined>;
77

8-
type ContentSecurityPolicyCamel = {
8+
export type ContentSecurityPolicyCamel = {
99
childSrc?: CspSetting;
1010
connectSrc?: CspSetting;
1111
defaultSrc?: CspSetting;
@@ -36,18 +36,18 @@ type ContentSecurityPolicyCamel = {
3636
upgradeInsecureRequests?: boolean;
3737
};
3838

39-
type ContentSecurityPolicyKebab =
39+
export type ContentSecurityPolicyKebab =
4040
KebabCasedProperties<ContentSecurityPolicyCamel>;
4141

42-
type ContentSecurityPolicy =
42+
export type ContentSecurityPolicy =
4343
| ContentSecurityPolicyCamel
4444
| ContentSecurityPolicyKebab;
4545

4646
export type PublicContentSecurityPolicy = Parameters<
4747
typeof createContentSecurityPolicy
4848
>[0];
4949

50-
let reservedCSPKeywords = new Set([
50+
export let reservedCSPKeywords = new Set([
5151
"self",
5252
"none",
5353
"unsafe-inline",

packages/http-helmet/src/rules/permissions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type PermissionsPolicy = {
4242
[key in KnownPermissions]?: Array<string>;
4343
};
4444

45-
const reservedPermissionKeywords = new Set(["self", "*"]);
45+
export const reservedPermissionKeywords = new Set(["self", "*"]);
4646

4747
export function createPermissionsPolicy(features: PermissionsPolicy): string {
4848
return Object.entries(features)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createSecureHeaders } from "./helmet";
3+
import { mergeHeaders } from "./utils";
4+
import { SecurityHeaders } from "./v2";
5+
6+
describe("mergeHeaders", () => {
7+
it("merges headers", () => {
8+
let secureHeaders = createSecureHeaders({
9+
"Content-Security-Policy": { "default-src": ["'self'"] },
10+
});
11+
12+
let responseHeaders = new Headers({
13+
"Content-Type": "text/html",
14+
"x-foo": "bar",
15+
});
16+
17+
let merged = mergeHeaders(responseHeaders, secureHeaders);
18+
19+
expect(merged.get("Content-Type")).toBe("text/html");
20+
expect(merged.get("x-foo")).toBe("bar");
21+
expect(merged.get("Content-Security-Policy")).toBe("default-src 'self'");
22+
});
23+
24+
it("throws if the argument is not an object", () => {
25+
// @ts-expect-error
26+
expect(() => mergeHeaders("foo")).toThrowErrorMatchingInlineSnapshot(
27+
`[TypeError: All arguments must be of type object]`,
28+
);
29+
});
30+
31+
it("overrides existing headers", () => {
32+
let secureHeaders = createSecureHeaders({
33+
"Content-Security-Policy": { "default-src": ["'self'"] },
34+
});
35+
36+
let responseHeaders = new Headers({
37+
"Content-Security-Policy": "default-src 'none'",
38+
});
39+
40+
let merged1 = mergeHeaders(responseHeaders, secureHeaders);
41+
let merged2 = mergeHeaders(secureHeaders, responseHeaders);
42+
43+
expect(merged1.get("Content-Security-Policy")).toBe("default-src 'self'");
44+
expect(merged2.get("Content-Security-Policy")).toBe("default-src 'none'");
45+
});
46+
47+
it('keeps all "Set-Cookie" headers', () => {
48+
let headers1 = new Headers({ "Set-Cookie": "foo=bar" });
49+
let headers2 = new Headers({ "Set-Cookie": "baz=qux" });
50+
51+
let merged = mergeHeaders(headers1, headers2);
52+
53+
expect(merged.getSetCookie()).toStrictEqual(["foo=bar", "baz=qux"]);
54+
});
55+
56+
it("allows using just one argument", () => {
57+
let headers = new Headers({ "Content-Type": "text/plain" });
58+
59+
let merged = mergeHeaders(headers);
60+
61+
expect(merged.get("Content-Type")).toBe("text/plain");
62+
});
63+
64+
it("merges headers when using SecurityHeaders class", () => {
65+
let secureHeaders = new SecurityHeaders({
66+
"Content-Security-Policy": { "default-src": ["'self'"] },
67+
});
68+
69+
let responseHeaders = new Headers({
70+
"Content-Type": "text/html",
71+
"x-foo": "bar",
72+
});
73+
74+
let merged = mergeHeaders(responseHeaders, secureHeaders.toHeaders());
75+
76+
expect(merged.get("Content-Type")).toBe("text/html");
77+
expect(merged.get("x-foo")).toBe("bar");
78+
expect(merged.get("Content-Security-Policy")).toBe("default-src 'self'");
79+
});
80+
});

packages/http-helmet/src/utils.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ export function isQuoted(value: string): boolean {
22
return /^".*"$/.test(value);
33
}
44

5-
type Algorithm = "sha256" | "sha384" | "sha512";
5+
export type Algorithm = "sha256" | "sha384" | "sha512";
66

7-
type HashSource = `'${Algorithm}-${string}'`;
7+
export type HashSource = `'${Algorithm}-${string}'`;
8+
9+
export type NonceSource = `'nonce-${string}'`;
810

911
export type QuotedSource =
1012
| "'self'"
@@ -26,7 +28,8 @@ export let WASM_UNSAFE_EVAL = "'wasm-unsafe-eval'" as const;
2628
export let UNSAFE_HASHES = "'unsafe-hashes'" as const;
2729
export let STRICT_DYNAMIC = "'strict-dynamic'" as const;
2830
export let REPORT_SAMPLE = "'report-sample'" as const;
29-
export function NONCE(nonce: string): `'nonce-${string}'` {
31+
32+
export function NONCE(nonce: string): NonceSource {
3033
return `'nonce-${nonce}'`;
3134
}
3235
export function HASH(algorithm: Algorithm, hash: string): HashSource {
@@ -37,7 +40,9 @@ function isObject(value: unknown) {
3740
return value !== null && typeof value === "object";
3841
}
3942

40-
export function mergeHeaders(...sources: HeadersInit[]): Headers {
43+
export function mergeHeaders(
44+
...sources: [HeadersInit, ...HeadersInit[]]
45+
): Headers {
4146
let result = new Headers();
4247

4348
for (let source of sources) {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { SELF } from "#src/utils.ts";
2+
import { expect, it } from "vitest";
3+
import { ContentSecurityPolicy } from "./content-security-policy";
4+
5+
it("creates a CSP policy with a single directive", () => {
6+
let csp = new ContentSecurityPolicy();
7+
csp.set("default-src", [SELF]);
8+
expect(csp.toString()).toBe("default-src 'self'");
9+
});
10+
11+
it("creates a CSP policy with no directives", () => {
12+
let csp = new ContentSecurityPolicy();
13+
expect(csp.toString()).toBe("");
14+
});
15+
16+
it("creates a CSP policy with multiple directives", () => {
17+
let csp = new ContentSecurityPolicy();
18+
csp.set("default-src", [SELF]);
19+
csp.set("script-src", ["https://example.com"]);
20+
expect(csp.toString()).toBe(
21+
"default-src 'self'; script-src https://example.com",
22+
);
23+
});
24+
25+
it("creates a CSP policy with multiple values in a directive", () => {
26+
let csp = new ContentSecurityPolicy();
27+
csp.set("script-src", [SELF, "https://example.com"]);
28+
expect(csp.toString()).toBe("script-src 'self' https://example.com");
29+
});
30+
31+
it("can parse a CSP string", () => {
32+
let csp = new ContentSecurityPolicy();
33+
csp.parse("default-src 'self'; script-src https://example.com");
34+
expect(csp.get("default-src")).toEqual(["'self'"]);
35+
expect(csp.get("script-src")).toEqual(["https://example.com"]);
36+
});
37+
38+
it("handles `upgrade-insecure-requests` directive", () => {
39+
let csp = new ContentSecurityPolicy();
40+
csp.set("upgrade-insecure-requests", []);
41+
expect(csp.toString()).toBe("upgrade-insecure-requests");
42+
});
43+
44+
it("handles upgradeInsecureRequests method", () => {
45+
let csp = new ContentSecurityPolicy();
46+
csp.upgradeInsecureRequests();
47+
expect(csp.toString()).toBe("upgrade-insecure-requests");
48+
});
49+
50+
it("can call `append` multiple times for the same key", () => {
51+
let csp = new ContentSecurityPolicy();
52+
csp.append("default-src", [SELF]);
53+
csp.append("default-src", ["https://example.com"]);
54+
expect(csp.toString()).toBe("default-src 'self' https://example.com");
55+
});
56+
57+
it("can create csp with predefined directives", () => {
58+
let csp = new ContentSecurityPolicy({
59+
"default-src": [SELF],
60+
"script-src": ["https://example.com"],
61+
});
62+
expect(csp.toString()).toBe(
63+
"default-src 'self'; script-src https://example.com",
64+
);
65+
});
66+
67+
it("can create csp with predefined policy string", () => {
68+
let csp = new ContentSecurityPolicy(
69+
"default-src 'self'; script-src https://example.com",
70+
);
71+
expect(csp.toString()).toBe(
72+
"default-src 'self'; script-src https://example.com",
73+
);
74+
});
75+
76+
it("allows and filters out `undefined` values", () => {
77+
let csp = new ContentSecurityPolicy({
78+
"connect-src": [undefined, "'self'", undefined],
79+
});
80+
81+
expect(csp.toString()).toMatchInlineSnapshot(`"connect-src 'self'"`);
82+
});
83+
84+
it("allows there to be no define values for a csp key", () => {
85+
let csp = new ContentSecurityPolicy({
86+
"base-uri": [undefined],
87+
"default-src": ["'none'"],
88+
});
89+
expect(csp.toString()).toBe("default-src 'none'");
90+
});
91+
92+
it("throws an error if the value is reserved, but not properly quoted", () => {
93+
expect(
94+
() =>
95+
new ContentSecurityPolicy({
96+
"default-src": ["self", "https://example.com"],
97+
}),
98+
).toThrowErrorMatchingInlineSnapshot(
99+
`[ContentSecurityPolicyError: reserved keyword self must be quoted.]`,
100+
);
101+
});

0 commit comments

Comments
 (0)