Skip to content
Merged
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
2 changes: 1 addition & 1 deletion BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ npm_package(
":tinyland_security",
],
package = "@tummycrypt/tinyland-security",
version = "0.2.3",
version = "0.3.0",
visibility = ["//visibility:public"],
)

Expand Down
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module(
name = "tummycrypt_tinyland_security",
version = "0.2.3",
version = "0.3.0",
compatibility_level = 1,
)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tummycrypt/tinyland-security",
"version": "0.2.3",
"version": "0.3.0",
"description": "IP hashing, encryption, risk scoring, device detection, VPN detection, rate limiting, and timing-safe utilities",
"type": "module",
"main": "./dist/index.js",
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,13 @@ export {
deactivateIpBan, getActiveBans, cleanupExpiredBans,
configureIpBanStore
} from './ipBans.js';


export {
applyDefaultSecurityHeaders,
getDefaultSecurityHeaders,
} from './securityHeaders.js';
export type {
SecurityHeaderPreset,
SecurityHeadersOptions,
} from './securityHeaders.js';
101 changes: 101 additions & 0 deletions src/securityHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Security headers for HTTP responses.
*
* Provides convenience functions for applying a known-safe set of security
* headers to a Headers object, with three presets reflecting common deployment
* postures:
*
* - `strict`: maximum restriction, suitable for admin apps and authenticated
* surfaces. Frame-ancestors none, COOP/CORP same-origin, locked-down
* Permissions-Policy, base-uri/form-action self.
* - `moderate`: relaxes CORP to same-site and X-Frame-Options to SAMEORIGIN
* for apps that embed within the same site or allow same-site framing.
* - `permissive`: minimal headers (Referrer-Policy + X-Content-Type-Options).
* For public read-only surfaces where CSP would block legitimate behavior.
*
* `applyDefaultSecurityHeaders` is idempotent — it only sets headers that
* are not already present, so consumers can override individual headers
* upstream and still benefit from the rest of the preset.
*/

export type SecurityHeaderPreset = "strict" | "moderate" | "permissive";

export interface SecurityHeadersOptions {
/** Which preset to start from. Defaults to "strict". */
preset?: SecurityHeaderPreset;
/**
* Per-header overrides applied on top of the preset.
* Pass a string to set a header value.
* Pass `null` to remove a header that the preset would otherwise apply.
*/
overrides?: Record<string, string | null>;
}

const STRICT_HEADERS: Readonly<Record<string, string>> = Object.freeze({
"Content-Security-Policy":
"base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Resource-Policy": "same-origin",
"Permissions-Policy":
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
});

const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
...STRICT_HEADERS,
"Cross-Origin-Resource-Policy": "same-site",
"X-Frame-Options": "SAMEORIGIN",
});
Comment on lines +46 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 moderate CSP still contains frame-ancestors 'none', neutralising the framing relaxation

MODERATE_HEADERS spreads STRICT_HEADERS without overriding Content-Security-Policy, so it inherits frame-ancestors 'none'. All modern browsers (Chrome, Firefox, Safari) follow CSP frame-ancestors over X-Frame-Options; the SAMEORIGIN value in this preset is therefore a no-op — framing is still universally blocked, directly contradicting the preset's documented purpose of "allow same-site framing."

The CSP must be overridden to replace frame-ancestors 'none' with frame-ancestors 'self' to match the advertised behaviour.

Suggested change
const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
...STRICT_HEADERS,
"Cross-Origin-Resource-Policy": "same-site",
"X-Frame-Options": "SAMEORIGIN",
});
const MODERATE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
...STRICT_HEADERS,
"Content-Security-Policy":
"base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'",
"Cross-Origin-Resource-Policy": "same-site",
"X-Frame-Options": "SAMEORIGIN",
});


const PERMISSIVE_HEADERS: Readonly<Record<string, string>> = Object.freeze({
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
});

const PRESETS: Readonly<Record<SecurityHeaderPreset, Readonly<Record<string, string>>>> =
Object.freeze({
strict: STRICT_HEADERS,
moderate: MODERATE_HEADERS,
permissive: PERMISSIVE_HEADERS,
});

/**
* Get the resolved security header map for a preset, with overrides applied.
*
* Returns a fresh object each call — the caller is free to mutate it.
*/
export function getDefaultSecurityHeaders(
options: SecurityHeadersOptions = {},
): Record<string, string> {
const { preset = "strict", overrides = {} } = options;
const resolved: Record<string, string> = { ...PRESETS[preset] };
for (const [key, value] of Object.entries(overrides)) {
if (value === null) {
delete resolved[key];
} else {
resolved[key] = value;
}
}
return resolved;
}

