Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"typecheck": "pnpm run --recursive typecheck",
"validate": "run-p build lint format typecheck"
},
"packageManager": "pnpm@10.13.1+sha512.37ebf1a5c7a30d5fabe0c5df44ee8da4c965ca0c5af3dbab28c3a1681b70a256218d05c81c9c0dcf767ef6b8551eb5b960042b9ed4300c59242336377e01cfad",
"packageManager": "pnpm@10.14.0-0+sha512.2cd47a0cbf5f1d1de7693a88307a0ede5be94e0d3b34853d800ee775efbea0650cb562b77605ec80bc8d925f5cd27c4dfe8bb04d3a0b76090784c664450d32d6",
"dependencies": {
"@changesets/cli": "^2.29.5",
"@manypkg/get-packages": "^3.0.0",
Expand All @@ -43,6 +43,6 @@
},
"workspaces": [
"packages/*",
"examples/*"
"apps/*"
]
}
7 changes: 6 additions & 1 deletion packages/http-helmet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
"import": "./dist/react.js",
"require": "./dist/react.cjs"
},
"./v2": {
"import": "./dist/v2.js",
"require": "./dist/v2.cjs"
},
"./package.json": "./package.json"
},
"files": [
Expand All @@ -47,7 +51,9 @@
"package.json"
],
"dependencies": {
"content-security-policy-parser": "^0.6.0",
"change-case": "^5.4.4",
"ts-extras": "^0.14.0",
"type-fest": "^4.41.0"
},
"peerDependencies": {
Expand All @@ -72,7 +78,6 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitest/coverage-v8": "catalog:",
"content-security-policy-parser": "^0.6.0",
"happy-dom": "^18.0.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
Expand Down
61 changes: 0 additions & 61 deletions packages/http-helmet/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
createContentSecurityPolicy,
createSecureHeaders,
HASH,
mergeHeaders,
NONCE,
NONE,
REPORT_SAMPLE,
Expand Down Expand Up @@ -164,66 +163,6 @@ it("throws an error if the value is reserved", () => {
);
});

describe("mergeHeaders", () => {
it("merges headers", () => {
let secureHeaders = createSecureHeaders({
"Content-Security-Policy": { "default-src": ["'self'"] },
});

let responseHeaders = new Headers({
"Content-Type": "text/html",
"x-foo": "bar",
});

let merged = mergeHeaders(responseHeaders, secureHeaders);

expect(merged.get("Content-Type")).toBe("text/html");
expect(merged.get("x-foo")).toBe("bar");
expect(merged.get("Content-Security-Policy")).toBe("default-src 'self'");
});

it("throws if the argument is not an object", () => {
// @ts-expect-error
expect(() => mergeHeaders("foo")).toThrowErrorMatchingInlineSnapshot(
`[TypeError: All arguments must be of type object]`,
);
});

it("overrides existing headers", () => {
let secureHeaders = createSecureHeaders({
"Content-Security-Policy": { "default-src": ["'self'"] },
});

let responseHeaders = new Headers({
"Content-Security-Policy": "default-src 'none'",
});

let merged1 = mergeHeaders(responseHeaders, secureHeaders);
let merged2 = mergeHeaders(secureHeaders, responseHeaders);

expect(merged1.get("Content-Security-Policy")).toBe("default-src 'self'");
expect(merged2.get("Content-Security-Policy")).toBe("default-src 'none'");
});

it('keeps all "Set-Cookie" headers', () => {
let headers1 = new Headers({ "Set-Cookie": "foo=bar" });
let headers2 = new Headers({ "Set-Cookie": "baz=qux" });

let merged = mergeHeaders(headers1, headers2);

expect(merged.getSetCookie()).toStrictEqual(["foo=bar", "baz=qux"]);
});

it('merged different cased "Set-Cookie" headers"', () => {
let headers1 = new Headers({ "set-cookie": "foo=bar" });
let headers2 = new Headers({ "Set-Cookie": "baz=qux" });

let merged = mergeHeaders(headers1, headers2);

expect(merged.getSetCookie()).toStrictEqual(["foo=bar", "baz=qux"]);
});
});

