hazo_secure 0.5.0 → 1.0.1
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/README.md +87 -2
- package/dist/crypto/index.d.ts +32 -1
- package/dist/crypto/index.js +85 -5
- package/dist/fetch/index.d.ts +17 -1
- package/dist/fetch/index.js +39 -4
- package/dist/gdpr/index.d.ts +40 -2
- package/dist/gdpr/index.js +89 -9
- package/dist/ratelimit/index.d.ts +48 -3
- package/dist/ratelimit/index.js +82 -2
- package/package.json +14 -3
package/README.md
CHANGED
|
@@ -5,9 +5,10 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
|
|
|
5
5
|
| Subpath | Purpose |
|
|
6
6
|
|---|---|
|
|
7
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,
|
|
8
|
+
| `hazo_secure/ratelimit` | Sliding-window + token-bucket limiter with pluggable store (in-memory default, hazo_connect adapter) |
|
|
9
9
|
| `hazo_secure/crypto` | AES-256-GCM field-level encryption with key versioning and AAD support |
|
|
10
10
|
| `hazo_secure/gdpr` | Exporter / anonymiser registry + lifecycle orchestrator. Uses `hazo_jobs` for async export, `hazo_files` for ZIP delivery |
|
|
11
|
+
| `hazo_secure/csrf` | Double-submit CSRF protection for Next.js routes — token generation, cookie header, constant-time verification |
|
|
11
12
|
|
|
12
13
|
## Install
|
|
13
14
|
|
|
@@ -15,7 +16,7 @@ Security & compliance primitives for Node.js / Next.js apps. Four independent su
|
|
|
15
16
|
npm install hazo_secure
|
|
16
17
|
```
|
|
17
18
|
|
|
18
|
-
`hazo_logs` is a required peer dep. `hazo_jobs
|
|
19
|
+
`hazo_logs` is a required peer dep. `hazo_connect`, `hazo_jobs`, and `hazo_files` are optional peers — `hazo_connect` is needed for `ConnectRateLimitStore`; `hazo_jobs` and `hazo_files` are only needed for `hazo_secure/gdpr`.
|
|
19
20
|
|
|
20
21
|
## Quick start
|
|
21
22
|
|
|
@@ -72,6 +73,90 @@ gdpr.registerExporter({
|
|
|
72
73
|
});
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
## New in 1.0.0
|
|
77
|
+
|
|
78
|
+
### `hazo_secure/ratelimit` — ConnectRateLimitStore (token bucket)
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { ConnectRateLimitStore } from "hazo_secure/ratelimit";
|
|
82
|
+
|
|
83
|
+
const store = new ConnectRateLimitStore({ getHazoConnect: () => db });
|
|
84
|
+
const limiter = createRateLimiter({ store, windowMs: 60_000, max: 30 });
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Stores token-bucket state in `hazo_rl_buckets` (see `migrations/001_hazo_rl_buckets.sql`). Run the migration before use. Requires `hazo_connect` peer.
|
|
88
|
+
|
|
89
|
+
### `hazo_secure/fetch` — undici dispatcher + `validateConnectIp`
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { safeFetch, createSecureDispatcher, validateConnectIp } from "hazo_secure/fetch";
|
|
93
|
+
|
|
94
|
+
// Use standalone to close the DNS-rebinding gap at socket creation time
|
|
95
|
+
const dispatcher = createSecureDispatcher();
|
|
96
|
+
const res = await safeFetch(url, { policy, deps: { fetchImpl: (u, i) => fetch(u, { ...i, dispatcher }) } });
|
|
97
|
+
|
|
98
|
+
// Or validate resolved IPs yourself
|
|
99
|
+
validateConnectIp("169.254.169.254"); // throws — blocked CIDR
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Node.js only — undici is a Node dep. Edge runtime gets pre-flight checks only.
|
|
103
|
+
|
|
104
|
+
### `hazo_secure/crypto` — HttpKeyProvider
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { HttpKeyProvider } from "hazo_secure/crypto";
|
|
108
|
+
|
|
109
|
+
const provider = new HttpKeyProvider({
|
|
110
|
+
endpoint: "https://kms.internal/keys",
|
|
111
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
112
|
+
ttlMs: 30_000, // in-process cache TTL (default: 30 s)
|
|
113
|
+
});
|
|
114
|
+
provider.clearCache(); // force next fetch to re-fetch from endpoint
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Cloud-agnostic — works with any HTTPS key endpoint that returns `{ keyId, key: "<base64>" }`. Caches keys in-process to avoid latency on every encrypt/decrypt call.
|
|
118
|
+
|
|
119
|
+
### `hazo_secure/gdpr` — registry enhancements
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { createGdprRegistry, GdprRegistryError } from "hazo_secure/gdpr";
|
|
123
|
+
|
|
124
|
+
const gdpr = createGdprRegistry({ auditIntent: logAuditEntry });
|
|
125
|
+
|
|
126
|
+
// Validate registry completeness
|
|
127
|
+
const issues = await gdpr.validateRegistry({ requiredDomains: ["persons", "payments"] });
|
|
128
|
+
|
|
129
|
+
// Export user data (streams records from all registered exporters)
|
|
130
|
+
for await (const file of gdpr.exportUser({ userId })) {
|
|
131
|
+
console.log(file.filename, file.content);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Dry-run export without side effects
|
|
135
|
+
const files = await gdpr.dryRunExport({ userId });
|
|
136
|
+
|
|
137
|
+
// Anonymise user across all registered domains
|
|
138
|
+
await gdpr.anonymiseUser({ userId });
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`GdprRegistryError` is a typed error class — catch and `instanceof`-check it.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Runtime Compatibility
|
|
146
|
+
|
|
147
|
+
| Subpath | Edge runtime | Node.js (server) | Notes |
|
|
148
|
+
|---|---|---|---|
|
|
149
|
+
| `hazo_secure` | ✅ | ✅ | Types only, no runtime weight |
|
|
150
|
+
| `hazo_secure/csrf` | ✅ | ✅ | Web Crypto API only |
|
|
151
|
+
| `hazo_secure/ratelimit` | ✅ (MemoryRateLimitStore) | ✅ | ConnectRateLimitStore is Node-only (hazo_connect) |
|
|
152
|
+
| `hazo_secure/fetch` | ⚠️ pre-flight only | ✅ full protection | Edge: host/IP checks only; Node: + undici connect-time validation |
|
|
153
|
+
| `hazo_secure/crypto` | ❌ | ✅ | Uses `node:crypto` |
|
|
154
|
+
| `hazo_secure/gdpr` | ❌ | ✅ | Async iterables + optional hazo_connect |
|
|
155
|
+
|
|
156
|
+
**Node.js ≥ 18 required** for all Node-only subpaths.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
75
160
|
## Design
|
|
76
161
|
|
|
77
162
|
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.
|
package/dist/crypto/index.d.ts
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
interface HttpKeyProviderOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Base HTTPS endpoint for key material.
|
|
4
|
+
* - current(): GET {endpoint}/current → { keyId: string; key: string }
|
|
5
|
+
* - byId(id): GET {endpoint}/{id} → { keyId: string; key: string }
|
|
6
|
+
*
|
|
7
|
+
* `key` must be base64url-encoded 32-byte AES-256 key material.
|
|
8
|
+
*/
|
|
9
|
+
endpoint: string;
|
|
10
|
+
/**
|
|
11
|
+
* Fetch implementation. Inject safeFetch from hazo_secure/fetch for SSRF
|
|
12
|
+
* protection on external KMS endpoints. Defaults to globalThis.fetch.
|
|
13
|
+
*/
|
|
14
|
+
fetch?: typeof globalThis.fetch;
|
|
15
|
+
/** Bearer token added as Authorization header on every request. */
|
|
16
|
+
token?: string;
|
|
17
|
+
/** Additional headers added to every request. */
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
declare class HttpKeyProvider implements KeyProvider {
|
|
21
|
+
#private;
|
|
22
|
+
constructor(opts: HttpKeyProviderOptions);
|
|
23
|
+
current(): Promise<{
|
|
24
|
+
keyId: string;
|
|
25
|
+
key: Uint8Array;
|
|
26
|
+
}>;
|
|
27
|
+
byId(keyId: string): Promise<Uint8Array>;
|
|
28
|
+
/** Clear in-process key cache. Call after rotating keys. */
|
|
29
|
+
clearCache(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
1
32
|
/**
|
|
2
33
|
* Source of symmetric keys for `encryptField` / `decryptField`. The current
|
|
3
34
|
* key is used for encryption; `byId` resolves any key (current or rotated-out)
|
|
@@ -104,4 +135,4 @@ declare class StaticKeyProvider implements KeyProvider {
|
|
|
104
135
|
* Convention: `<table>:<entityId>:<field>` — collision-free across tables. */
|
|
105
136
|
declare function aadFor(table: string, entityId: string, field: string): string;
|
|
106
137
|
|
|
107
|
-
export { CryptoError, type EncryptedField, EnvKeyProvider, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
|
|
138
|
+
export { CryptoError, type EncryptedField, EnvKeyProvider, HttpKeyProvider, type HttpKeyProviderOptions, type KeyProvider, StaticKeyProvider, aadFor, decryptField, encryptField };
|
package/dist/crypto/index.js
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
// src/crypto/index.ts
|
|
2
2
|
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/crypto/provider-http.ts
|
|
5
|
+
var KEY_LEN = 32;
|
|
6
|
+
var HttpKeyProvider = class {
|
|
7
|
+
#endpoint;
|
|
8
|
+
#fetch;
|
|
9
|
+
#token;
|
|
10
|
+
#headers;
|
|
11
|
+
/** Cache for key material, keyed by keyId. */
|
|
12
|
+
#cache = /* @__PURE__ */ new Map();
|
|
13
|
+
/** Cached result of the most recent successful /current fetch. */
|
|
14
|
+
#currentCache = null;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.#endpoint = opts.endpoint.replace(/\/$/, "");
|
|
17
|
+
this.#fetch = opts.fetch ?? globalThis.fetch;
|
|
18
|
+
this.#token = opts.token;
|
|
19
|
+
this.#headers = opts.headers ?? {};
|
|
20
|
+
}
|
|
21
|
+
async current() {
|
|
22
|
+
if (this.#currentCache) return this.#currentCache;
|
|
23
|
+
const data = await this.#fetchSegment("current");
|
|
24
|
+
const key = this.#parseAndCacheKey(data);
|
|
25
|
+
this.#currentCache = { keyId: data.keyId, key };
|
|
26
|
+
return this.#currentCache;
|
|
27
|
+
}
|
|
28
|
+
async byId(keyId) {
|
|
29
|
+
const cached = this.#cache.get(keyId);
|
|
30
|
+
if (cached) return cached;
|
|
31
|
+
const data = await this.#fetchSegment(keyId);
|
|
32
|
+
return this.#parseAndCacheKey(data);
|
|
33
|
+
}
|
|
34
|
+
/** Clear in-process key cache. Call after rotating keys. */
|
|
35
|
+
clearCache() {
|
|
36
|
+
this.#cache.clear();
|
|
37
|
+
this.#currentCache = null;
|
|
38
|
+
}
|
|
39
|
+
#parseAndCacheKey(data) {
|
|
40
|
+
const buf = Buffer.from(data.key, "base64url");
|
|
41
|
+
if (buf.byteLength !== KEY_LEN) {
|
|
42
|
+
throw new CryptoError(
|
|
43
|
+
`HttpKeyProvider: key ${data.keyId} is ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN}`,
|
|
44
|
+
"invalid_key"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const key = new Uint8Array(buf);
|
|
48
|
+
this.#cache.set(data.keyId, key);
|
|
49
|
+
return key;
|
|
50
|
+
}
|
|
51
|
+
async #fetchSegment(segment) {
|
|
52
|
+
const url = `${this.#endpoint}/${segment}`;
|
|
53
|
+
const headers = { ...this.#headers };
|
|
54
|
+
if (this.#token) headers["Authorization"] = `Bearer ${this.#token}`;
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await this.#fetch(url, { headers });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw new CryptoError(
|
|
60
|
+
`HttpKeyProvider: fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
|
61
|
+
"key_not_found"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
throw new CryptoError(
|
|
66
|
+
`HttpKeyProvider: endpoint returned ${res.status} for ${url}`,
|
|
67
|
+
"key_not_found"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return await res.json();
|
|
72
|
+
} catch {
|
|
73
|
+
throw new CryptoError(
|
|
74
|
+
`HttpKeyProvider: invalid JSON response from ${url}`,
|
|
75
|
+
"key_not_found"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/crypto/index.ts
|
|
3
82
|
var CryptoError = class extends Error {
|
|
4
83
|
constructor(message, code) {
|
|
5
84
|
super(message);
|
|
@@ -11,7 +90,7 @@ var CryptoError = class extends Error {
|
|
|
11
90
|
var ALG = "aes-256-gcm";
|
|
12
91
|
var IV_LEN = 12;
|
|
13
92
|
var TAG_LEN = 16;
|
|
14
|
-
var
|
|
93
|
+
var KEY_LEN2 = 32;
|
|
15
94
|
function toB64(buf) {
|
|
16
95
|
return Buffer.from(buf).toString("base64");
|
|
17
96
|
}
|
|
@@ -20,9 +99,9 @@ function fromB64(s) {
|
|
|
20
99
|
}
|
|
21
100
|
async function encryptField(plaintext, opts) {
|
|
22
101
|
const { keyId, key } = await opts.keys.current();
|
|
23
|
-
if (key.byteLength !==
|
|
102
|
+
if (key.byteLength !== KEY_LEN2) {
|
|
24
103
|
throw new CryptoError(
|
|
25
|
-
`Key for ${keyId} is ${key.byteLength} bytes; AES-256-GCM needs ${
|
|
104
|
+
`Key for ${keyId} is ${key.byteLength} bytes; AES-256-GCM needs ${KEY_LEN2}.`,
|
|
26
105
|
"invalid_key"
|
|
27
106
|
);
|
|
28
107
|
}
|
|
@@ -99,9 +178,9 @@ var EnvKeyProvider = class {
|
|
|
99
178
|
throw new CryptoError(`Missing ${varName} env var`, "key_not_found");
|
|
100
179
|
}
|
|
101
180
|
const buf = Buffer.from(raw, "base64");
|
|
102
|
-
if (buf.byteLength !==
|
|
181
|
+
if (buf.byteLength !== KEY_LEN2) {
|
|
103
182
|
throw new CryptoError(
|
|
104
|
-
`${varName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${
|
|
183
|
+
`${varName} decodes to ${buf.byteLength} bytes; AES-256-GCM needs ${KEY_LEN2}`,
|
|
105
184
|
"invalid_key"
|
|
106
185
|
);
|
|
107
186
|
}
|
|
@@ -140,6 +219,7 @@ function aadFor(table, entityId, field) {
|
|
|
140
219
|
export {
|
|
141
220
|
CryptoError,
|
|
142
221
|
EnvKeyProvider,
|
|
222
|
+
HttpKeyProvider,
|
|
143
223
|
StaticKeyProvider,
|
|
144
224
|
aadFor,
|
|
145
225
|
decryptField,
|
package/dist/fetch/index.d.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
import { Agent } from 'undici';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates a resolved IP at connect time. Throws if private/reserved.
|
|
5
|
+
* Exported so it can be unit-tested directly.
|
|
6
|
+
*/
|
|
7
|
+
declare function validateConnectIp(ip: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* Creates an undici Agent whose DNS lookup validates the resolved IP before
|
|
10
|
+
* the TCP socket is opened. This prevents DNS rebinding: even if the DNS
|
|
11
|
+
* record flips after the pre-flight check, the IP is re-validated here.
|
|
12
|
+
*
|
|
13
|
+
* Node 18+ ships undici as a built-in; we pin an explicit dep for stability.
|
|
14
|
+
*/
|
|
15
|
+
declare function createSecureDispatcher(): Agent;
|
|
16
|
+
|
|
1
17
|
interface SafeFetchPolicy {
|
|
2
18
|
allowedHosts?: readonly string[];
|
|
3
19
|
allowedHostPatterns?: readonly RegExp[];
|
|
@@ -21,4 +37,4 @@ declare class SafeFetchError extends Error {
|
|
|
21
37
|
}
|
|
22
38
|
declare function safeFetch(input: string | URL, options: SafeFetchOptions): Promise<Response>;
|
|
23
39
|
|
|
24
|
-
export { type SafeFetchDeps, SafeFetchError, type SafeFetchErrorCode, type SafeFetchOptions, type SafeFetchPolicy, safeFetch };
|
|
40
|
+
export { type SafeFetchDeps, SafeFetchError, type SafeFetchErrorCode, type SafeFetchOptions, type SafeFetchPolicy, createSecureDispatcher, safeFetch, validateConnectIp };
|
package/dist/fetch/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/fetch/index.ts
|
|
2
|
-
import { lookup } from "dns/promises";
|
|
2
|
+
import { lookup as lookup2 } from "dns/promises";
|
|
3
|
+
import { fetch as undiciFetch } from "undici";
|
|
3
4
|
|
|
4
5
|
// src/fetch/cidr.ts
|
|
5
6
|
import { isIP } from "net";
|
|
@@ -70,7 +71,38 @@ function isPrivateIp(ip) {
|
|
|
70
71
|
return false;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// src/fetch/dispatcher.ts
|
|
75
|
+
import { Agent } from "undici";
|
|
76
|
+
import * as dns from "dns";
|
|
77
|
+
function validateConnectIp(ip) {
|
|
78
|
+
if (isPrivateIp(ip)) {
|
|
79
|
+
const err = new Error(
|
|
80
|
+
`SSRF: resolved IP ${ip} is a private/reserved address \u2014 blocked at connect time`
|
|
81
|
+
);
|
|
82
|
+
err.code = "SSRF_PRIVATE_IP";
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function createSecureDispatcher() {
|
|
87
|
+
return new Agent({
|
|
88
|
+
connect: {
|
|
89
|
+
lookup(hostname, _options, callback) {
|
|
90
|
+
dns.lookup(hostname, (err, address, family) => {
|
|
91
|
+
if (err) return callback(err, "", 4);
|
|
92
|
+
try {
|
|
93
|
+
validateConnectIp(address);
|
|
94
|
+
} catch (ssrfErr) {
|
|
95
|
+
return callback(ssrfErr, "", 4);
|
|
96
|
+
}
|
|
97
|
+
callback(null, address, family);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
73
104
|
// src/fetch/index.ts
|
|
105
|
+
var secureDispatcher = createSecureDispatcher();
|
|
74
106
|
var SafeFetchError = class extends Error {
|
|
75
107
|
constructor(code, message) {
|
|
76
108
|
super(message);
|
|
@@ -83,7 +115,7 @@ var DEFAULT_TIMEOUT_MS = 1e4;
|
|
|
83
115
|
var DEFAULT_MAX_REDIRECTS = 3;
|
|
84
116
|
var DEFAULT_PROTOCOLS = ["https:", "http:"];
|
|
85
117
|
async function defaultDnsLookup(host) {
|
|
86
|
-
const records = await
|
|
118
|
+
const records = await lookup2(host, { all: true, verbatim: true });
|
|
87
119
|
return records.map((r) => r.address);
|
|
88
120
|
}
|
|
89
121
|
function hostAllowed(host, policy) {
|
|
@@ -129,7 +161,8 @@ async function validateUrl(url, policy, deps) {
|
|
|
129
161
|
}
|
|
130
162
|
async function safeFetch(input, options) {
|
|
131
163
|
const { policy, deps = {}, ...init } = options;
|
|
132
|
-
const fetchImpl = deps.fetchImpl ??
|
|
164
|
+
const fetchImpl = deps.fetchImpl ?? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
165
|
+
((url, init2) => undiciFetch(url, { ...init2, dispatcher: secureDispatcher }));
|
|
133
166
|
const maxRedirects = policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
134
167
|
const timeoutMs = policy.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
135
168
|
let currentUrl;
|
|
@@ -185,5 +218,7 @@ async function safeFetch(input, options) {
|
|
|
185
218
|
}
|
|
186
219
|
export {
|
|
187
220
|
SafeFetchError,
|
|
188
|
-
|
|
221
|
+
createSecureDispatcher,
|
|
222
|
+
safeFetch,
|
|
223
|
+
validateConnectIp
|
|
189
224
|
};
|
package/dist/gdpr/index.d.ts
CHANGED
|
@@ -13,12 +13,50 @@ interface DomainAnonymiser {
|
|
|
13
13
|
domain: string;
|
|
14
14
|
anonymise(ctx: ExporterContext): Promise<void>;
|
|
15
15
|
}
|
|
16
|
+
type UserExportResult = Array<{
|
|
17
|
+
domain: string;
|
|
18
|
+
files: Array<{
|
|
19
|
+
filename: string;
|
|
20
|
+
content: Uint8Array | string;
|
|
21
|
+
}>;
|
|
22
|
+
}>;
|
|
23
|
+
type DryRunExportResult = Array<{
|
|
24
|
+
domain: string;
|
|
25
|
+
files: string[];
|
|
26
|
+
}>;
|
|
27
|
+
interface ValidateRegistryOptions {
|
|
28
|
+
/** Domain names that MUST each have both an exporter and an anonymiser registered. */
|
|
29
|
+
requiredDomains: string[];
|
|
30
|
+
}
|
|
31
|
+
/** Matches hazo_audit's auditIntent after partial application with an adapter. */
|
|
32
|
+
type AuditIntentFn = (name: string, callback: () => Promise<void>, opts?: {
|
|
33
|
+
subject_kind?: string;
|
|
34
|
+
subject_id?: string;
|
|
35
|
+
metadata?: Record<string, unknown>;
|
|
36
|
+
}) => Promise<void>;
|
|
37
|
+
interface GdprRegistryOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Optional audit trail. Pass hazo_audit's auditIntent partially applied:
|
|
40
|
+
* createGdprRegistry({
|
|
41
|
+
* auditIntent: (name, cb, opts) => auditIntent(adapter, name, cb, opts),
|
|
42
|
+
* })
|
|
43
|
+
*/
|
|
44
|
+
auditIntent?: AuditIntentFn;
|
|
45
|
+
}
|
|
46
|
+
declare class GdprRegistryError extends Error {
|
|
47
|
+
readonly code: 'incomplete_registry';
|
|
48
|
+
constructor(message: string, code: 'incomplete_registry');
|
|
49
|
+
}
|
|
16
50
|
interface GdprRegistry {
|
|
17
51
|
registerExporter(exporter: DomainExporter): void;
|
|
18
52
|
registerAnonymiser(anonymiser: DomainAnonymiser): void;
|
|
19
53
|
listExporters(): readonly DomainExporter[];
|
|
20
54
|
listAnonymisers(): readonly DomainAnonymiser[];
|
|
55
|
+
exportUser(ctx: ExporterContext): Promise<UserExportResult>;
|
|
56
|
+
anonymiseUser(ctx: ExporterContext): Promise<void>;
|
|
57
|
+
validateRegistry(opts: ValidateRegistryOptions): void;
|
|
58
|
+
dryRunExport(ctx: ExporterContext): Promise<DryRunExportResult>;
|
|
21
59
|
}
|
|
22
|
-
declare function createGdprRegistry(): GdprRegistry;
|
|
60
|
+
declare function createGdprRegistry(opts?: GdprRegistryOptions): GdprRegistry;
|
|
23
61
|
|
|
24
|
-
export { type DomainAnonymiser, type DomainExporter, type ExporterContext, type GdprRegistry, createGdprRegistry };
|
|
62
|
+
export { type AuditIntentFn, type DomainAnonymiser, type DomainExporter, type DryRunExportResult, type ExporterContext, type GdprRegistry, GdprRegistryError, type GdprRegistryOptions, type UserExportResult, type ValidateRegistryOptions, createGdprRegistry };
|
package/dist/gdpr/index.js
CHANGED
|
@@ -1,22 +1,102 @@
|
|
|
1
1
|
// src/gdpr/index.ts
|
|
2
|
-
|
|
2
|
+
var GdprRegistryError = class extends Error {
|
|
3
|
+
constructor(message, code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = "GdprRegistryError";
|
|
7
|
+
}
|
|
8
|
+
code;
|
|
9
|
+
};
|
|
10
|
+
function createGdprRegistry(opts = {}) {
|
|
3
11
|
const exporters = [];
|
|
4
12
|
const anonymisers = [];
|
|
13
|
+
const { auditIntent } = opts;
|
|
14
|
+
async function exportUser(ctx) {
|
|
15
|
+
const run = async () => {
|
|
16
|
+
const result = [];
|
|
17
|
+
for (const exporter of exporters) {
|
|
18
|
+
const files = [];
|
|
19
|
+
for await (const item of exporter.export(ctx)) {
|
|
20
|
+
files.push(item);
|
|
21
|
+
}
|
|
22
|
+
result.push({ domain: exporter.domain, files });
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
if (auditIntent) {
|
|
27
|
+
let result;
|
|
28
|
+
await auditIntent(
|
|
29
|
+
"gdpr.export",
|
|
30
|
+
async () => {
|
|
31
|
+
result = await run();
|
|
32
|
+
},
|
|
33
|
+
{ subject_kind: "user", subject_id: ctx.userId }
|
|
34
|
+
);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
return run();
|
|
38
|
+
}
|
|
39
|
+
async function anonymiseUser(ctx) {
|
|
40
|
+
const run = async () => {
|
|
41
|
+
for (const anonymiser of anonymisers) {
|
|
42
|
+
await anonymiser.anonymise(ctx);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
if (auditIntent) {
|
|
46
|
+
await auditIntent(
|
|
47
|
+
"gdpr.anonymise",
|
|
48
|
+
run,
|
|
49
|
+
{ subject_kind: "user", subject_id: ctx.userId, metadata: { domainCount: anonymisers.length } }
|
|
50
|
+
);
|
|
51
|
+
} else {
|
|
52
|
+
await run();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function validateRegistry(options) {
|
|
56
|
+
const exporterDomains = new Set(exporters.map((e) => e.domain));
|
|
57
|
+
const anonymiserDomains = new Set(anonymisers.map((a) => a.domain));
|
|
58
|
+
const gaps = [];
|
|
59
|
+
for (const domain of options.requiredDomains) {
|
|
60
|
+
const missing = [];
|
|
61
|
+
if (!exporterDomains.has(domain)) missing.push("exporter");
|
|
62
|
+
if (!anonymiserDomains.has(domain)) missing.push("anonymiser");
|
|
63
|
+
if (missing.length > 0) gaps.push(` ${domain}: missing ${missing.join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
if (gaps.length > 0) {
|
|
66
|
+
throw new GdprRegistryError(
|
|
67
|
+
`GDPR registry incomplete:
|
|
68
|
+
${gaps.join("\n")}`,
|
|
69
|
+
"incomplete_registry"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function dryRunExport(ctx) {
|
|
74
|
+
const result = [];
|
|
75
|
+
for (const exporter of exporters) {
|
|
76
|
+
const files = [];
|
|
77
|
+
for await (const item of exporter.export(ctx)) {
|
|
78
|
+
files.push(item.filename);
|
|
79
|
+
}
|
|
80
|
+
result.push({ domain: exporter.domain, files });
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
5
84
|
return {
|
|
6
|
-
registerExporter(e) {
|
|
85
|
+
registerExporter: (e) => {
|
|
7
86
|
exporters.push(e);
|
|
8
87
|
},
|
|
9
|
-
registerAnonymiser(a) {
|
|
88
|
+
registerAnonymiser: (a) => {
|
|
10
89
|
anonymisers.push(a);
|
|
11
90
|
},
|
|
12
|
-
listExporters()
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
91
|
+
listExporters: () => exporters,
|
|
92
|
+
listAnonymisers: () => anonymisers,
|
|
93
|
+
exportUser,
|
|
94
|
+
anonymiseUser,
|
|
95
|
+
validateRegistry,
|
|
96
|
+
dryRunExport
|
|
18
97
|
};
|
|
19
98
|
}
|
|
20
99
|
export {
|
|
100
|
+
GdprRegistryError,
|
|
21
101
|
createGdprRegistry
|
|
22
102
|
};
|
|
@@ -8,7 +8,7 @@ declare class MemoryRateLimitStore implements RateLimitStore {
|
|
|
8
8
|
private sweepTimer;
|
|
9
9
|
constructor(opts?: MemoryStoreOptions);
|
|
10
10
|
get(key: string): Promise<RateLimitState | null>;
|
|
11
|
-
increment(key: string, windowMs: number): Promise<RateLimitState>;
|
|
11
|
+
increment(key: string, windowMs: number, _max?: number): Promise<RateLimitState>;
|
|
12
12
|
reset(key: string): Promise<void>;
|
|
13
13
|
size(): number;
|
|
14
14
|
sweep(): void;
|
|
@@ -16,6 +16,50 @@ declare class MemoryRateLimitStore implements RateLimitStore {
|
|
|
16
16
|
}
|
|
17
17
|
declare function getDefaultMemoryStore(): MemoryRateLimitStore;
|
|
18
18
|
|
|
19
|
+
type BucketRow = {
|
|
20
|
+
id: string;
|
|
21
|
+
tokens: number;
|
|
22
|
+
last_refill_at: string;
|
|
23
|
+
max_tokens: number;
|
|
24
|
+
refill_rate_per_ms: number;
|
|
25
|
+
};
|
|
26
|
+
type SvcLike = {
|
|
27
|
+
findOneBy(criteria: Record<string, unknown>): Promise<BucketRow | null>;
|
|
28
|
+
insert(data: Partial<BucketRow> | Partial<BucketRow>[]): Promise<BucketRow[]>;
|
|
29
|
+
updateById(id: unknown, patch: Partial<BucketRow>): Promise<BucketRow[]>;
|
|
30
|
+
deleteById(id: unknown): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
interface ConnectRateLimitStoreOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Factory that resolves to a hazo_connect HazoConnectAdapter. Used in production.
|
|
35
|
+
* Call createCrudService from hazo_connect/server before passing.
|
|
36
|
+
*/
|
|
37
|
+
getAdapter?: () => unknown | Promise<unknown>;
|
|
38
|
+
/** For testing: inject a CrudService stub directly. */
|
|
39
|
+
_svc?: () => SvcLike | Promise<SvcLike>;
|
|
40
|
+
/** Injectable clock. Defaults to Date.now. */
|
|
41
|
+
now?: () => number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Rate-limit store backed by hazo_connect (PostgreSQL or SQLite).
|
|
45
|
+
* Uses a token bucket algorithm — no boundary burst, continuous token refill.
|
|
46
|
+
*
|
|
47
|
+
* Requires the hazo_rl_buckets table from migrations/001_hazo_rl_buckets.sql.
|
|
48
|
+
*
|
|
49
|
+
* Token bucket → RateLimitState mapping:
|
|
50
|
+
* count = max - tokensAfterConsumption
|
|
51
|
+
* count <= max → allowed (tokensAfter >= 0)
|
|
52
|
+
* count > max → denied (tokensAfter < 0)
|
|
53
|
+
*/
|
|
54
|
+
declare class ConnectRateLimitStore implements RateLimitStore {
|
|
55
|
+
#private;
|
|
56
|
+
constructor(opts: ConnectRateLimitStoreOptions);
|
|
57
|
+
private svc;
|
|
58
|
+
get(key: string): Promise<RateLimitState | null>;
|
|
59
|
+
increment(key: string, windowMs: number, max?: number): Promise<RateLimitState>;
|
|
60
|
+
reset(key: string): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
19
63
|
interface RateLimitState {
|
|
20
64
|
count: number;
|
|
21
65
|
resetAt: number;
|
|
@@ -28,7 +72,8 @@ interface RateLimitDecision {
|
|
|
28
72
|
}
|
|
29
73
|
interface RateLimitStore {
|
|
30
74
|
get(key: string): Promise<RateLimitState | null>;
|
|
31
|
-
|
|
75
|
+
/** max is optional for backward compat; ConnectRateLimitStore requires it. */
|
|
76
|
+
increment(key: string, windowMs: number, max?: number): Promise<RateLimitState>;
|
|
32
77
|
reset(key: string): Promise<void>;
|
|
33
78
|
}
|
|
34
79
|
interface RateLimiterOptions {
|
|
@@ -51,4 +96,4 @@ interface WithRateLimitOptions extends RateLimiterOptions {
|
|
|
51
96
|
type AnyRouteHandler = (...args: any[]) => Promise<Response>;
|
|
52
97
|
declare function withRateLimit<H extends AnyRouteHandler>(handler: H, opts: WithRateLimitOptions): H;
|
|
53
98
|
|
|
54
|
-
export { MemoryRateLimitStore, type RateLimitDecision, type RateLimitState, type RateLimitStore, type RateLimiter, type RateLimiterOptions, type WithRateLimitOptions, checkRateLimit, createRateLimiter, defaultKeyResolver, getDefaultMemoryStore, withRateLimit };
|
|
99
|
+
export { ConnectRateLimitStore, type ConnectRateLimitStoreOptions, MemoryRateLimitStore, type RateLimitDecision, type RateLimitState, type RateLimitStore, type RateLimiter, type RateLimiterOptions, type WithRateLimitOptions, checkRateLimit, createRateLimiter, defaultKeyResolver, getDefaultMemoryStore, withRateLimit };
|
package/dist/ratelimit/index.js
CHANGED
|
@@ -21,7 +21,7 @@ var MemoryRateLimitStore = class {
|
|
|
21
21
|
}
|
|
22
22
|
return { ...e.state };
|
|
23
23
|
}
|
|
24
|
-
async increment(key, windowMs) {
|
|
24
|
+
async increment(key, windowMs, _max) {
|
|
25
25
|
const t = this.now();
|
|
26
26
|
const existing = this.map.get(key);
|
|
27
27
|
if (!existing || existing.expiresAt <= t) {
|
|
@@ -57,6 +57,85 @@ function getDefaultMemoryStore() {
|
|
|
57
57
|
return defaultStore;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// src/ratelimit/store-connect.ts
|
|
61
|
+
var ConnectRateLimitStore = class {
|
|
62
|
+
#getSvc;
|
|
63
|
+
#now;
|
|
64
|
+
constructor(opts) {
|
|
65
|
+
if (opts._svc) {
|
|
66
|
+
this.#getSvc = opts._svc;
|
|
67
|
+
} else if (opts.getAdapter) {
|
|
68
|
+
const getAdapter = opts.getAdapter;
|
|
69
|
+
this.#getSvc = async () => {
|
|
70
|
+
const { createCrudService } = await import("hazo_connect/server");
|
|
71
|
+
const adapter = await getAdapter();
|
|
72
|
+
return createCrudService(adapter, "hazo_rl_buckets", {
|
|
73
|
+
autoId: { enabled: false, column: "id" }
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
} else {
|
|
77
|
+
throw new Error("ConnectRateLimitStore: provide getAdapter or _svc");
|
|
78
|
+
}
|
|
79
|
+
this.#now = opts.now ?? Date.now;
|
|
80
|
+
}
|
|
81
|
+
async svc() {
|
|
82
|
+
return this.#getSvc();
|
|
83
|
+
}
|
|
84
|
+
async get(key) {
|
|
85
|
+
const svc = await this.svc();
|
|
86
|
+
const row = await svc.findOneBy({ id: key });
|
|
87
|
+
if (!row) return null;
|
|
88
|
+
const now = this.#now();
|
|
89
|
+
const elapsedMs = now - new Date(row.last_refill_at).getTime();
|
|
90
|
+
const tokens = Math.min(row.max_tokens, row.tokens + elapsedMs * row.refill_rate_per_ms);
|
|
91
|
+
const storedTokens = Math.max(0, tokens);
|
|
92
|
+
const resetAt = now + Math.ceil(Math.max(0, 1 - storedTokens) / row.refill_rate_per_ms);
|
|
93
|
+
return { count: Math.max(0, row.max_tokens - tokens), resetAt };
|
|
94
|
+
}
|
|
95
|
+
async increment(key, windowMs, max) {
|
|
96
|
+
if (max === void 0) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"ConnectRateLimitStore requires max in increment(). Ensure createRateLimiter / checkRateLimit receives { max }."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const svc = await this.svc();
|
|
102
|
+
const now = this.#now();
|
|
103
|
+
const refillRatePerMs = max / windowMs;
|
|
104
|
+
const row = await svc.findOneBy({ id: key });
|
|
105
|
+
let tokensAfter;
|
|
106
|
+
if (!row) {
|
|
107
|
+
tokensAfter = max - 1;
|
|
108
|
+
await svc.insert({
|
|
109
|
+
id: key,
|
|
110
|
+
tokens: Math.max(0, tokensAfter),
|
|
111
|
+
last_refill_at: new Date(now).toISOString(),
|
|
112
|
+
max_tokens: max,
|
|
113
|
+
refill_rate_per_ms: refillRatePerMs
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
const elapsedMs = now - new Date(row.last_refill_at).getTime();
|
|
117
|
+
const refilledTokens = Math.min(max, row.tokens + elapsedMs * refillRatePerMs);
|
|
118
|
+
tokensAfter = refilledTokens - 1;
|
|
119
|
+
await svc.updateById(key, {
|
|
120
|
+
tokens: Math.max(0, tokensAfter),
|
|
121
|
+
last_refill_at: new Date(now).toISOString(),
|
|
122
|
+
max_tokens: max,
|
|
123
|
+
refill_rate_per_ms: refillRatePerMs
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const count = max - tokensAfter;
|
|
127
|
+
const storedTokens = Math.max(0, tokensAfter);
|
|
128
|
+
const refillNeeded = Math.max(0, 1 - storedTokens);
|
|
129
|
+
const resetAt = now + Math.ceil(refillNeeded / refillRatePerMs);
|
|
130
|
+
return { count, resetAt };
|
|
131
|
+
}
|
|
132
|
+
async reset(key) {
|
|
133
|
+
const svc = await this.svc();
|
|
134
|
+
const row = await svc.findOneBy({ id: key });
|
|
135
|
+
if (row) await svc.deleteById(key);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
60
139
|
// src/ratelimit/index.ts
|
|
61
140
|
function defaultKeyResolver(req) {
|
|
62
141
|
const headers = req.headers;
|
|
@@ -73,7 +152,7 @@ function createRateLimiter(opts) {
|
|
|
73
152
|
const keyResolver = opts.keyResolver ?? defaultKeyResolver;
|
|
74
153
|
const now = opts.now ?? Date.now;
|
|
75
154
|
async function checkKey(key) {
|
|
76
|
-
const state = await store.increment(key, opts.windowMs);
|
|
155
|
+
const state = await store.increment(key, opts.windowMs, opts.max);
|
|
77
156
|
const allowed = state.count <= opts.max;
|
|
78
157
|
const remaining = Math.max(0, opts.max - state.count);
|
|
79
158
|
const retryAfterSeconds = Math.max(0, Math.ceil((state.resetAt - now()) / 1e3));
|
|
@@ -113,6 +192,7 @@ function withRateLimit(handler, opts) {
|
|
|
113
192
|
return fn;
|
|
114
193
|
}
|
|
115
194
|
export {
|
|
195
|
+
ConnectRateLimitStore,
|
|
116
196
|
MemoryRateLimitStore,
|
|
117
197
|
checkRateLimit,
|
|
118
198
|
createRateLimiter,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_secure",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Security & compliance primitives — SSRF-safe fetch, rate limiting, field-level encryption, GDPR orchestration. Four subpath modules, pick the ones you need.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -54,14 +54,25 @@
|
|
|
54
54
|
"install:test-app": "cd test-app && npm install",
|
|
55
55
|
"prepublishOnly": "npm run build"
|
|
56
56
|
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"undici": "^7.3.0"
|
|
59
|
+
},
|
|
57
60
|
"peerDependencies": {
|
|
58
61
|
"hazo_logs": "^1.1.0",
|
|
62
|
+
"hazo_connect": "^2.6.0",
|
|
59
63
|
"hazo_jobs": "^0.7.0",
|
|
60
64
|
"hazo_files": "^1.4.7"
|
|
61
65
|
},
|
|
62
66
|
"peerDependenciesMeta": {
|
|
63
|
-
"
|
|
64
|
-
|
|
67
|
+
"hazo_connect": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"hazo_jobs": {
|
|
71
|
+
"optional": true
|
|
72
|
+
},
|
|
73
|
+
"hazo_files": {
|
|
74
|
+
"optional": true
|
|
75
|
+
}
|
|
65
76
|
},
|
|
66
77
|
"devDependencies": {
|
|
67
78
|
"@types/jest": "^29.0.0",
|