/**
* Apply security headers to a Headers object.
*
* Only sets headers that are not already present, so callers can override
* individual headers upstream (e.g. setting CSP per-route) and the preset
* fills in the rest.
*/
export function applyDefaultSecurityHeaders(
headers: Headers,
options: SecurityHeadersOptions = {},
): void {
const resolved = getDefaultSecurityHeaders(options);
for (const [key, value] of Object.entries(resolved)) {
if (!headers.has(key)) {
headers.set(key, value);
}
}
}
96 changes: 96 additions & 0 deletions tests/securityHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
applyDefaultSecurityHeaders,
getDefaultSecurityHeaders,
} from "../src/securityHeaders.js";

describe("getDefaultSecurityHeaders", () => {
it("returns the strict preset by default", () => {
const headers = getDefaultSecurityHeaders();
expect(headers["Content-Security-Policy"]).toContain("frame-ancestors 'none'");
expect(headers["X-Frame-Options"]).toBe("DENY");
expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-origin");
});

it("relaxes X-Frame-Options and CORP for moderate", () => {
const headers = getDefaultSecurityHeaders({ preset: "moderate" });
expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN");
expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-site");
expect(headers["Content-Security-Policy"]).toBeDefined();
});
Comment on lines +15 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Moderate-preset CSP test doesn't assert frame-ancestors value

The test checks only that Content-Security-Policy is defined, which passes even with frame-ancestors 'none' inherited from STRICT. A targeted assertion on the frame-ancestors token would have caught the bug above. Consider asserting that the value contains frame-ancestors 'self' and does not contain 'none'.

Suggested change
it("relaxes X-Frame-Options and CORP for moderate", () => {
const headers = getDefaultSecurityHeaders({ preset: "moderate" });
expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN");
expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-site");
expect(headers["Content-Security-Policy"]).toBeDefined();
});
it("relaxes X-Frame-Options and CORP for moderate", () => {
const headers = getDefaultSecurityHeaders({ preset: "moderate" });
expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN");
expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-site");
expect(headers["Content-Security-Policy"]).toContain("frame-ancestors 'self'");
expect(headers["Content-Security-Policy"]).not.toContain("frame-ancestors 'none'");
});


it("emits only minimal headers for permissive", () => {
const headers = getDefaultSecurityHeaders({ preset: "permissive" });
expect(headers["Referrer-Policy"]).toBe("strict-origin-when-cross-origin");
expect(headers["X-Content-Type-Options"]).toBe("nosniff");
expect(headers["Content-Security-Policy"]).toBeUndefined();
expect(headers["X-Frame-Options"]).toBeUndefined();
});

it("applies string overrides on top of preset", () => {
const headers = getDefaultSecurityHeaders({
preset: "strict",
overrides: { "X-Frame-Options": "SAMEORIGIN" },
});
expect(headers["X-Frame-Options"]).toBe("SAMEORIGIN");
expect(headers["Cross-Origin-Resource-Policy"]).toBe("same-origin");
});

it("removes headers when override is null", () => {
const headers = getDefaultSecurityHeaders({
preset: "strict",
overrides: { "Content-Security-Policy": null },
});
expect(headers["Content-Security-Policy"]).toBeUndefined();
expect(headers["X-Frame-Options"]).toBe("DENY");
});

it("returns a fresh object each call", () => {
const a = getDefaultSecurityHeaders();
const b = getDefaultSecurityHeaders();
a["Custom"] = "x";
expect(b["Custom"]).toBeUndefined();
});
});

describe("applyDefaultSecurityHeaders", () => {
it("sets all preset headers on an empty Headers object", () => {
const headers = new Headers();
applyDefaultSecurityHeaders(headers);
expect(headers.get("X-Frame-Options")).toBe("DENY");
expect(headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
});

it("does not overwrite existing headers", () => {
const headers = new Headers();
headers.set("X-Frame-Options", "SAMEORIGIN");
applyDefaultSecurityHeaders(headers);
expect(headers.get("X-Frame-Options")).toBe("SAMEORIGIN");
});

it("respects preset choice", () => {
const headers = new Headers();
applyDefaultSecurityHeaders(headers, { preset: "moderate" });
expect(headers.get("X-Frame-Options")).toBe("SAMEORIGIN");
});

it("respects overrides", () => {
const headers = new Headers();
applyDefaultSecurityHeaders(headers, {
preset: "strict",
overrides: { "X-Custom": "tinyland" },
});
expect(headers.get("X-Custom")).toBe("tinyland");
expect(headers.get("X-Frame-Options")).toBe("DENY");
});

it("removes preset headers when override is null", () => {
const headers = new Headers();
applyDefaultSecurityHeaders(headers, {
overrides: { "Content-Security-Policy": null },
});
expect(headers.has("Content-Security-Policy")).toBe(false);
expect(headers.get("X-Frame-Options")).toBe("DENY");
});
});
Loading