it("allows mixing camel and kebab case for CSP keys", () => {
let secureHeaders = createSecureHeaders({
"Content-Security-Policy": {
Expand Down
7 changes: 7 additions & 0 deletions packages/http-helmet/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export {
mergeHeaders,
} from "./utils";

export type {
Algorithm,
HashSource,
NonceSource,
QuotedSource,
} from "./utils.ts";

export {
createContentSecurityPolicy,
createPermissionsPolicy,
Expand Down
10 changes: 5 additions & 5 deletions packages/http-helmet/src/rules/content-security-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import type { KebabCasedProperties, LiteralUnion } from "type-fest";
import type { QuotedSource } from "../utils.js";
import { isQuoted } from "../utils.js";

type CspSetting = Array<LiteralUnion<QuotedSource, string> | undefined>;
export type CspSetting = Array<LiteralUnion<QuotedSource, string> | undefined>;

type ContentSecurityPolicyCamel = {
export type ContentSecurityPolicyCamel = {
childSrc?: CspSetting;
connectSrc?: CspSetting;
defaultSrc?: CspSetting;
Expand Down Expand Up @@ -36,18 +36,18 @@ type ContentSecurityPolicyCamel = {
upgradeInsecureRequests?: boolean;
};

type ContentSecurityPolicyKebab =
export type ContentSecurityPolicyKebab =
KebabCasedProperties<ContentSecurityPolicyCamel>;

type ContentSecurityPolicy =
export type ContentSecurityPolicy =
| ContentSecurityPolicyCamel
| ContentSecurityPolicyKebab;

export type PublicContentSecurityPolicy = Parameters<
typeof createContentSecurityPolicy
>[0];

let reservedCSPKeywords = new Set([
export let reservedCSPKeywords = new Set([
"self",
"none",
"unsafe-inline",
Expand Down
2 changes: 1 addition & 1 deletion packages/http-helmet/src/rules/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export type PermissionsPolicy = {
[key in KnownPermissions]?: Array<string>;
};

const reservedPermissionKeywords = new Set(["self", "*"]);
export const reservedPermissionKeywords = new Set(["self", "*"]);

export function createPermissionsPolicy(features: PermissionsPolicy): string {
return Object.entries(features)
Expand Down
80 changes: 80 additions & 0 deletions packages/http-helmet/src/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { createSecureHeaders } from "./helmet";
import { mergeHeaders } from "./utils";
import { SecurityHeaders } from "./v2";

describe("mergeHeaders", () => {
it("merges headers", () => {
let secureHeaders = createSecureHeaders({
"Content-Security-Policy": { "default-src": ["'self'"] },
});

let responseHeaders = new Headers({
"Content-Type": "text/html",
"x-foo": "bar",
});

let merged = mergeHeaders(responseHeaders, secureHeaders);

expect(merged.get("Content-Type")).toBe("text/html");
expect(merged.get("x-foo")).toBe("bar");
expect(merged.get("Content-Security-Policy")).toBe("default-src 'self'");
});

it("throws if the argument is not an object", () => {
// @ts-expect-error
expect(() => mergeHeaders("foo")).toThrowErrorMatchingInlineSnapshot(
`[TypeError: All arguments must be of type object]`,
);
});

it("overrides existing headers", () => {
let secureHeaders = createSecureHeaders({
"Content-Security-Policy": { "default-src": ["'self'"] },
});

let responseHeaders = new Headers({
"Content-Security-Policy": "default-src 'none'",
});

let merged1 = mergeHeaders(responseHeaders, secureHeaders);
let merged2 = mergeHeaders(secureHeaders, responseHeaders);

expect(merged1.get("Content-Security-Policy")).toBe("default-src 'self'");
expect(merged2.get("Content-Security-Policy")).toBe("default-src 'none'");
});

it('keeps all "Set-Cookie" headers', () => {
let headers1 = new Headers({ "Set-Cookie": "foo=bar" });
let headers2 = new Headers({ "Set-Cookie": "baz=qux" });

let merged = mergeHeaders(headers1, headers2);

expect(merged.getSetCookie()).toStrictEqual(["foo=bar", "baz=qux"]);
});

it("allows using just one argument", () => {
let headers = new Headers({ "Content-Type": "text/plain" });

let merged = mergeHeaders(headers);

expect(merged.get("Content-Type")).toBe("text/plain");
});

it("merges headers when using SecurityHeaders class", () => {
let secureHeaders = new SecurityHeaders({
"Content-Security-Policy": { "default-src": ["'self'"] },
});

let responseHeaders = new Headers({
"Content-Type": "text/html",
"x-foo": "bar",
});

let merged = mergeHeaders(responseHeaders, secureHeaders.toHeaders());

expect(merged.get("Content-Type")).toBe("text/html");
expect(merged.get("x-foo")).toBe("bar");
expect(merged.get("Content-Security-Policy")).toBe("default-src 'self'");
});
});
13 changes: 9 additions & 4 deletions packages/http-helmet/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ export function isQuoted(value: string): boolean {
return /^".*"$/.test(value);
}

type Algorithm = "sha256" | "sha384" | "sha512";
export type Algorithm = "sha256" | "sha384" | "sha512";

type HashSource = `'${Algorithm}-${string}'`;
export type HashSource = `'${Algorithm}-${string}'`;

export type NonceSource = `'nonce-${string}'`;

export type QuotedSource =
| "'self'"
Expand All @@ -26,7 +28,8 @@ export let WASM_UNSAFE_EVAL = "'wasm-unsafe-eval'" as const;
export let UNSAFE_HASHES = "'unsafe-hashes'" as const;
export let STRICT_DYNAMIC = "'strict-dynamic'" as const;
export let REPORT_SAMPLE = "'report-sample'" as const;
export function NONCE(nonce: string): `'nonce-${string}'` {

export function NONCE(nonce: string): NonceSource {
return `'nonce-${nonce}'`;
}
export function HASH(algorithm: Algorithm, hash: string): HashSource {
Expand All @@ -37,7 +40,9 @@ function isObject(value: unknown) {
return value !== null && typeof value === "object";
}

export function mergeHeaders(...sources: HeadersInit[]): Headers {
export function mergeHeaders(
...sources: [HeadersInit, ...HeadersInit[]]
): Headers {
let result = new Headers();

for (let source of sources) {
Expand Down
101 changes: 101 additions & 0 deletions packages/http-helmet/src/v2/content-security-policy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { SELF } from "#src/utils.ts";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The import path "#src/utils.ts" uses a path alias that doesn't seem to be configured for this package in package.json. While this might work in your test environment, it can cause issues with tooling and build processes that don't know how to resolve it.

It's recommended to use a relative path to make the import resolution explicit and robust.

Suggested change
import { SELF } from "#src/utils.ts";
import { SELF } from "../utils.ts";

import { expect, it } from "vitest";
import { ContentSecurityPolicy } from "./content-security-policy";

it("creates a CSP policy with a single directive", () => {
let csp = new ContentSecurityPolicy();
csp.set("default-src", [SELF]);
expect(csp.toString()).toBe("default-src 'self'");
});

it("creates a CSP policy with no directives", () => {
let csp = new ContentSecurityPolicy();
expect(csp.toString()).toBe("");
});

it("creates a CSP policy with multiple directives", () => {
let csp = new ContentSecurityPolicy();
csp.set("default-src", [SELF]);
csp.set("script-src", ["https://example.com"]);
expect(csp.toString()).toBe(
"default-src 'self'; script-src https://example.com",
);
});

it("creates a CSP policy with multiple values in a directive", () => {
let csp = new ContentSecurityPolicy();
csp.set("script-src", [SELF, "https://example.com"]);
expect(csp.toString()).toBe("script-src 'self' https://example.com");
});

it("can parse a CSP string", () => {
let csp = new ContentSecurityPolicy();
csp.parse("default-src 'self'; script-src https://example.com");
expect(csp.get("default-src")).toEqual(["'self'"]);
expect(csp.get("script-src")).toEqual(["https://example.com"]);
});

it("handles `upgrade-insecure-requests` directive", () => {
let csp = new ContentSecurityPolicy();
csp.set("upgrade-insecure-requests", []);
expect(csp.toString()).toBe("upgrade-insecure-requests");
});

it("handles upgradeInsecureRequests method", () => {
let csp = new ContentSecurityPolicy();
csp.upgradeInsecureRequests();
expect(csp.toString()).toBe("upgrade-insecure-requests");
});

it("can call `append` multiple times for the same key", () => {
let csp = new ContentSecurityPolicy();
csp.append("default-src", [SELF]);
csp.append("default-src", ["https://example.com"]);
expect(csp.toString()).toBe("default-src 'self' https://example.com");
});

it("can create csp with predefined directives", () => {
let csp = new ContentSecurityPolicy({
"default-src": [SELF],
"script-src": ["https://example.com"],
});
expect(csp.toString()).toBe(
"default-src 'self'; script-src https://example.com",
);
});

it("can create csp with predefined policy string", () => {
let csp = new ContentSecurityPolicy(
"default-src 'self'; script-src https://example.com",
);
expect(csp.toString()).toBe(
"default-src 'self'; script-src https://example.com",
);
});

it("allows and filters out `undefined` values", () => {
let csp = new ContentSecurityPolicy({
"connect-src": [undefined, "'self'", undefined],
});

expect(csp.toString()).toMatchInlineSnapshot(`"connect-src 'self'"`);
});

it("allows there to be no define values for a csp key", () => {
let csp = new ContentSecurityPolicy({
"base-uri": [undefined],
"default-src": ["'none'"],
});
expect(csp.toString()).toBe("default-src 'none'");
});

it("throws an error if the value is reserved, but not properly quoted", () => {
expect(
() =>
new ContentSecurityPolicy({
"default-src": ["self", "https://example.com"],
}),
).toThrowErrorMatchingInlineSnapshot(
`[ContentSecurityPolicyError: reserved keyword self must be quoted.]`,
);
});
Loading
Loading