hazo_secure 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pubs Abayasiri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # hazo_secure
2
+
3
+ Security & compliance primitives for Node.js / Next.js apps. Four independent subpath modules — import only the ones you need.
4
+
5
+ | Subpath | Purpose |
6
+ |---|---|
7
+ | `hazo_secure/fetch` | SSRF-safe `safeFetch()` — domain allowlist, private-CIDR blocker, redirect controls |
8
+ | `hazo_secure/ratelimit` | Sliding-window + token-bucket limiter with pluggable store (in-memory default, Redis adapter) |
9
+ | `hazo_secure/crypto` | AES-256-GCM field-level encryption with key versioning and AAD support |
10
+ | `hazo_secure/gdpr` | Exporter / anonymiser registry + lifecycle orchestrator. Uses `hazo_jobs` for async export, `hazo_files` for ZIP delivery |
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install hazo_secure
16
+ ```
17
+
18
+ `hazo_logs` is a required peer dep. `hazo_jobs` and `hazo_files` are optional peers — only needed if you use `hazo_secure/gdpr`.
19
+
20
+ ## Quick start
21
+
22
+ ### SSRF-safe outbound fetch
23
+
24
+ ```ts
25
+ import { safeFetch } from "hazo_secure/fetch";
26
+
27
+ const res = await safeFetch("https://nominatim.openstreetmap.org/search", {
28
+ policy: {
29
+ allowedHosts: ["nominatim.openstreetmap.org"],
30
+ blockPrivateIps: true,
31
+ maxRedirects: 3,
32
+ timeoutMs: 5000,
33
+ },
34
+ });
35
+ ```
36
+
37
+ ### Rate limit a Next.js route
38
+
39
+ ```ts
40
+ import { checkRateLimit } from "hazo_secure/ratelimit";
41
+
42
+ export async function POST(req: Request) {
43
+ const decision = await checkRateLimit(req, { windowMs: 60_000, max: 30 });
44
+ if (!decision.allowed) return new Response("Too Many Requests", { status: 429 });
45
+ // ...
46
+ }
47
+ ```
48
+
49
+ ### Encrypt a PII field
50
+
51
+ ```ts
52
+ import { encryptField, decryptField } from "hazo_secure/crypto";
53
+
54
+ const stored = await encryptField(plaintext, { keys, aad: `user:${userId}` });
55
+ const recovered = await decryptField(stored, { keys });
56
+ ```
57
+
58
+ ### Register GDPR exporters
59
+
60
+ ```ts
61
+ import { createGdprRegistry } from "hazo_secure/gdpr";
62
+
63
+ const gdpr = createGdprRegistry();
64
+
65
+ gdpr.registerExporter({
66
+ domain: "persons",
67
+ async *export({ userId }) {
68
+ for (const row of await db.personsByUser(userId)) {
69
+ yield { filename: `persons/${row.id}.json`, content: JSON.stringify(row, null, 2) };
70
+ }
71
+ },
72
+ });
73
+ ```
74
+
75
+ ## Design
76
+
77
+ Each subpath is independent — no cross-imports between `/fetch`, `/ratelimit`, `/crypto`, `/gdpr`. The `gdpr` module *consumes* the others when a project wires them together, but the package itself doesn't entangle them.
78
+
79
+ See `CLAUDE.md` and `AGENTS.md` for architecture details.
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Source of symmetric keys for `encryptField` / `decryptField`. The current
3
+ * key is used for encryption; `byId` resolves any key (current or rotated-out)
4
+ * so we can still decrypt fields written under a previous key id.
5
+ *
6
+ * Production implementations should source keys from a KMS — see
7
+ * `EnvKeyProvider` below for the env-var fallback the local dev / single-VPS
8
+ * path uses today.
9
+ */
10
+ interface KeyProvider {
11
+ current(): Promise<{
12
+ keyId: string;
13
+ key: Uint8Array;
14
+ }>;
15
+ byId(keyId: string): Promise<Uint8Array>;
16
+ }
17
+ /**
18
+ * Envelope for an encrypted string. Stored verbatim in a JSONB column.
19
+ *
20
+ * - `v` is the envelope version — bumped if we change the algorithm or layout.
21
+ * Decryption checks this and refuses unknown versions.
22
+ * - `keyId` lets the consumer rotate keys without re-encrypting historical
23
+ * rows in bulk; the provider resolves the right key per-field.
24
+ * - `iv` is a 12-byte AES-GCM nonce (base64). MUST be unique per encryption
25
+ * under the same key — `encryptField` generates this from a CSPRNG.
26
+ * - `ct` is the ciphertext (base64).
27
+ * - `tag` is the 16-byte GCM auth tag (base64). MUST verify on decrypt.
28
+ * - `aad` is the binding string (e.g. `app_health_data:<personId>:<field>`).
29
+ * Optional but strongly recommended — without it, two ciphertexts for the
30
+ * same plaintext under the same key are confusable, and worse, ciphertexts
31
+ * can be moved between rows without detection.
32
+ */
33
+ interface EncryptedField {
34
+ v: 1;
35
+ keyId: string;
36
+ iv: string;
37
+ ct: string;
38
+ tag: string;
39
+ aad?: string;
40
+ }
41
+ declare class CryptoError extends Error {
42
+ readonly code: "unsupported_version" | "key_not_found" | "decrypt_failed" | "aad_mismatch" | "invalid_key" | "missing_env_key" | "no_keys_loaded";
43
+ constructor(message: string, code: "unsupported_version" | "key_not_found" | "decrypt_failed" | "aad_mismatch" | "invalid_key" | "missing_env_key" | "no_keys_loaded");
44
+ }
45
+ /**
46
+ * Encrypt a UTF-8 string under the current key.
47
+ * Throws `CryptoError("invalid_key")` if the key length is wrong.
48
+ */
49
+ declare function encryptField(plaintext: string, opts: {
50
+ keys: KeyProvider;
51
+ aad?: string;
52
+ }): Promise<EncryptedField>;
53
+ /**
54
+ * Decrypt a previously-encrypted field. Verifies the AAD matches what's on
55
+ * the envelope (defense-in-depth — the GCM tag already binds it, but failing
56
+ * loud here surfaces logic bugs at the call site).
57
+ */
58
+ declare function decryptField(field: EncryptedField, opts: {
59
+ keys: KeyProvider;
60
+ aad?: string;
61
+ }): Promise<string>;
62
+ /**
63
+ * Env-var-backed `KeyProvider` for the local dev + single-VPS deployment
64
+ * pattern. Reads keys from env vars matching `<PREFIX>_<KEY_ID>`, each a
65
+ * base64-encoded 32-byte AES-256 key. The current key id comes from
66
+ * `<PREFIX>_CURRENT`.
67
+ *
68
+ * Example:
69
+ * KINSTRIPE_HEALTH_KEY_CURRENT=v1
70
+ * KINSTRIPE_HEALTH_KEY_v1=<base64 of 32 bytes>
71
+ * KINSTRIPE_HEALTH_KEY_v2=<base64 of 32 bytes> # ready for rotation
72
+ *
73
+ * Rotation: write rows under the new id, leave old ids in env so historical
74
+ * rows stay decryptable. A bulk re-encrypt job (future) walks rows and
75
+ * re-encrypts under the new id at leisure.
76
+ *
77
+ * Lazy initial load + memoised so the env-var lookup runs once per process.
78
+ */
79
+ declare class EnvKeyProvider implements KeyProvider {
80
+ private readonly prefix;
81
+ private readonly cache;
82
+ constructor(prefix: string);
83
+ current(): Promise<{
84
+ keyId: string;
85
+ key: Uint8Array;
86
+ }>;
87
+ byId(keyId: string): Promise<Uint8Array>;
88
+ }
89
+ /**
90
+ * In-memory `KeyProvider` for tests + ephemeral one-shot encryption.
91
+ * Don't use in production — keys live in process memory only.
92
+ */
93
+ declare class StaticKeyProvider implements KeyProvider {
94
+ private readonly currentKeyId;
95
+ private readonly keys;
96
+ constructor(currentKeyId: string, keys: Record<string, Uint8Array>);
97
+ current(): Promise<{
98
+ keyId: string;
99
+ key: Uint8Array;
100
+ }>;
101
+ byId(keyId: string): Promise<Uint8Array>;
102
+ }
103
+ /** Helper: build a `aad` string with a stable, parser-friendly shape.
104
+ * Convention: `<table>:<entityId>:<field>` — collision-free across tables. */
105
+ declare function aadFor(table: string, entityId: string, field: string): string;
106
+
107
+ export { CryptoError, type EncryptedField, EnvKeyProvider, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
@@ -0,0 +1,147 @@
1
+ // src/crypto/index.ts
2
+ import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
3
+ var CryptoError = class extends Error {
4
+ constructor(message, code) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "CryptoError";
8
+ }
9
+ code;
10
+ };
11
+ var ALG = "aes-256-gcm";
12
+ var IV_LEN = 12;
13
+ var TAG_LEN = 16;
14
+ var KEY_LEN = 32;
15
+ function toB64(buf) {
16
+ return Buffer.from(buf).toString("base64");
17
+ }
18
+ function fromB64(s) {
19
+ return Buffer.from(s, "base64");
20
+ }
21
+ async function encryptField(plaintext, opts) {
22
+ const { keyId, key } = await opts.keys.current();
23
+ if (key.byteLength !== KEY_LEN) {
24
+ throw new CryptoError(
25
+ `Key for ${keyId} is ${key.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}.`,
26
+ "invalid_key"
27
+ );
28
+ }
29
+ const iv = randomBytes(IV_LEN);
30
+ const cipher = createCipheriv(ALG, key, iv, { authTagLength: TAG_LEN });
31
+ if (opts.aad) cipher.setAAD(Buffer.from(opts.aad, "utf8"));
32
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
33
+ const tag = cipher.getAuthTag();
34
+ return {
35
+ v: 1,
36
+ keyId,
37
+ iv: toB64(iv),
38
+ ct: toB64(ct),
39
+ tag: toB64(tag),
40
+ ...opts.aad ? { aad: opts.aad } : {}
41
+ };
42
+ }
43
+ async function decryptField(field, opts) {
44
+ if (field.v !== 1) {
45
+ throw new CryptoError(`Unknown envelope version ${field.v}`, "unsupported_version");
46
+ }
47
+ if (opts.aad !== void 0 && field.aad !== void 0 && opts.aad !== field.aad) {
48
+ throw new CryptoError("AAD mismatch \u2014 refusing to decrypt", "aad_mismatch");
49
+ }
50
+ let key;
51
+ try {
52
+ key = await opts.keys.byId(field.keyId);
53
+ } catch (err) {
54
+ throw new CryptoError(
55
+ `Key not found: ${field.keyId} (${err instanceof Error ? err.message : String(err)})`,
56
+ "key_not_found"
57
+ );
58
+ }
59
+ const iv = fromB64(field.iv);
60
+ const ct = fromB64(field.ct);
61
+ const tag = fromB64(field.tag);
62
+ const decipher = createDecipheriv(ALG, key, iv, { authTagLength: TAG_LEN });
63
+ const aad = field.aad ?? opts.aad;
64
+ if (aad !== void 0) decipher.setAAD(Buffer.from(aad, "utf8"));
65
+ decipher.setAuthTag(tag);
66
+ try {
67
+ const out = Buffer.concat([decipher.update(ct), decipher.final()]);
68
+ return out.toString("utf8");
69
+ } catch (err) {
70
+ throw new CryptoError(
71
+ `GCM auth tag verification failed (${err instanceof Error ? err.message : String(err)})`,
72
+ "decrypt_failed"
73
+ );
74
+ }
75
+ }
76
+ var EnvKeyProvider = class {
77
+ constructor(prefix) {
78
+ this.prefix = prefix;
79
+ }
80
+ prefix;
81
+ cache = /* @__PURE__ */ new Map();
82
+ async current() {
83
+ const currentVar = `${this.prefix}_CURRENT`;
84
+ const keyId = process.env[currentVar];
85
+ if (!keyId) {
86
+ throw new CryptoError(
87
+ `Missing ${currentVar} env var (expected current key id like "v1")`,
88
+ "missing_env_key"
89
+ );
90
+ }
91
+ const key = await this.byId(keyId);
92
+ return { keyId, key };
93
+ }
94
+ async byId(keyId) {
95
+ if (this.cache.has(keyId)) return this.cache.get(keyId);
96
+ const varName = `${this.prefix}_${keyId}`;
97
+ const raw = process.env[varName];
98
+ if (!raw) {
99
+ throw new CryptoError(`Missing ${varName} env var`, "key_not_found");
100
+ }
101
+ const buf = Buffer.from(raw, "base64");
102
+ if (buf.byteLength !== KEY_LEN) {
103
+ throw new CryptoError(
104
+ `${varName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}`,
105
+ "invalid_key"
106
+ );
107
+ }
108
+ this.cache.set(keyId, buf);
109
+ return buf;
110
+ }
111
+ };
112
+ var StaticKeyProvider = class {
113
+ constructor(currentKeyId, keys) {
114
+ this.currentKeyId = currentKeyId;
115
+ this.keys = keys;
116
+ }
117
+ currentKeyId;
118
+ keys;
119
+ async current() {
120
+ const key = this.keys[this.currentKeyId];
121
+ if (!key) {
122
+ throw new CryptoError(
123
+ `Static provider has no key for current id ${this.currentKeyId}`,
124
+ "no_keys_loaded"
125
+ );
126
+ }
127
+ return { keyId: this.currentKeyId, key };
128
+ }
129
+ async byId(keyId) {
130
+ const key = this.keys[keyId];
131
+ if (!key) {
132
+ throw new CryptoError(`Static provider has no key for id ${keyId}`, "key_not_found");
133
+ }
134
+ return key;
135
+ }
136
+ };
137
+ function aadFor(table, entityId, field) {
138
+ return `${table}:${entityId}:${field}`;
139
+ }
140
+ export {
141
+ CryptoError,
142
+ EnvKeyProvider,
143
+ StaticKeyProvider,
144
+ aadFor,
145
+ decryptField,
146
+ encryptField
147
+ };
@@ -0,0 +1,46 @@
1
+ interface CsrfPolicy {
2
+ cookieName?: string;
3
+ headerName?: string;
4
+ tokenLength?: number;
5
+ /** Methods that require a verified token. Defaults to mutating verbs. */
6
+ protectedMethods?: readonly string[];
7
+ /** Cookie attributes. `secure` defaults to true in production. */
8
+ cookie?: {
9
+ path?: string;
10
+ domain?: string;
11
+ sameSite?: "lax" | "strict" | "none";
12
+ secure?: boolean;
13
+ maxAgeSeconds?: number;
14
+ };
15
+ }
16
+ type CsrfErrorCode = "csrf_cookie_missing" | "csrf_header_missing" | "csrf_mismatch" | "csrf_malformed";
17
+ declare class CsrfError extends Error {
18
+ readonly code: CsrfErrorCode;
19
+ constructor(code: CsrfErrorCode, message: string);
20
+ }
21
+ /** Generate a fresh URL-safe random token of the configured length (in bytes;
22
+ * output is base64url-encoded so the string is longer than the byte count). */
23
+ declare function issueCsrfToken(policy?: CsrfPolicy): string;
24
+ /** Build the Set-Cookie header value for the CSRF cookie. Caller appends to
25
+ * the response. The cookie is intentionally NOT httpOnly — the client must
26
+ * read it to echo into the request header (double-submit pattern). */
27
+ declare function csrfCookieHeader(token: string, policy?: CsrfPolicy): string;
28
+ interface VerifyCsrfResult {
29
+ ok: boolean;
30
+ code?: CsrfErrorCode;
31
+ cookieToken?: string;
32
+ }
33
+ /** Verify a request's CSRF token using the double-submit cookie pattern.
34
+ * Reads cookie + header, requires both present and equal. Safe methods
35
+ * (GET/HEAD/OPTIONS) pass automatically. */
36
+ declare function verifyCsrf(req: Request, policy?: CsrfPolicy): VerifyCsrfResult;
37
+ interface WithCsrfOptions extends CsrfPolicy {
38
+ onReject?: (req: Request, result: VerifyCsrfResult) => Response | Promise<Response>;
39
+ /** When true (default), GET responses that lack a CSRF cookie get a fresh
40
+ * Set-Cookie appended so clients always have a token to echo. */
41
+ issueOnSafeMethods?: boolean;
42
+ }
43
+ type AnyRouteHandler = (...args: any[]) => Promise<Response>;
44
+ declare function withCsrf<H extends AnyRouteHandler>(handler: H, opts?: WithCsrfOptions): H;
45
+
46
+ export { CsrfError, type CsrfErrorCode, type CsrfPolicy, type VerifyCsrfResult, type WithCsrfOptions, csrfCookieHeader, issueCsrfToken, verifyCsrf, withCsrf };
@@ -0,0 +1,116 @@
1
+ // src/csrf/index.ts
2
+ var DEFAULT_COOKIE_NAME = "hs_csrf";
3
+ var DEFAULT_HEADER_NAME = "x-csrf-token";
4
+ var DEFAULT_TOKEN_LENGTH = 32;
5
+ var DEFAULT_PROTECTED = ["POST", "PUT", "PATCH", "DELETE"];
6
+ var CsrfError = class extends Error {
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = "CsrfError";
11
+ }
12
+ code;
13
+ };
14
+ function resolved(policy) {
15
+ return {
16
+ cookieName: policy.cookieName ?? DEFAULT_COOKIE_NAME,
17
+ headerName: (policy.headerName ?? DEFAULT_HEADER_NAME).toLowerCase(),
18
+ tokenLength: policy.tokenLength ?? DEFAULT_TOKEN_LENGTH,
19
+ protectedMethods: policy.protectedMethods ?? DEFAULT_PROTECTED
20
+ };
21
+ }
22
+ function toBase64Url(bytes) {
23
+ let bin = "";
24
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
25
+ const b64 = typeof btoa === "function" ? btoa(bin) : Buffer.from(bytes).toString("base64");
26
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
27
+ }
28
+ function issueCsrfToken(policy = {}) {
29
+ const { tokenLength } = resolved(policy);
30
+ const bytes = new Uint8Array(tokenLength);
31
+ globalThis.crypto.getRandomValues(bytes);
32
+ return toBase64Url(bytes);
33
+ }
34
+ function csrfCookieHeader(token, policy = {}) {
35
+ const { cookieName } = resolved(policy);
36
+ const c = policy.cookie ?? {};
37
+ const parts = [`${cookieName}=${token}`];
38
+ parts.push(`Path=${c.path ?? "/"}`);
39
+ if (c.domain) parts.push(`Domain=${c.domain}`);
40
+ parts.push(`SameSite=${capitaliseFirst(c.sameSite ?? "lax")}`);
41
+ const secureDefault = process.env.NODE_ENV === "production";
42
+ if (c.secure ?? secureDefault) parts.push("Secure");
43
+ if (c.maxAgeSeconds != null) parts.push(`Max-Age=${c.maxAgeSeconds}`);
44
+ return parts.join("; ");
45
+ }
46
+ function capitaliseFirst(s) {
47
+ return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
48
+ }
49
+ function parseCookieValue(cookieHeader, name) {
50
+ if (!cookieHeader) return null;
51
+ const parts = cookieHeader.split(";");
52
+ for (const part of parts) {
53
+ const eq = part.indexOf("=");
54
+ if (eq < 0) continue;
55
+ const k = part.slice(0, eq).trim();
56
+ if (k === name) return part.slice(eq + 1).trim();
57
+ }
58
+ return null;
59
+ }
60
+ function constantTimeEquals(a, b) {
61
+ if (a.length !== b.length) return false;
62
+ let diff = 0;
63
+ for (let i = 0; i < a.length; i++) {
64
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
65
+ }
66
+ return diff === 0;
67
+ }
68
+ function verifyCsrf(req, policy = {}) {
69
+ const { cookieName, headerName, protectedMethods } = resolved(policy);
70
+ if (!protectedMethods.includes(req.method.toUpperCase())) {
71
+ return { ok: true };
72
+ }
73
+ const cookieToken = parseCookieValue(req.headers.get("cookie"), cookieName);
74
+ if (!cookieToken) return { ok: false, code: "csrf_cookie_missing" };
75
+ if (!/^[A-Za-z0-9_-]+$/.test(cookieToken)) return { ok: false, code: "csrf_malformed" };
76
+ const headerToken = req.headers.get(headerName);
77
+ if (!headerToken) return { ok: false, code: "csrf_header_missing", cookieToken };
78
+ if (!constantTimeEquals(cookieToken, headerToken)) {
79
+ return { ok: false, code: "csrf_mismatch", cookieToken };
80
+ }
81
+ return { ok: true, cookieToken };
82
+ }
83
+ function withCsrf(handler, opts = {}) {
84
+ const { cookieName, protectedMethods } = resolved(opts);
85
+ const issueOnSafe = opts.issueOnSafeMethods ?? true;
86
+ const fn = async (...args) => {
87
+ const req = args[0];
88
+ const method = req.method.toUpperCase();
89
+ if (protectedMethods.includes(method)) {
90
+ const result = verifyCsrf(req, opts);
91
+ if (!result.ok) {
92
+ if (opts.onReject) return await opts.onReject(req, result);
93
+ return new Response("Forbidden", {
94
+ status: 403,
95
+ headers: { "X-CSRF-Reject-Reason": result.code ?? "csrf_unknown" }
96
+ });
97
+ }
98
+ }
99
+ const res = await handler(...args);
100
+ if (issueOnSafe && !protectedMethods.includes(method)) {
101
+ const hasCookie = parseCookieValue(req.headers.get("cookie"), cookieName);
102
+ if (!hasCookie) {
103
+ res.headers.append("Set-Cookie", csrfCookieHeader(issueCsrfToken(opts), opts));
104
+ }
105
+ }
106
+ return res;
107
+ };
108
+ return fn;
109
+ }
110
+ export {
111
+ CsrfError,
112
+ csrfCookieHeader,
113
+ issueCsrfToken,
114
+ verifyCsrf,
115
+ withCsrf
116
+ };
@@ -0,0 +1,24 @@
1
+ interface SafeFetchPolicy {
2
+ allowedHosts?: readonly string[];
3
+ allowedHostPatterns?: readonly RegExp[];
4
+ blockPrivateIps?: boolean;
5
+ maxRedirects?: number;
6
+ timeoutMs?: number;
7
+ allowedProtocols?: readonly ("http:" | "https:")[];
8
+ }
9
+ interface SafeFetchDeps {
10
+ dnsLookup?: (host: string) => Promise<readonly string[]>;
11
+ fetchImpl?: typeof fetch;
12
+ }
13
+ interface SafeFetchOptions extends Omit<RequestInit, "redirect"> {
14
+ policy: SafeFetchPolicy;
15
+ deps?: SafeFetchDeps;
16
+ }
17
+ type SafeFetchErrorCode = "invalid_url" | "protocol_not_allowed" | "host_not_allowed" | "dns_failed" | "private_ip_blocked" | "redirect_limit" | "redirect_loop" | "timeout";
18
+ declare class SafeFetchError extends Error {
19
+ readonly code: SafeFetchErrorCode;
20
+ constructor(code: SafeFetchErrorCode, message: string);
21
+ }
22
+ declare function safeFetch(input: string | URL, options: SafeFetchOptions): Promise<Response>;
23
+
24
+ export { type SafeFetchDeps, SafeFetchError, type SafeFetchErrorCode, type SafeFetchOptions, type SafeFetchPolicy, safeFetch };
@@ -0,0 +1,189 @@
1
+ // src/fetch/index.ts
2
+ import { lookup } from "dns/promises";
3
+
4
+ // src/fetch/cidr.ts
5
+ import { isIP } from "net";
6
+ var IPV4_PRIVATE_RANGES = [
7
+ [0, 8],
8
+ // 0.0.0.0/8 — "this network"
9
+ [167772160, 8],
10
+ // 10.0.0.0/8 — RFC1918
11
+ [2130706432, 8],
12
+ // 127.0.0.0/8 — loopback
13
+ [2851995648, 16],
14
+ // 169.254.0.0/16 — link-local incl. 169.254.169.254 metadata
15
+ [2886729728, 12],
16
+ // 172.16.0.0/12 — RFC1918
17
+ [3232235520, 16],
18
+ // 192.168.0.0/16 — RFC1918
19
+ [1681915904, 10],
20
+ // 100.64.0.0/10 — CGNAT
21
+ [3758096384, 4],
22
+ // 224.0.0.0/4 — multicast
23
+ [4026531840, 4]
24
+ // 240.0.0.0/4 — reserved (incl 255.255.255.255 broadcast)
25
+ ];
26
+ function ipv4ToInt(ip) {
27
+ const parts = ip.split(".");
28
+ if (parts.length !== 4) return null;
29
+ let n = 0;
30
+ for (const part of parts) {
31
+ const octet = Number(part);
32
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null;
33
+ n = n * 256 + octet >>> 0;
34
+ }
35
+ return n;
36
+ }
37
+ function ipv4InRange(ipInt, baseInt, prefix) {
38
+ const mask = prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0;
39
+ return (ipInt & mask) === (baseInt & mask);
40
+ }
41
+ function isPrivateIpv4(ip) {
42
+ const n = ipv4ToInt(ip);
43
+ if (n === null) return false;
44
+ return IPV4_PRIVATE_RANGES.some(([base, prefix]) => ipv4InRange(n, base, prefix));
45
+ }
46
+ function isPrivateIpv6(ip) {
47
+ const normalised = ip.toLowerCase();
48
+ if (normalised === "::1") return true;
49
+ if (normalised === "::") return true;
50
+ if (normalised.startsWith("fe80:") || normalised.startsWith("fe80::")) return true;
51
+ if (normalised.startsWith("fc") || normalised.startsWith("fd")) {
52
+ const firstByte = parseInt(normalised.slice(0, 2), 16);
53
+ if ((firstByte & 254) === 252) return true;
54
+ }
55
+ const mapped = normalised.match(/^::ffff:([\d.]+)$/);
56
+ if (mapped) return isPrivateIpv4(mapped[1]);
57
+ const mapped2 = normalised.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
58
+ if (mapped2) {
59
+ const high = parseInt(mapped2[1], 16);
60
+ const low = parseInt(mapped2[2], 16);
61
+ const ipv4 = `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
62
+ return isPrivateIpv4(ipv4);
63
+ }
64
+ return false;
65
+ }
66
+ function isPrivateIp(ip) {
67
+ const family = isIP(ip);
68
+ if (family === 4) return isPrivateIpv4(ip);
69
+ if (family === 6) return isPrivateIpv6(ip);
70
+ return false;
71
+ }
72
+
73
+ // src/fetch/index.ts
74
+ var SafeFetchError = class extends Error {
75
+ constructor(code, message) {
76
+ super(message);
77
+ this.code = code;
78
+ this.name = "SafeFetchError";
79
+ }
80
+ code;
81
+ };
82
+ var DEFAULT_TIMEOUT_MS = 1e4;
83
+ var DEFAULT_MAX_REDIRECTS = 3;
84
+ var DEFAULT_PROTOCOLS = ["https:", "http:"];
85
+ async function defaultDnsLookup(host) {
86
+ const records = await lookup(host, { all: true, verbatim: true });
87
+ return records.map((r) => r.address);
88
+ }
89
+ function hostAllowed(host, policy) {
90
+ const hosts = policy.allowedHosts;
91
+ const patterns = policy.allowedHostPatterns;
92
+ if ((hosts === void 0 || hosts.length === 0) && (patterns === void 0 || patterns.length === 0)) {
93
+ return true;
94
+ }
95
+ if (hosts?.some((h) => h.toLowerCase() === host.toLowerCase())) return true;
96
+ if (patterns?.some((p) => p.test(host))) return true;
97
+ return false;
98
+ }
99
+ async function validateUrl(url, policy, deps) {
100
+ const protocols = policy.allowedProtocols ?? DEFAULT_PROTOCOLS;
101
+ if (!protocols.includes(url.protocol)) {
102
+ throw new SafeFetchError("protocol_not_allowed", `Protocol ${url.protocol} not allowed`);
103
+ }
104
+ if (!hostAllowed(url.hostname, policy)) {
105
+ throw new SafeFetchError("host_not_allowed", `Host ${url.hostname} not allowed`);
106
+ }
107
+ if (policy.blockPrivateIps !== false) {
108
+ let addresses;
109
+ try {
110
+ addresses = await (deps.dnsLookup ?? defaultDnsLookup)(url.hostname);
111
+ } catch (err) {
112
+ throw new SafeFetchError(
113
+ "dns_failed",
114
+ `DNS lookup for ${url.hostname} failed: ${err.message}`
115
+ );
116
+ }
117
+ if (addresses.length === 0) {
118
+ throw new SafeFetchError("dns_failed", `DNS lookup for ${url.hostname} returned no records`);
119
+ }
120
+ for (const addr of addresses) {
121
+ if (isPrivateIp(addr)) {
122
+ throw new SafeFetchError(
123
+ "private_ip_blocked",
124
+ `Host ${url.hostname} resolves to private IP ${addr}`
125
+ );
126
+ }
127
+ }
128
+ }
129
+ }
130
+ async function safeFetch(input, options) {
131
+ const { policy, deps = {}, ...init } = options;
132
+ const fetchImpl = deps.fetchImpl ?? fetch;
133
+ const maxRedirects = policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
134
+ const timeoutMs = policy.timeoutMs ?? DEFAULT_TIMEOUT_MS;
135
+ let currentUrl;
136
+ try {
137
+ currentUrl = input instanceof URL ? input : new URL(input);
138
+ } catch {
139
+ throw new SafeFetchError("invalid_url", `Could not parse URL: ${String(input)}`);
140
+ }
141
+ const visited = /* @__PURE__ */ new Set();
142
+ let hopsRemaining = maxRedirects;
143
+ while (true) {
144
+ await validateUrl(currentUrl, policy, deps);
145
+ const urlKey = currentUrl.toString();
146
+ if (visited.has(urlKey)) {
147
+ throw new SafeFetchError("redirect_loop", `Redirect loop detected at ${urlKey}`);
148
+ }
149
+ visited.add(urlKey);
150
+ const controller = new AbortController();
151
+ const externalSignal = init.signal;
152
+ if (externalSignal) {
153
+ if (externalSignal.aborted) controller.abort(externalSignal.reason);
154
+ else externalSignal.addEventListener("abort", () => controller.abort(externalSignal.reason));
155
+ }
156
+ const timer = setTimeout(() => controller.abort(new SafeFetchError("timeout", `Request to ${urlKey} timed out after ${timeoutMs}ms`)), timeoutMs);
157
+ let res;
158
+ try {
159
+ res = await fetchImpl(currentUrl, {
160
+ ...init,
161
+ signal: controller.signal,
162
+ redirect: "manual"
163
+ });
164
+ } catch (err) {
165
+ if (err.name === "AbortError" || err.code === "ABORT_ERR") {
166
+ const reason = controller.signal.reason;
167
+ if (reason instanceof SafeFetchError) throw reason;
168
+ throw new SafeFetchError("timeout", `Request to ${urlKey} aborted`);
169
+ }
170
+ throw err;
171
+ } finally {
172
+ clearTimeout(timer);
173
+ }
174
+ if (res.status >= 300 && res.status < 400 && res.headers.get("location")) {
175
+ if (hopsRemaining <= 0) {
176
+ throw new SafeFetchError("redirect_limit", `Exceeded ${maxRedirects} redirects`);
177
+ }
178
+ hopsRemaining--;
179
+ const next = new URL(res.headers.get("location"), currentUrl);
180
+ currentUrl = next;
181
+ continue;
182
+ }
183
+ return res;
184
+ }
185
+ }
186
+ export {
187
+ SafeFetchError,
188
+ safeFetch
189
+ };
@@ -0,0 +1,24 @@
1
+ interface ExporterContext {
2
+ userId: string;
3
+ locale?: string;
4
+ }
5
+ interface DomainExporter {
6
+ domain: string;
7
+ export(ctx: ExporterContext): AsyncIterable<{
8
+ filename: string;
9
+ content: Uint8Array | string;
10
+ }>;
11
+ }
12
+ interface DomainAnonymiser {
13
+ domain: string;
14
+ anonymise(ctx: ExporterContext): Promise<void>;
15
+ }
16
+ interface GdprRegistry {
17
+ registerExporter(exporter: DomainExporter): void;
18
+ registerAnonymiser(anonymiser: DomainAnonymiser): void;
19
+ listExporters(): readonly DomainExporter[];
20
+ listAnonymisers(): readonly DomainAnonymiser[];
21
+ }
22
+ declare function createGdprRegistry(): GdprRegistry;
23
+
24
+ export { type DomainAnonymiser, type DomainExporter, type ExporterContext, type GdprRegistry, createGdprRegistry };
@@ -0,0 +1,22 @@
1
+ // src/gdpr/index.ts
2
+ function createGdprRegistry() {
3
+ const exporters = [];
4
+ const anonymisers = [];
5
+ return {
6
+ registerExporter(e) {
7
+ exporters.push(e);
8
+ },
9
+ registerAnonymiser(a) {
10
+ anonymisers.push(a);
11
+ },
12
+ listExporters() {
13
+ return exporters;
14
+ },
15
+ listAnonymisers() {
16
+ return anonymisers;
17
+ }
18
+ };
19
+ }
20
+ export {
21
+ createGdprRegistry
22
+ };
@@ -0,0 +1,10 @@
1
+ interface Logger {
2
+ debug(msg: string, meta?: Record<string, unknown>): void;
3
+ info(msg: string, meta?: Record<string, unknown>): void;
4
+ warn(msg: string, meta?: Record<string, unknown>): void;
5
+ error(msg: string, meta?: Record<string, unknown>): void;
6
+ }
7
+ declare const NoopLogger: Logger;
8
+ declare const PACKAGE_VERSION = "0.1.0";
9
+
10
+ export { type Logger, NoopLogger, PACKAGE_VERSION };
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // src/index.ts
2
+ var NoopLogger = {
3
+ debug() {
4
+ },
5
+ info() {
6
+ },
7
+ warn() {
8
+ },
9
+ error() {
10
+ }
11
+ };
12
+ var PACKAGE_VERSION = "0.1.0";
13
+ export {
14
+ NoopLogger,
15
+ PACKAGE_VERSION
16
+ };
@@ -0,0 +1,54 @@
1
+ interface MemoryStoreOptions {
2
+ now?: () => number;
3
+ sweepIntervalMs?: number;
4
+ }
5
+ declare class MemoryRateLimitStore implements RateLimitStore {
6
+ private readonly map;
7
+ private readonly now;
8
+ private sweepTimer;
9
+ constructor(opts?: MemoryStoreOptions);
10
+ get(key: string): Promise<RateLimitState | null>;
11
+ increment(key: string, windowMs: number): Promise<RateLimitState>;
12
+ reset(key: string): Promise<void>;
13
+ size(): number;
14
+ sweep(): void;
15
+ stop(): void;
16
+ }
17
+ declare function getDefaultMemoryStore(): MemoryRateLimitStore;
18
+
19
+ interface RateLimitState {
20
+ count: number;
21
+ resetAt: number;
22
+ }
23
+ interface RateLimitDecision {
24
+ allowed: boolean;
25
+ remaining: number;
26
+ resetAt: number;
27
+ retryAfterSeconds: number;
28
+ }
29
+ interface RateLimitStore {
30
+ get(key: string): Promise<RateLimitState | null>;
31
+ increment(key: string, windowMs: number): Promise<RateLimitState>;
32
+ reset(key: string): Promise<void>;
33
+ }
34
+ interface RateLimiterOptions {
35
+ store?: RateLimitStore;
36
+ windowMs: number;
37
+ max: number;
38
+ keyResolver?: (req: Request) => string;
39
+ now?: () => number;
40
+ }
41
+ interface RateLimiter {
42
+ check(req: Request): Promise<RateLimitDecision>;
43
+ checkKey(key: string): Promise<RateLimitDecision>;
44
+ }
45
+ declare function defaultKeyResolver(req: Request): string;
46
+ declare function createRateLimiter(opts: RateLimiterOptions): RateLimiter;
47
+ declare function checkRateLimit(req: Request, opts: RateLimiterOptions): Promise<RateLimitDecision>;
48
+ interface WithRateLimitOptions extends RateLimiterOptions {
49
+ onLimit?: (req: Request, decision: RateLimitDecision) => Response | Promise<Response>;
50
+ }
51
+ type AnyRouteHandler = (...args: any[]) => Promise<Response>;
52
+ declare function withRateLimit<H extends AnyRouteHandler>(handler: H, opts: WithRateLimitOptions): H;
53
+
54
+ export { MemoryRateLimitStore, type RateLimitDecision, type RateLimitState, type RateLimitStore, type RateLimiter, type RateLimiterOptions, type WithRateLimitOptions, checkRateLimit, createRateLimiter, defaultKeyResolver, getDefaultMemoryStore, withRateLimit };
@@ -0,0 +1,122 @@
1
+ // src/ratelimit/store-memory.ts
2
+ var MemoryRateLimitStore = class {
3
+ map = /* @__PURE__ */ new Map();
4
+ now;
5
+ sweepTimer = null;
6
+ constructor(opts = {}) {
7
+ this.now = opts.now ?? Date.now;
8
+ const sweepMs = opts.sweepIntervalMs ?? 6e4;
9
+ if (sweepMs > 0 && typeof setInterval === "function") {
10
+ this.sweepTimer = setInterval(() => this.sweep(), sweepMs);
11
+ const t = this.sweepTimer;
12
+ t.unref?.();
13
+ }
14
+ }
15
+ async get(key) {
16
+ const e = this.map.get(key);
17
+ if (!e) return null;
18
+ if (e.expiresAt <= this.now()) {
19
+ this.map.delete(key);
20
+ return null;
21
+ }
22
+ return { ...e.state };
23
+ }
24
+ async increment(key, windowMs) {
25
+ const t = this.now();
26
+ const existing = this.map.get(key);
27
+ if (!existing || existing.expiresAt <= t) {
28
+ const state = { count: 1, resetAt: t + windowMs };
29
+ this.map.set(key, { state, expiresAt: state.resetAt });
30
+ return { ...state };
31
+ }
32
+ existing.state.count += 1;
33
+ return { ...existing.state };
34
+ }
35
+ async reset(key) {
36
+ this.map.delete(key);
37
+ }
38
+ size() {
39
+ return this.map.size;
40
+ }
41
+ sweep() {
42
+ const t = this.now();
43
+ for (const [k, v] of this.map) {
44
+ if (v.expiresAt <= t) this.map.delete(k);
45
+ }
46
+ }
47
+ stop() {
48
+ if (this.sweepTimer) {
49
+ clearInterval(this.sweepTimer);
50
+ this.sweepTimer = null;
51
+ }
52
+ }
53
+ };
54
+ var defaultStore = null;
55
+ function getDefaultMemoryStore() {
56
+ if (!defaultStore) defaultStore = new MemoryRateLimitStore();
57
+ return defaultStore;
58
+ }
59
+
60
+ // src/ratelimit/index.ts
61
+ function defaultKeyResolver(req) {
62
+ const headers = req.headers;
63
+ const xff = headers.get("x-forwarded-for");
64
+ if (xff) return xff.split(",")[0].trim();
65
+ const xri = headers.get("x-real-ip");
66
+ if (xri) return xri.trim();
67
+ const cf = headers.get("cf-connecting-ip");
68
+ if (cf) return cf.trim();
69
+ return "unknown";
70
+ }
71
+ function createRateLimiter(opts) {
72
+ const store = opts.store ?? getDefaultMemoryStore();
73
+ const keyResolver = opts.keyResolver ?? defaultKeyResolver;
74
+ const now = opts.now ?? Date.now;
75
+ async function checkKey(key) {
76
+ const state = await store.increment(key, opts.windowMs);
77
+ const allowed = state.count <= opts.max;
78
+ const remaining = Math.max(0, opts.max - state.count);
79
+ const retryAfterSeconds = Math.max(0, Math.ceil((state.resetAt - now()) / 1e3));
80
+ return { allowed, remaining, resetAt: state.resetAt, retryAfterSeconds };
81
+ }
82
+ return {
83
+ check: (req) => checkKey(keyResolver(req)),
84
+ checkKey
85
+ };
86
+ }
87
+ async function checkRateLimit(req, opts) {
88
+ return createRateLimiter(opts).check(req);
89
+ }
90
+ function withRateLimit(handler, opts) {
91
+ const limiter = createRateLimiter(opts);
92
+ const fn = async (...args) => {
93
+ const req = args[0];
94
+ const decision = await limiter.check(req);
95
+ if (!decision.allowed) {
96
+ if (opts.onLimit) return await opts.onLimit(req, decision);
97
+ return new Response("Too Many Requests", {
98
+ status: 429,
99
+ headers: {
100
+ "Retry-After": String(decision.retryAfterSeconds),
101
+ "X-RateLimit-Limit": String(opts.max),
102
+ "X-RateLimit-Remaining": "0",
103
+ "X-RateLimit-Reset": String(decision.resetAt)
104
+ }
105
+ });
106
+ }
107
+ const res = await handler(...args);
108
+ res.headers.set("X-RateLimit-Limit", String(opts.max));
109
+ res.headers.set("X-RateLimit-Remaining", String(decision.remaining));
110
+ res.headers.set("X-RateLimit-Reset", String(decision.resetAt));
111
+ return res;
112
+ };
113
+ return fn;
114
+ }
115
+ export {
116
+ MemoryRateLimitStore,
117
+ checkRateLimit,
118
+ createRateLimiter,
119
+ defaultKeyResolver,
120
+ getDefaultMemoryStore,
121
+ withRateLimit
122
+ };
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "hazo_secure",
3
+ "version": "0.5.0",
4
+ "description": "Security & compliance primitives — SSRF-safe fetch, rate limiting, field-level encryption, GDPR orchestration. Four subpath modules, pick the ones you need.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./fetch": {
16
+ "types": "./dist/fetch/index.d.ts",
17
+ "import": "./dist/fetch/index.js",
18
+ "default": "./dist/fetch/index.js"
19
+ },
20
+ "./ratelimit": {
21
+ "types": "./dist/ratelimit/index.d.ts",
22
+ "import": "./dist/ratelimit/index.js",
23
+ "default": "./dist/ratelimit/index.js"
24
+ },
25
+ "./crypto": {
26
+ "types": "./dist/crypto/index.d.ts",
27
+ "import": "./dist/crypto/index.js",
28
+ "default": "./dist/crypto/index.js"
29
+ },
30
+ "./gdpr": {
31
+ "types": "./dist/gdpr/index.d.ts",
32
+ "import": "./dist/gdpr/index.js",
33
+ "default": "./dist/gdpr/index.js"
34
+ },
35
+ "./csrf": {
36
+ "types": "./dist/csrf/index.d.ts",
37
+ "import": "./dist/csrf/index.js",
38
+ "default": "./dist/csrf/index.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "LICENSE",
44
+ "README.md"
45
+ ],
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "dev": "tsup --watch",
49
+ "lint": "tsc --noEmit",
50
+ "test": "jest",
51
+ "test:watch": "jest --watch",
52
+ "dev:test-app": "npm run build && cd test-app && npm run dev",
53
+ "build:test-app": "npm run build && cd test-app && npm run build",
54
+ "install:test-app": "cd test-app && npm install",
55
+ "prepublishOnly": "npm run build"
56
+ },
57
+ "peerDependencies": {
58
+ "hazo_logs": "^1.1.0",
59
+ "hazo_jobs": "^0.7.0",
60
+ "hazo_files": "^1.4.7"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "hazo_jobs": { "optional": true },
64
+ "hazo_files": { "optional": true }
65
+ },
66
+ "devDependencies": {
67
+ "@types/jest": "^29.0.0",
68
+ "@types/node": "^22.0.0",
69
+ "jest": "^29.0.0",
70
+ "ts-jest": "^29.0.0",
71
+ "tsup": "^8.5.1",
72
+ "typescript": "^5.7.2"
73
+ },
74
+ "engines": {
75
+ "node": ">=18.0.0"
76
+ },
77
+ "author": "Pubs Abayasiri",
78
+ "license": "MIT"
79
+ }