@vivero/stoma 0.1.0-rc.10
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/CHANGELOG.md +196 -0
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/dist/adapters/bun.d.ts +9 -0
- package/dist/adapters/bun.js +8 -0
- package/dist/adapters/bun.js.map +1 -0
- package/dist/adapters/cloudflare.d.ts +49 -0
- package/dist/adapters/cloudflare.js +85 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/deno.d.ts +9 -0
- package/dist/adapters/deno.js +8 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/durable-object.d.ts +63 -0
- package/dist/adapters/durable-object.js +46 -0
- package/dist/adapters/durable-object.js.map +1 -0
- package/dist/adapters/index.d.ts +13 -0
- package/dist/adapters/index.js +53 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/memory.d.ts +9 -0
- package/dist/adapters/memory.js +14 -0
- package/dist/adapters/memory.js.map +1 -0
- package/dist/adapters/node.d.ts +9 -0
- package/dist/adapters/node.js +8 -0
- package/dist/adapters/node.js.map +1 -0
- package/dist/adapters/postgres.d.ts +109 -0
- package/dist/adapters/postgres.js +242 -0
- package/dist/adapters/postgres.js.map +1 -0
- package/dist/adapters/redis.d.ts +116 -0
- package/dist/adapters/redis.js +194 -0
- package/dist/adapters/redis.js.map +1 -0
- package/dist/adapters/testing.d.ts +32 -0
- package/dist/adapters/testing.js +33 -0
- package/dist/adapters/testing.js.map +1 -0
- package/dist/adapters/types.d.ts +4 -0
- package/dist/adapters/types.js +1 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/config/index.d.ts +11 -0
- package/dist/config/index.js +21 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/merge.d.ts +48 -0
- package/dist/config/merge.js +83 -0
- package/dist/config/merge.js.map +1 -0
- package/dist/config/schema.d.ts +254 -0
- package/dist/config/schema.js +109 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/core/errors.d.ts +66 -0
- package/dist/core/errors.js +47 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/gateway.d.ts +44 -0
- package/dist/core/gateway.js +400 -0
- package/dist/core/gateway.js.map +1 -0
- package/dist/core/health.d.ts +78 -0
- package/dist/core/health.js +65 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/pipeline.d.ts +62 -0
- package/dist/core/pipeline.js +214 -0
- package/dist/core/pipeline.js.map +1 -0
- package/dist/core/protocol.d.ts +4 -0
- package/dist/core/protocol.js +1 -0
- package/dist/core/protocol.js.map +1 -0
- package/dist/core/scope.d.ts +67 -0
- package/dist/core/scope.js +44 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/core/types.d.ts +252 -0
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +158 -0
- package/dist/index.js.map +1 -0
- package/dist/observability/admin.d.ts +32 -0
- package/dist/observability/admin.js +85 -0
- package/dist/observability/admin.js.map +1 -0
- package/dist/observability/metrics.d.ts +78 -0
- package/dist/observability/metrics.js +107 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/observability/tracing.d.ts +149 -0
- package/dist/observability/tracing.js +191 -0
- package/dist/observability/tracing.js.map +1 -0
- package/dist/policies/auth/api-key-auth.d.ts +64 -0
- package/dist/policies/auth/api-key-auth.js +93 -0
- package/dist/policies/auth/api-key-auth.js.map +1 -0
- package/dist/policies/auth/basic-auth.d.ts +33 -0
- package/dist/policies/auth/basic-auth.js +96 -0
- package/dist/policies/auth/basic-auth.js.map +1 -0
- package/dist/policies/auth/crypto.d.ts +29 -0
- package/dist/policies/auth/crypto.js +100 -0
- package/dist/policies/auth/crypto.js.map +1 -0
- package/dist/policies/auth/generate-http-signature.d.ts +30 -0
- package/dist/policies/auth/generate-http-signature.js +79 -0
- package/dist/policies/auth/generate-http-signature.js.map +1 -0
- package/dist/policies/auth/generate-jwt.d.ts +44 -0
- package/dist/policies/auth/generate-jwt.js +99 -0
- package/dist/policies/auth/generate-jwt.js.map +1 -0
- package/dist/policies/auth/http-signature-base.d.ts +55 -0
- package/dist/policies/auth/http-signature-base.js +140 -0
- package/dist/policies/auth/http-signature-base.js.map +1 -0
- package/dist/policies/auth/jws.d.ts +46 -0
- package/dist/policies/auth/jws.js +317 -0
- package/dist/policies/auth/jws.js.map +1 -0
- package/dist/policies/auth/jwt-auth.d.ts +64 -0
- package/dist/policies/auth/jwt-auth.js +266 -0
- package/dist/policies/auth/jwt-auth.js.map +1 -0
- package/dist/policies/auth/oauth2.d.ts +38 -0
- package/dist/policies/auth/oauth2.js +254 -0
- package/dist/policies/auth/oauth2.js.map +1 -0
- package/dist/policies/auth/rbac.d.ts +30 -0
- package/dist/policies/auth/rbac.js +115 -0
- package/dist/policies/auth/rbac.js.map +1 -0
- package/dist/policies/auth/verify-http-signature.d.ts +30 -0
- package/dist/policies/auth/verify-http-signature.js +147 -0
- package/dist/policies/auth/verify-http-signature.js.map +1 -0
- package/dist/policies/index.d.ts +51 -0
- package/dist/policies/index.js +109 -0
- package/dist/policies/index.js.map +1 -0
- package/dist/policies/mock.d.ts +60 -0
- package/dist/policies/mock.js +29 -0
- package/dist/policies/mock.js.map +1 -0
- package/dist/policies/observability/assign-metrics.d.ts +37 -0
- package/dist/policies/observability/assign-metrics.js +29 -0
- package/dist/policies/observability/assign-metrics.js.map +1 -0
- package/dist/policies/observability/metrics-reporter.d.ts +25 -0
- package/dist/policies/observability/metrics-reporter.js +62 -0
- package/dist/policies/observability/metrics-reporter.js.map +1 -0
- package/dist/policies/observability/request-log.d.ts +135 -0
- package/dist/policies/observability/request-log.js +134 -0
- package/dist/policies/observability/request-log.js.map +1 -0
- package/dist/policies/observability/server-timing.d.ts +35 -0
- package/dist/policies/observability/server-timing.js +89 -0
- package/dist/policies/observability/server-timing.js.map +1 -0
- package/dist/policies/proxy.d.ts +59 -0
- package/dist/policies/proxy.js +47 -0
- package/dist/policies/proxy.js.map +1 -0
- package/dist/policies/resilience/circuit-breaker.d.ts +4 -0
- package/dist/policies/resilience/circuit-breaker.js +280 -0
- package/dist/policies/resilience/circuit-breaker.js.map +1 -0
- package/dist/policies/resilience/latency-injection.d.ts +35 -0
- package/dist/policies/resilience/latency-injection.js +26 -0
- package/dist/policies/resilience/latency-injection.js.map +1 -0
- package/dist/policies/resilience/retry.d.ts +71 -0
- package/dist/policies/resilience/retry.js +79 -0
- package/dist/policies/resilience/retry.js.map +1 -0
- package/dist/policies/resilience/timeout.d.ts +32 -0
- package/dist/policies/resilience/timeout.js +46 -0
- package/dist/policies/resilience/timeout.js.map +1 -0
- package/dist/policies/sdk/define-policy.d.ts +176 -0
- package/dist/policies/sdk/define-policy.js +42 -0
- package/dist/policies/sdk/define-policy.js.map +1 -0
- package/dist/policies/sdk/helpers.d.ts +132 -0
- package/dist/policies/sdk/helpers.js +87 -0
- package/dist/policies/sdk/helpers.js.map +1 -0
- package/dist/policies/sdk/index.d.ts +10 -0
- package/dist/policies/sdk/index.js +35 -0
- package/dist/policies/sdk/index.js.map +1 -0
- package/dist/policies/sdk/priority.d.ts +44 -0
- package/dist/policies/sdk/priority.js +36 -0
- package/dist/policies/sdk/priority.js.map +1 -0
- package/dist/policies/sdk/testing.d.ts +53 -0
- package/dist/policies/sdk/testing.js +41 -0
- package/dist/policies/sdk/testing.js.map +1 -0
- package/dist/policies/sdk/trace.d.ts +73 -0
- package/dist/policies/sdk/trace.js +25 -0
- package/dist/policies/sdk/trace.js.map +1 -0
- package/dist/policies/traffic/cache.d.ts +4 -0
- package/dist/policies/traffic/cache.js +224 -0
- package/dist/policies/traffic/cache.js.map +1 -0
- package/dist/policies/traffic/dynamic-routing.d.ts +54 -0
- package/dist/policies/traffic/dynamic-routing.js +36 -0
- package/dist/policies/traffic/dynamic-routing.js.map +1 -0
- package/dist/policies/traffic/geo-ip-filter.d.ts +37 -0
- package/dist/policies/traffic/geo-ip-filter.js +74 -0
- package/dist/policies/traffic/geo-ip-filter.js.map +1 -0
- package/dist/policies/traffic/http-callout.d.ts +59 -0
- package/dist/policies/traffic/http-callout.js +69 -0
- package/dist/policies/traffic/http-callout.js.map +1 -0
- package/dist/policies/traffic/interrupt.d.ts +46 -0
- package/dist/policies/traffic/interrupt.js +38 -0
- package/dist/policies/traffic/interrupt.js.map +1 -0
- package/dist/policies/traffic/ip-filter.d.ts +47 -0
- package/dist/policies/traffic/ip-filter.js +57 -0
- package/dist/policies/traffic/ip-filter.js.map +1 -0
- package/dist/policies/traffic/json-threat-protection.d.ts +51 -0
- package/dist/policies/traffic/json-threat-protection.js +173 -0
- package/dist/policies/traffic/json-threat-protection.js.map +1 -0
- package/dist/policies/traffic/rate-limit.d.ts +4 -0
- package/dist/policies/traffic/rate-limit.js +145 -0
- package/dist/policies/traffic/rate-limit.js.map +1 -0
- package/dist/policies/traffic/regex-threat-protection.d.ts +54 -0
- package/dist/policies/traffic/regex-threat-protection.js +109 -0
- package/dist/policies/traffic/regex-threat-protection.js.map +1 -0
- package/dist/policies/traffic/request-limit.d.ts +27 -0
- package/dist/policies/traffic/request-limit.js +41 -0
- package/dist/policies/traffic/request-limit.js.map +1 -0
- package/dist/policies/traffic/resource-filter.d.ts +38 -0
- package/dist/policies/traffic/resource-filter.js +184 -0
- package/dist/policies/traffic/resource-filter.js.map +1 -0
- package/dist/policies/traffic/ssl-enforce.d.ts +27 -0
- package/dist/policies/traffic/ssl-enforce.js +38 -0
- package/dist/policies/traffic/ssl-enforce.js.map +1 -0
- package/dist/policies/traffic/traffic-shadow.d.ts +40 -0
- package/dist/policies/traffic/traffic-shadow.js +87 -0
- package/dist/policies/traffic/traffic-shadow.js.map +1 -0
- package/dist/policies/transform/assign-attributes.d.ts +33 -0
- package/dist/policies/transform/assign-attributes.js +38 -0
- package/dist/policies/transform/assign-attributes.js.map +1 -0
- package/dist/policies/transform/assign-content.d.ts +40 -0
- package/dist/policies/transform/assign-content.js +185 -0
- package/dist/policies/transform/assign-content.js.map +1 -0
- package/dist/policies/transform/cors.d.ts +57 -0
- package/dist/policies/transform/cors.js +23 -0
- package/dist/policies/transform/cors.js.map +1 -0
- package/dist/policies/transform/json-validation.d.ts +50 -0
- package/dist/policies/transform/json-validation.js +125 -0
- package/dist/policies/transform/json-validation.js.map +1 -0
- package/dist/policies/transform/override-method.d.ts +33 -0
- package/dist/policies/transform/override-method.js +48 -0
- package/dist/policies/transform/override-method.js.map +1 -0
- package/dist/policies/transform/request-validation.d.ts +59 -0
- package/dist/policies/transform/request-validation.js +121 -0
- package/dist/policies/transform/request-validation.js.map +1 -0
- package/dist/policies/transform/transform.d.ts +75 -0
- package/dist/policies/transform/transform.js +116 -0
- package/dist/policies/transform/transform.js.map +1 -0
- package/dist/policies/types.d.ts +4 -0
- package/dist/policies/types.js +1 -0
- package/dist/policies/types.js.map +1 -0
- package/dist/protocol-2fD3DJrL.d.ts +725 -0
- package/dist/utils/cidr.d.ts +58 -0
- package/dist/utils/cidr.js +107 -0
- package/dist/utils/cidr.js.map +1 -0
- package/dist/utils/debug.d.ts +1 -0
- package/dist/utils/debug.js +13 -0
- package/dist/utils/debug.js.map +1 -0
- package/dist/utils/headers.d.ts +68 -0
- package/dist/utils/headers.js +25 -0
- package/dist/utils/headers.js.map +1 -0
- package/dist/utils/ip.d.ts +64 -0
- package/dist/utils/ip.js +29 -0
- package/dist/utils/ip.js.map +1 -0
- package/dist/utils/redact.d.ts +30 -0
- package/dist/utils/redact.js +52 -0
- package/dist/utils/redact.js.map +1 -0
- package/dist/utils/request-id.d.ts +11 -0
- package/dist/utils/request-id.js +7 -0
- package/dist/utils/request-id.js.map +1 -0
- package/dist/utils/timing-safe.d.ts +31 -0
- package/dist/utils/timing-safe.js +17 -0
- package/dist/utils/timing-safe.js.map +1 -0
- package/dist/utils/timing.d.ts +27 -0
- package/dist/utils/timing.js +12 -0
- package/dist/utils/timing.js.map +1 -0
- package/dist/utils/trace-context.d.ts +51 -0
- package/dist/utils/trace-context.js +37 -0
- package/dist/utils/trace-context.js.map +1 -0
- package/package.json +213 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { GatewayError } from "../../core/errors";
|
|
2
|
+
import { definePolicy, Priority } from "../sdk";
|
|
3
|
+
function validateJsonStructure(value, config, currentDepth = 0) {
|
|
4
|
+
if (currentDepth > config.maxDepth) {
|
|
5
|
+
throw new GatewayError(400, "json_threat", "JSON exceeds maximum depth");
|
|
6
|
+
}
|
|
7
|
+
if (typeof value === "string" && value.length > config.maxStringLength) {
|
|
8
|
+
throw new GatewayError(
|
|
9
|
+
400,
|
|
10
|
+
"json_threat",
|
|
11
|
+
"String value exceeds maximum length"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(value)) {
|
|
15
|
+
if (value.length > config.maxArraySize) {
|
|
16
|
+
throw new GatewayError(400, "json_threat", "Array exceeds maximum size");
|
|
17
|
+
}
|
|
18
|
+
for (const item of value) {
|
|
19
|
+
validateJsonStructure(item, config, currentDepth + 1);
|
|
20
|
+
}
|
|
21
|
+
} else if (value !== null && typeof value === "object") {
|
|
22
|
+
const keys = Object.keys(value);
|
|
23
|
+
if (keys.length > config.maxKeys) {
|
|
24
|
+
throw new GatewayError(
|
|
25
|
+
400,
|
|
26
|
+
"json_threat",
|
|
27
|
+
"Object exceeds maximum key count"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
if (key.length > config.maxStringLength) {
|
|
32
|
+
throw new GatewayError(
|
|
33
|
+
400,
|
|
34
|
+
"json_threat",
|
|
35
|
+
"String value exceeds maximum length"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
validateJsonStructure(
|
|
39
|
+
value[key],
|
|
40
|
+
config,
|
|
41
|
+
currentDepth + 1
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const jsonThreatProtection = /* @__PURE__ */ definePolicy({
|
|
47
|
+
name: "json-threat-protection",
|
|
48
|
+
priority: Priority.EARLY,
|
|
49
|
+
phases: ["request-body"],
|
|
50
|
+
defaults: {
|
|
51
|
+
maxDepth: 20,
|
|
52
|
+
maxKeys: 100,
|
|
53
|
+
maxStringLength: 1e4,
|
|
54
|
+
maxArraySize: 100,
|
|
55
|
+
maxBodySize: 1048576,
|
|
56
|
+
contentTypes: ["application/json"]
|
|
57
|
+
},
|
|
58
|
+
handler: async (c, next, { config, debug }) => {
|
|
59
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
60
|
+
const matchedType = config.contentTypes.some(
|
|
61
|
+
(ct) => contentType.includes(ct)
|
|
62
|
+
);
|
|
63
|
+
if (!matchedType) {
|
|
64
|
+
debug("skipping - content type %s not inspected", contentType);
|
|
65
|
+
await next();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const contentLength = c.req.header("content-length");
|
|
69
|
+
if (contentLength !== void 0) {
|
|
70
|
+
const length = Number.parseInt(contentLength, 10);
|
|
71
|
+
if (!Number.isNaN(length) && length > config.maxBodySize) {
|
|
72
|
+
debug("body size %d exceeds max %d", length, config.maxBodySize);
|
|
73
|
+
throw new GatewayError(
|
|
74
|
+
413,
|
|
75
|
+
"body_too_large",
|
|
76
|
+
"Request body exceeds maximum size"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const cloned = c.req.raw.clone();
|
|
81
|
+
const text = await cloned.text();
|
|
82
|
+
if (!text) {
|
|
83
|
+
debug("empty body - passing through");
|
|
84
|
+
await next();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(text);
|
|
90
|
+
} catch {
|
|
91
|
+
debug("invalid JSON");
|
|
92
|
+
throw new GatewayError(
|
|
93
|
+
400,
|
|
94
|
+
"invalid_json",
|
|
95
|
+
"Invalid JSON in request body"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
validateJsonStructure(parsed, {
|
|
99
|
+
maxDepth: config.maxDepth,
|
|
100
|
+
maxKeys: config.maxKeys,
|
|
101
|
+
maxStringLength: config.maxStringLength,
|
|
102
|
+
maxArraySize: config.maxArraySize
|
|
103
|
+
});
|
|
104
|
+
debug("JSON structure validated");
|
|
105
|
+
await next();
|
|
106
|
+
},
|
|
107
|
+
evaluate: {
|
|
108
|
+
onRequest: async (input, { config, debug }) => {
|
|
109
|
+
const contentType = input.headers.get("content-type") ?? "";
|
|
110
|
+
const matchedType = config.contentTypes.some(
|
|
111
|
+
(ct) => contentType.includes(ct)
|
|
112
|
+
);
|
|
113
|
+
if (!matchedType) {
|
|
114
|
+
debug("skipping - content type %s not inspected", contentType);
|
|
115
|
+
return { action: "continue" };
|
|
116
|
+
}
|
|
117
|
+
const contentLength = input.headers.get("content-length");
|
|
118
|
+
if (contentLength) {
|
|
119
|
+
const length = Number.parseInt(contentLength, 10);
|
|
120
|
+
if (!Number.isNaN(length) && length > config.maxBodySize) {
|
|
121
|
+
debug("body size %d exceeds max %d", length, config.maxBodySize);
|
|
122
|
+
return {
|
|
123
|
+
action: "reject",
|
|
124
|
+
status: 413,
|
|
125
|
+
code: "body_too_large",
|
|
126
|
+
message: "Request body exceeds maximum size"
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
if (!input.body) {
|
|
133
|
+
debug("empty body - passing through");
|
|
134
|
+
return { action: "continue" };
|
|
135
|
+
}
|
|
136
|
+
const bodyStr = typeof input.body === "string" ? input.body : new TextDecoder().decode(input.body);
|
|
137
|
+
parsed = JSON.parse(bodyStr);
|
|
138
|
+
} catch {
|
|
139
|
+
debug("invalid JSON");
|
|
140
|
+
return {
|
|
141
|
+
action: "reject",
|
|
142
|
+
status: 400,
|
|
143
|
+
code: "invalid_json",
|
|
144
|
+
message: "Invalid JSON in request body"
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
validateJsonStructure(parsed, {
|
|
149
|
+
maxDepth: config.maxDepth,
|
|
150
|
+
maxKeys: config.maxKeys,
|
|
151
|
+
maxStringLength: config.maxStringLength,
|
|
152
|
+
maxArraySize: config.maxArraySize
|
|
153
|
+
});
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err instanceof GatewayError) {
|
|
156
|
+
return {
|
|
157
|
+
action: "reject",
|
|
158
|
+
status: err.statusCode,
|
|
159
|
+
code: err.code,
|
|
160
|
+
message: err.message
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
debug("JSON structure validated");
|
|
166
|
+
return { action: "continue" };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
export {
|
|
171
|
+
jsonThreatProtection
|
|
172
|
+
};
|
|
173
|
+
//# sourceMappingURL=json-threat-protection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/policies/traffic/json-threat-protection.ts"],"sourcesContent":["/**\n * JSON threat protection policy - structural limits on request bodies.\n *\n * Protects against JSON-based attacks by enforcing maximum depth, key count,\n * string length, array size, and raw body size. Zero external dependencies -\n * uses a recursive JSON walker.\n *\n * @module json-threat-protection\n */\n\nimport { GatewayError } from \"../../core/errors\";\nimport { definePolicy, Priority } from \"../sdk\";\nimport type { PolicyConfig } from \"../types\";\n\nexport interface JsonThreatProtectionConfig extends PolicyConfig {\n /** Maximum nesting depth. Default: `20`. */\n maxDepth?: number;\n /** Maximum number of keys per object. Default: `100`. */\n maxKeys?: number;\n /** Maximum string value length (also applies to object keys). Default: `10000`. */\n maxStringLength?: number;\n /** Maximum array length. Default: `100`. */\n maxArraySize?: number;\n /** Maximum raw body size in bytes. Checked BEFORE parsing. Default: `1048576` (1 MB). */\n maxBodySize?: number;\n /**\n * Content types to inspect.\n * Requests with other content types pass through without inspection.\n * Default: `[\"application/json\"]`.\n */\n contentTypes?: string[];\n}\n\n/**\n * Recursively validate the structural constraints of a parsed JSON value.\n *\n * Throws a `GatewayError` with code `\"json_threat\"` if any limit is exceeded.\n */\nfunction validateJsonStructure(\n value: unknown,\n config: {\n maxDepth: number;\n maxKeys: number;\n maxStringLength: number;\n maxArraySize: number;\n },\n currentDepth = 0\n): void {\n if (currentDepth > config.maxDepth) {\n throw new GatewayError(400, \"json_threat\", \"JSON exceeds maximum depth\");\n }\n\n if (typeof value === \"string\" && value.length > config.maxStringLength) {\n throw new GatewayError(\n 400,\n \"json_threat\",\n \"String value exceeds maximum length\"\n );\n }\n\n if (Array.isArray(value)) {\n if (value.length > config.maxArraySize) {\n throw new GatewayError(400, \"json_threat\", \"Array exceeds maximum size\");\n }\n for (const item of value) {\n validateJsonStructure(item, config, currentDepth + 1);\n }\n } else if (value !== null && typeof value === \"object\") {\n const keys = Object.keys(value as Record<string, unknown>);\n if (keys.length > config.maxKeys) {\n throw new GatewayError(\n 400,\n \"json_threat\",\n \"Object exceeds maximum key count\"\n );\n }\n for (const key of keys) {\n if (key.length > config.maxStringLength) {\n throw new GatewayError(\n 400,\n \"json_threat\",\n \"String value exceeds maximum length\"\n );\n }\n validateJsonStructure(\n (value as Record<string, unknown>)[key],\n config,\n currentDepth + 1\n );\n }\n }\n}\n\n/**\n * JSON threat protection policy.\n *\n * Enforces structural limits on JSON request bodies to prevent abuse\n * from deeply nested objects, excessively large arrays, long strings,\n * or oversized payloads. Runs at EARLY priority to reject malicious\n * payloads before they reach business logic.\n *\n * @example\n * ```ts\n * import { jsonThreatProtection } from \"@vivero/stoma\";\n *\n * // Default limits (20 depth, 100 keys, 10K string, 100 array, 1MB body)\n * jsonThreatProtection();\n *\n * // Strict limits for a public API\n * jsonThreatProtection({\n * maxDepth: 5,\n * maxKeys: 20,\n * maxStringLength: 1000,\n * maxArraySize: 50,\n * maxBodySize: 102400, // 100KB\n * });\n * ```\n */\nexport const jsonThreatProtection =\n /*#__PURE__*/ definePolicy<JsonThreatProtectionConfig>({\n name: \"json-threat-protection\",\n priority: Priority.EARLY,\n phases: [\"request-body\"],\n defaults: {\n maxDepth: 20,\n maxKeys: 100,\n maxStringLength: 10000,\n maxArraySize: 100,\n maxBodySize: 1048576,\n contentTypes: [\"application/json\"],\n },\n handler: async (c, next, { config, debug }) => {\n const contentType = c.req.header(\"content-type\") ?? \"\";\n const matchedType = config.contentTypes!.some((ct) =>\n contentType.includes(ct)\n );\n\n if (!matchedType) {\n debug(\"skipping - content type %s not inspected\", contentType);\n await next();\n return;\n }\n\n // Check Content-Length against maxBodySize BEFORE parsing\n const contentLength = c.req.header(\"content-length\");\n if (contentLength !== undefined) {\n const length = Number.parseInt(contentLength, 10);\n if (!Number.isNaN(length) && length > config.maxBodySize!) {\n debug(\"body size %d exceeds max %d\", length, config.maxBodySize);\n throw new GatewayError(\n 413,\n \"body_too_large\",\n \"Request body exceeds maximum size\"\n );\n }\n }\n\n // Clone the request so downstream handlers can still read the body\n const cloned = c.req.raw.clone();\n const text = await cloned.text();\n\n if (!text) {\n debug(\"empty body - passing through\");\n await next();\n return;\n }\n\n // Parse JSON\n let parsed: unknown;\n try {\n parsed = JSON.parse(text);\n } catch {\n debug(\"invalid JSON\");\n throw new GatewayError(\n 400,\n \"invalid_json\",\n \"Invalid JSON in request body\"\n );\n }\n\n // Walk the parsed JSON and validate structural limits\n validateJsonStructure(parsed, {\n maxDepth: config.maxDepth!,\n maxKeys: config.maxKeys!,\n maxStringLength: config.maxStringLength!,\n maxArraySize: config.maxArraySize!,\n });\n\n debug(\"JSON structure validated\");\n await next();\n },\n evaluate: {\n onRequest: async (input, { config, debug }) => {\n const contentType = input.headers.get(\"content-type\") ?? \"\";\n const matchedType = config.contentTypes!.some((ct) =>\n contentType.includes(ct)\n );\n\n if (!matchedType) {\n debug(\"skipping - content type %s not inspected\", contentType);\n return { action: \"continue\" };\n }\n\n // Check Content-Length against maxBodySize BEFORE parsing\n const contentLength = input.headers.get(\"content-length\");\n if (contentLength) {\n const length = Number.parseInt(contentLength, 10);\n if (!Number.isNaN(length) && length > config.maxBodySize!) {\n debug(\"body size %d exceeds max %d\", length, config.maxBodySize);\n return {\n action: \"reject\",\n status: 413,\n code: \"body_too_large\",\n message: \"Request body exceeds maximum size\",\n };\n }\n }\n\n // Parse JSON\n let parsed: unknown;\n try {\n if (!input.body) {\n debug(\"empty body - passing through\");\n return { action: \"continue\" };\n }\n const bodyStr =\n typeof input.body === \"string\"\n ? input.body\n : new TextDecoder().decode(input.body);\n parsed = JSON.parse(bodyStr);\n } catch {\n debug(\"invalid JSON\");\n return {\n action: \"reject\",\n status: 400,\n code: \"invalid_json\",\n message: \"Invalid JSON in request body\",\n };\n }\n\n // Walk the parsed JSON and validate structural limits\n try {\n validateJsonStructure(parsed, {\n maxDepth: config.maxDepth!,\n maxKeys: config.maxKeys!,\n maxStringLength: config.maxStringLength!,\n maxArraySize: config.maxArraySize!,\n });\n } catch (err) {\n if (err instanceof GatewayError) {\n return {\n action: \"reject\",\n status: err.statusCode,\n code: err.code,\n message: err.message,\n };\n }\n throw err;\n }\n\n debug(\"JSON structure validated\");\n return { action: \"continue\" };\n },\n },\n });\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { GatewayError } from "../../core/errors";
|
|
2
|
+
import { extractClientIp } from "../../utils/ip";
|
|
3
|
+
import { definePolicy, Priority, safeCall, setDebugHeader } from "../sdk";
|
|
4
|
+
class InMemoryRateLimitStore {
|
|
5
|
+
counters = /* @__PURE__ */ new Map();
|
|
6
|
+
cleanupInterval = null;
|
|
7
|
+
/** Maximum number of unique keys to prevent memory exhaustion */
|
|
8
|
+
maxKeys;
|
|
9
|
+
cleanupIntervalMs;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
if (typeof options === "number") {
|
|
12
|
+
this.maxKeys = options;
|
|
13
|
+
this.cleanupIntervalMs = 6e4;
|
|
14
|
+
} else {
|
|
15
|
+
this.maxKeys = options?.maxKeys ?? 1e5;
|
|
16
|
+
this.cleanupIntervalMs = options?.cleanupIntervalMs ?? 6e4;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Start the periodic cleanup interval on first use (Workers-safe). */
|
|
20
|
+
ensureCleanupInterval() {
|
|
21
|
+
if (this.cleanupInterval) return;
|
|
22
|
+
this.cleanupInterval = setInterval(
|
|
23
|
+
() => this.cleanup(),
|
|
24
|
+
this.cleanupIntervalMs
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Increment the counter for a key within the given time window.
|
|
29
|
+
*
|
|
30
|
+
* When the store reaches `maxKeys` capacity and no expired entries can
|
|
31
|
+
* be evicted, returns `{ count: MAX_SAFE_INTEGER, resetAt }` to trigger
|
|
32
|
+
* rate limiting (fail-closed). This prevents unbounded memory growth at
|
|
33
|
+
* the cost of potentially rejecting legitimate requests - an intentional
|
|
34
|
+
* security trade-off where memory safety takes priority over availability.
|
|
35
|
+
*/
|
|
36
|
+
async increment(key, windowSeconds) {
|
|
37
|
+
this.ensureCleanupInterval();
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const existing = this.counters.get(key);
|
|
40
|
+
if (existing && existing.resetAt > now) {
|
|
41
|
+
existing.count++;
|
|
42
|
+
return { count: existing.count, resetAt: existing.resetAt };
|
|
43
|
+
}
|
|
44
|
+
if (this.counters.size >= this.maxKeys && !existing) {
|
|
45
|
+
this.cleanup();
|
|
46
|
+
if (this.counters.size >= this.maxKeys) {
|
|
47
|
+
const resetAt2 = now + windowSeconds * 1e3;
|
|
48
|
+
return { count: Number.MAX_SAFE_INTEGER, resetAt: resetAt2 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const resetAt = now + windowSeconds * 1e3;
|
|
52
|
+
const entry = { count: 1, resetAt };
|
|
53
|
+
this.counters.set(key, entry);
|
|
54
|
+
return { count: 1, resetAt };
|
|
55
|
+
}
|
|
56
|
+
cleanup() {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
for (const [key, entry] of this.counters) {
|
|
59
|
+
if (entry.resetAt <= now) {
|
|
60
|
+
this.counters.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Stop the cleanup interval (for testing) */
|
|
65
|
+
destroy() {
|
|
66
|
+
if (this.cleanupInterval) {
|
|
67
|
+
clearInterval(this.cleanupInterval);
|
|
68
|
+
this.cleanupInterval = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Reset all counters (for testing) */
|
|
72
|
+
reset() {
|
|
73
|
+
this.counters.clear();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const defaultStores = /* @__PURE__ */ new WeakMap();
|
|
77
|
+
function getStore(config) {
|
|
78
|
+
if (config.store) return config.store;
|
|
79
|
+
let store = defaultStores.get(config);
|
|
80
|
+
if (!store) {
|
|
81
|
+
store = new InMemoryRateLimitStore();
|
|
82
|
+
defaultStores.set(config, store);
|
|
83
|
+
}
|
|
84
|
+
return store;
|
|
85
|
+
}
|
|
86
|
+
const rateLimit = /* @__PURE__ */ definePolicy({
|
|
87
|
+
name: "rate-limit",
|
|
88
|
+
priority: Priority.RATE_LIMIT,
|
|
89
|
+
defaults: {
|
|
90
|
+
windowSeconds: 60,
|
|
91
|
+
statusCode: 429,
|
|
92
|
+
message: "Rate limit exceeded"
|
|
93
|
+
},
|
|
94
|
+
handler: async (c, next, { config, debug, trace }) => {
|
|
95
|
+
const store = getStore(config);
|
|
96
|
+
let key;
|
|
97
|
+
if (config.keyBy) {
|
|
98
|
+
key = await config.keyBy(c);
|
|
99
|
+
} else {
|
|
100
|
+
key = extractClientIp(c.req.raw.headers, { ipHeaders: config.ipHeaders });
|
|
101
|
+
}
|
|
102
|
+
const result = await safeCall(
|
|
103
|
+
() => store.increment(key, config.windowSeconds),
|
|
104
|
+
null,
|
|
105
|
+
debug,
|
|
106
|
+
"store.increment()"
|
|
107
|
+
);
|
|
108
|
+
if (!result) {
|
|
109
|
+
debug(`store unavailable, failing open (key=${key})`);
|
|
110
|
+
await next();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const { count, resetAt } = result;
|
|
114
|
+
const remaining = Math.max(0, config.max - count);
|
|
115
|
+
const resetSeconds = Math.ceil((resetAt - Date.now()) / 1e3);
|
|
116
|
+
setDebugHeader(c, "x-stoma-ratelimit-key", key);
|
|
117
|
+
setDebugHeader(c, "x-stoma-ratelimit-window", config.windowSeconds);
|
|
118
|
+
if (count > config.max) {
|
|
119
|
+
debug(`limited (key=${key}, count=${count}, max=${config.max})`);
|
|
120
|
+
trace("rejected", { key, count, max: config.max });
|
|
121
|
+
const resetHeader = String(resetSeconds);
|
|
122
|
+
throw new GatewayError(
|
|
123
|
+
config.statusCode,
|
|
124
|
+
"rate_limited",
|
|
125
|
+
config.message,
|
|
126
|
+
{
|
|
127
|
+
"x-ratelimit-limit": String(config.max),
|
|
128
|
+
"x-ratelimit-remaining": "0",
|
|
129
|
+
"x-ratelimit-reset": resetHeader,
|
|
130
|
+
"retry-after": resetHeader
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
trace("allowed", { key, count, max: config.max, remaining });
|
|
135
|
+
await next();
|
|
136
|
+
c.res.headers.set("x-ratelimit-limit", String(config.max));
|
|
137
|
+
c.res.headers.set("x-ratelimit-remaining", String(remaining));
|
|
138
|
+
c.res.headers.set("x-ratelimit-reset", String(resetSeconds));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
export {
|
|
142
|
+
InMemoryRateLimitStore,
|
|
143
|
+
rateLimit
|
|
144
|
+
};
|
|
145
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/policies/traffic/rate-limit.ts"],"sourcesContent":["/**\n * Rate limiting policy with pluggable counter storage.\n *\n * @module rate-limit\n */\nimport type { Context } from \"hono\";\nimport { GatewayError } from \"../../core/errors\";\nimport { extractClientIp } from \"../../utils/ip\";\nimport { definePolicy, Priority, safeCall, setDebugHeader } from \"../sdk\";\nimport type { PolicyConfig } from \"../types\";\n\nexport interface RateLimitConfig extends PolicyConfig {\n /** Maximum requests per window */\n max: number;\n /** Time window in seconds. Default: 60. */\n windowSeconds?: number;\n /** Key extractor - determines the rate limit bucket. Default: client IP. */\n keyBy?: (c: Context) => string | Promise<string>;\n /** Storage backend for counters */\n store?: RateLimitStore;\n /** Response status code when limited. Default: 429. */\n statusCode?: number;\n /** Custom response body when limited */\n message?: string;\n /** Ordered list of headers to inspect for the client IP (when `keyBy` is not set). Default: `[\"cf-connecting-ip\", \"x-forwarded-for\"]`. */\n ipHeaders?: string[];\n}\n\n/** Pluggable storage backend for rate limit counters */\nexport interface RateLimitStore {\n /** Increment the counter for a key, returning the new count and TTL */\n increment(\n key: string,\n windowSeconds: number\n ): Promise<{ count: number; resetAt: number }>;\n /** Optional: cleanup resources (like intervals) used by the store */\n destroy?(): void;\n}\n\n/** Default in-memory rate limit store */\nexport interface InMemoryRateLimitStoreOptions {\n /** Maximum number of unique keys to prevent memory exhaustion. Default: 100000. */\n maxKeys?: number;\n /** Cleanup interval in ms for expired entries. Default: 60000. */\n cleanupIntervalMs?: number;\n}\n\n/**\n * Default in-memory rate limit store backed by a `Map`.\n *\n * The store is bounded by `maxKeys` (default 100,000) to prevent unbounded\n * memory growth from unique rate-limit keys. When the store reaches capacity\n * and no expired entries can be evicted, it **fails closed** - returning\n * `MAX_SAFE_INTEGER` as the count to trigger rate limiting. This is an\n * intentional security design: memory safety takes priority over availability.\n *\n * Note the distinction between store-level and policy-level failure modes:\n * - **Store at capacity** (this class): fail-closed - reject the request\n * - **Store throws/times out** (policy handler via `safeCall`): fail-open - allow the request\n */\nexport class InMemoryRateLimitStore implements RateLimitStore {\n private counters = new Map<string, { count: number; resetAt: number }>();\n private cleanupInterval: ReturnType<typeof setInterval> | null = null;\n /** Maximum number of unique keys to prevent memory exhaustion */\n private maxKeys: number;\n private cleanupIntervalMs: number;\n\n constructor(options?: InMemoryRateLimitStoreOptions | number) {\n // Back-compat: accept a plain number as maxKeys\n if (typeof options === \"number\") {\n this.maxKeys = options;\n this.cleanupIntervalMs = 60_000;\n } else {\n this.maxKeys = options?.maxKeys ?? 100_000;\n this.cleanupIntervalMs = options?.cleanupIntervalMs ?? 60_000;\n }\n }\n\n /** Start the periodic cleanup interval on first use (Workers-safe). */\n private ensureCleanupInterval(): void {\n if (this.cleanupInterval) return;\n this.cleanupInterval = setInterval(\n () => this.cleanup(),\n this.cleanupIntervalMs\n );\n }\n\n /**\n * Increment the counter for a key within the given time window.\n *\n * When the store reaches `maxKeys` capacity and no expired entries can\n * be evicted, returns `{ count: MAX_SAFE_INTEGER, resetAt }` to trigger\n * rate limiting (fail-closed). This prevents unbounded memory growth at\n * the cost of potentially rejecting legitimate requests - an intentional\n * security trade-off where memory safety takes priority over availability.\n */\n async increment(\n key: string,\n windowSeconds: number\n ): Promise<{ count: number; resetAt: number }> {\n this.ensureCleanupInterval();\n const now = Date.now();\n const existing = this.counters.get(key);\n\n if (existing && existing.resetAt > now) {\n existing.count++;\n return { count: existing.count, resetAt: existing.resetAt };\n }\n\n // Protect against memory exhaustion: if we're at capacity, evict expired\n // entries first. If still at capacity, reject with a high count to\n // trigger rate limiting rather than allowing unbounded memory growth.\n if (this.counters.size >= this.maxKeys && !existing) {\n this.cleanup();\n if (this.counters.size >= this.maxKeys) {\n const resetAt = now + windowSeconds * 1000;\n return { count: Number.MAX_SAFE_INTEGER, resetAt };\n }\n }\n\n const resetAt = now + windowSeconds * 1000;\n const entry = { count: 1, resetAt };\n this.counters.set(key, entry);\n return { count: 1, resetAt };\n }\n\n private cleanup(): void {\n const now = Date.now();\n for (const [key, entry] of this.counters) {\n if (entry.resetAt <= now) {\n this.counters.delete(key);\n }\n }\n }\n\n /** Stop the cleanup interval (for testing) */\n destroy(): void {\n if (this.cleanupInterval) {\n clearInterval(this.cleanupInterval);\n this.cleanupInterval = null;\n }\n }\n\n /** Reset all counters (for testing) */\n reset(): void {\n this.counters.clear();\n }\n}\n\n/**\n * Per-instance default stores for factory calls that don't provide their own.\n *\n * Keyed by the resolved config object (unique per factory call via\n * `resolveConfig`'s spread). This preserves the lazy-init behavior: the\n * `InMemoryRateLimitStore` is only created on the first request, not at\n * factory time, avoiding premature timer starts in test environments.\n */\nconst defaultStores = new WeakMap<object, InMemoryRateLimitStore>();\n\nfunction getStore(config: RateLimitConfig): RateLimitStore {\n if (config.store) return config.store;\n let store = defaultStores.get(config);\n if (!store) {\n store = new InMemoryRateLimitStore();\n defaultStores.set(config, store);\n }\n return store;\n}\n\n/**\n * Rate limit requests with pluggable storage backends.\n *\n * Defaults to client IP extraction via `CF-Connecting-IP` or `X-Forwarded-For`.\n * Sets standard `X-RateLimit-*` response headers on every request and\n * throws a 429 when the limit is exceeded.\n *\n * @param config - Rate limit settings. `max` is required; other fields have sensible defaults.\n * @returns A {@link Policy} at priority 20 (runs after auth).\n *\n * @example\n * ```ts\n * // 100 requests per minute per IP (in-memory)\n * rateLimit({ max: 100 });\n *\n * // Custom key + Cloudflare KV store\n * rateLimit({\n * max: 50,\n * windowSeconds: 300,\n * keyBy: (c) => c.req.header(\"x-user-id\") ?? \"anonymous\",\n * store: new KVRateLimitStore(env.RATE_LIMIT_KV),\n * });\n * ```\n */\nexport const rateLimit = /*#__PURE__*/ definePolicy<RateLimitConfig>({\n name: \"rate-limit\",\n priority: Priority.RATE_LIMIT,\n defaults: {\n windowSeconds: 60,\n statusCode: 429,\n message: \"Rate limit exceeded\",\n },\n handler: async (c, next, { config, debug, trace }) => {\n const store = getStore(config);\n\n // Extract the rate limit key\n let key: string;\n if (config.keyBy) {\n key = await config.keyBy(c);\n } else {\n key = extractClientIp(c.req.raw.headers, { ipHeaders: config.ipHeaders });\n }\n\n // Resilient to store failures - fail-open (allow the request) if the\n // store is unreachable, but skip rate-limit headers since we have no data.\n const result = await safeCall(\n () => store.increment(key, config.windowSeconds!),\n null,\n debug,\n \"store.increment()\"\n );\n\n if (!result) {\n debug(`store unavailable, failing open (key=${key})`);\n await next();\n return;\n }\n\n const { count, resetAt } = result;\n const remaining = Math.max(0, config.max - count);\n const resetSeconds = Math.ceil((resetAt - Date.now()) / 1000);\n setDebugHeader(c, \"x-stoma-ratelimit-key\", key);\n setDebugHeader(c, \"x-stoma-ratelimit-window\", config.windowSeconds!);\n\n if (count > config.max) {\n debug(`limited (key=${key}, count=${count}, max=${config.max})`);\n trace(\"rejected\", { key, count, max: config.max });\n const resetHeader = String(resetSeconds);\n throw new GatewayError(\n config.statusCode!,\n \"rate_limited\",\n config.message!,\n {\n \"x-ratelimit-limit\": String(config.max),\n \"x-ratelimit-remaining\": \"0\",\n \"x-ratelimit-reset\": resetHeader,\n \"retry-after\": resetHeader,\n }\n );\n }\n\n trace(\"allowed\", { key, count, max: config.max, remaining });\n\n await next();\n\n // Set rate limit headers on the response AFTER downstream runs,\n // so they're applied even when handlers return raw Response objects\n c.res.headers.set(\"x-ratelimit-limit\", String(config.max));\n c.res.headers.set(\"x-ratelimit-remaining\", String(remaining));\n c.res.headers.set(\"x-ratelimit-reset\", String(resetSeconds));\n },\n});\n"],"mappings":"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","names":["resetAt"]}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { g as PolicyConfig, P as Policy } from '../../protocol-2fD3DJrL.js';
|
|
2
|
+
import 'hono';
|
|
3
|
+
import '../sdk/trace.js';
|
|
4
|
+
import '@vivero/stoma-core';
|
|
5
|
+
|
|
6
|
+
/** A single pattern rule with target areas and optional custom message. */
|
|
7
|
+
interface RegexPatternRule {
|
|
8
|
+
/** Regular expression pattern string. */
|
|
9
|
+
regex: string;
|
|
10
|
+
/** Which parts of the request to scan. */
|
|
11
|
+
targets: Array<"path" | "headers" | "body" | "query">;
|
|
12
|
+
/** Custom error message when this pattern matches. */
|
|
13
|
+
message?: string;
|
|
14
|
+
}
|
|
15
|
+
interface RegexThreatProtectionConfig extends PolicyConfig {
|
|
16
|
+
/** Pattern rules to evaluate against request data. */
|
|
17
|
+
patterns: RegexPatternRule[];
|
|
18
|
+
/** Regex flags applied to all patterns. Default: `"i"` (case-insensitive). */
|
|
19
|
+
flags?: string;
|
|
20
|
+
/** Only inspect body for these content types. Default: `["application/json", "text/plain"]`. */
|
|
21
|
+
contentTypes?: string[];
|
|
22
|
+
/** Maximum body bytes to scan. Default: `65536` (64KB). */
|
|
23
|
+
maxBodyScanLength?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Regex threat protection policy.
|
|
27
|
+
*
|
|
28
|
+
* Scans request path, query string, headers, and/or body against
|
|
29
|
+
* configurable regex patterns. Throws a 400 GatewayError on first match.
|
|
30
|
+
*
|
|
31
|
+
* @security User-provided regex patterns can cause catastrophic backtracking
|
|
32
|
+
* (ReDoS) if they contain nested quantifiers or overlapping alternations
|
|
33
|
+
* (e.g. `(a+)+`, `(a|a)*b`). A crafted input string can cause the regex
|
|
34
|
+
* engine to run in exponential time, blocking the worker thread and
|
|
35
|
+
* effectively denying service. All patterns should be reviewed for
|
|
36
|
+
* super-linear time complexity before deployment. Consider using atomic
|
|
37
|
+
* patterns, possessive quantifiers (where supported), or testing patterns
|
|
38
|
+
* with a ReDoS detection tool.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { regexThreatProtection } from "@vivero/stoma";
|
|
43
|
+
*
|
|
44
|
+
* regexThreatProtection({
|
|
45
|
+
* patterns: [
|
|
46
|
+
* { regex: "(union|select|insert|delete|drop)\\s", targets: ["path", "query", "body"], message: "SQL injection detected" },
|
|
47
|
+
* { regex: "<script[^>]*>", targets: ["body", "headers"], message: "XSS detected" },
|
|
48
|
+
* ],
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
declare const regexThreatProtection: (config: RegexThreatProtectionConfig) => Policy;
|
|
53
|
+
|
|
54
|
+
export { type RegexPatternRule, type RegexThreatProtectionConfig, regexThreatProtection };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { GatewayError } from "../../core/errors";
|
|
2
|
+
import { definePolicy, Priority } from "../sdk";
|
|
3
|
+
const patternCache = /* @__PURE__ */ new WeakMap();
|
|
4
|
+
function getCompiledPatterns(patterns, flags) {
|
|
5
|
+
let compiled = patternCache.get(patterns);
|
|
6
|
+
if (!compiled) {
|
|
7
|
+
const sanitizedFlags = flags.replace(/g/g, "");
|
|
8
|
+
if (sanitizedFlags !== flags) {
|
|
9
|
+
console.warn(
|
|
10
|
+
"[stoma:regex-threat-protection] Stripped 'g' flag - not meaningful with .test()"
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
compiled = patterns.map((p) => ({
|
|
14
|
+
regex: new RegExp(p.regex, sanitizedFlags),
|
|
15
|
+
targets: p.targets,
|
|
16
|
+
message: p.message ?? "Request blocked by threat protection"
|
|
17
|
+
}));
|
|
18
|
+
patternCache.set(patterns, compiled);
|
|
19
|
+
}
|
|
20
|
+
return compiled;
|
|
21
|
+
}
|
|
22
|
+
const regexThreatProtection = /* @__PURE__ */ definePolicy({
|
|
23
|
+
name: "regex-threat-protection",
|
|
24
|
+
priority: Priority.EARLY,
|
|
25
|
+
defaults: {
|
|
26
|
+
patterns: [],
|
|
27
|
+
flags: "i",
|
|
28
|
+
contentTypes: ["application/json", "text/plain"],
|
|
29
|
+
maxBodyScanLength: 65536
|
|
30
|
+
},
|
|
31
|
+
handler: async (c, next, { config, debug }) => {
|
|
32
|
+
const compiled = getCompiledPatterns(
|
|
33
|
+
config.patterns,
|
|
34
|
+
config.flags ?? "i"
|
|
35
|
+
);
|
|
36
|
+
if (compiled.length === 0) {
|
|
37
|
+
debug("no patterns configured - passing through");
|
|
38
|
+
await next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const anyTargetsBody = compiled.some((p) => p.targets.includes("body"));
|
|
42
|
+
let bodyText = null;
|
|
43
|
+
if (anyTargetsBody) {
|
|
44
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
45
|
+
const matchedType = config.contentTypes.some(
|
|
46
|
+
(ct) => contentType.includes(ct)
|
|
47
|
+
);
|
|
48
|
+
if (matchedType) {
|
|
49
|
+
const cloned = c.req.raw.clone();
|
|
50
|
+
const reader = cloned.body?.getReader();
|
|
51
|
+
if (reader) {
|
|
52
|
+
let text = "";
|
|
53
|
+
const maxLen = config.maxBodyScanLength;
|
|
54
|
+
let done = false;
|
|
55
|
+
const decoder = new TextDecoder();
|
|
56
|
+
while (!done && text.length < maxLen) {
|
|
57
|
+
const result = await reader.read();
|
|
58
|
+
if (result.done) {
|
|
59
|
+
done = true;
|
|
60
|
+
} else {
|
|
61
|
+
text += decoder.decode(result.value, { stream: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
reader.cancel();
|
|
65
|
+
if (text.length > maxLen) {
|
|
66
|
+
text = text.slice(0, maxLen);
|
|
67
|
+
}
|
|
68
|
+
bodyText = text || null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const pattern of compiled) {
|
|
73
|
+
if (pattern.targets.includes("path")) {
|
|
74
|
+
if (pattern.regex.test(c.req.path)) {
|
|
75
|
+
debug("path matched pattern: %s", pattern.regex.source);
|
|
76
|
+
throw new GatewayError(400, "threat_detected", pattern.message);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (pattern.targets.includes("query")) {
|
|
80
|
+
const url = new URL(c.req.url);
|
|
81
|
+
const queryString = decodeURIComponent(url.search.slice(1));
|
|
82
|
+
if (queryString && pattern.regex.test(queryString)) {
|
|
83
|
+
debug("query matched pattern: %s", pattern.regex.source);
|
|
84
|
+
throw new GatewayError(400, "threat_detected", pattern.message);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (pattern.targets.includes("headers")) {
|
|
88
|
+
for (const [, value] of c.req.raw.headers.entries()) {
|
|
89
|
+
if (pattern.regex.test(value)) {
|
|
90
|
+
debug("header matched pattern: %s", pattern.regex.source);
|
|
91
|
+
throw new GatewayError(400, "threat_detected", pattern.message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (pattern.targets.includes("body") && bodyText) {
|
|
96
|
+
if (pattern.regex.test(bodyText)) {
|
|
97
|
+
debug("body matched pattern: %s", pattern.regex.source);
|
|
98
|
+
throw new GatewayError(400, "threat_detected", pattern.message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
debug("all patterns passed");
|
|
103
|
+
await next();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
export {
|
|
107
|
+
regexThreatProtection
|
|
108
|
+
};
|
|
109
|
+
//# sourceMappingURL=regex-threat-protection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/policies/traffic/regex-threat-protection.ts"],"sourcesContent":["/**\n * Regex-based threat protection policy.\n *\n * Blocks requests matching dangerous patterns (SQL injection, XSS, etc.)\n * in the path, query string, headers, or body. Patterns are compiled once\n * per config object and cached via WeakMap.\n *\n * @module regex-threat-protection\n */\n\nimport { GatewayError } from \"../../core/errors\";\nimport { definePolicy, Priority } from \"../sdk\";\nimport type { PolicyConfig } from \"../types\";\n\n/** A single pattern rule with target areas and optional custom message. */\nexport interface RegexPatternRule {\n /** Regular expression pattern string. */\n regex: string;\n /** Which parts of the request to scan. */\n targets: Array<\"path\" | \"headers\" | \"body\" | \"query\">;\n /** Custom error message when this pattern matches. */\n message?: string;\n}\n\nexport interface RegexThreatProtectionConfig extends PolicyConfig {\n /** Pattern rules to evaluate against request data. */\n patterns: RegexPatternRule[];\n /** Regex flags applied to all patterns. Default: `\"i\"` (case-insensitive). */\n flags?: string;\n /** Only inspect body for these content types. Default: `[\"application/json\", \"text/plain\"]`. */\n contentTypes?: string[];\n /** Maximum body bytes to scan. Default: `65536` (64KB). */\n maxBodyScanLength?: number;\n}\n\n/** Compiled pattern with pre-built RegExp for efficient matching. */\ninterface CompiledPattern {\n regex: RegExp;\n targets: Array<\"path\" | \"headers\" | \"body\" | \"query\">;\n message: string;\n}\n\nconst patternCache = new WeakMap<RegexPatternRule[], CompiledPattern[]>();\n\nfunction getCompiledPatterns(\n patterns: RegexPatternRule[],\n flags: string\n): CompiledPattern[] {\n let compiled = patternCache.get(patterns);\n if (!compiled) {\n // Strip 'g' flag - not meaningful with .test() and can cause\n // stateful lastIndex issues across calls.\n const sanitizedFlags = flags.replace(/g/g, \"\");\n if (sanitizedFlags !== flags) {\n console.warn(\n \"[stoma:regex-threat-protection] Stripped 'g' flag - not meaningful with .test()\"\n );\n }\n compiled = patterns.map((p) => ({\n regex: new RegExp(p.regex, sanitizedFlags),\n targets: p.targets,\n message: p.message ?? \"Request blocked by threat protection\",\n }));\n patternCache.set(patterns, compiled);\n }\n return compiled;\n}\n\n/**\n * Regex threat protection policy.\n *\n * Scans request path, query string, headers, and/or body against\n * configurable regex patterns. Throws a 400 GatewayError on first match.\n *\n * @security User-provided regex patterns can cause catastrophic backtracking\n * (ReDoS) if they contain nested quantifiers or overlapping alternations\n * (e.g. `(a+)+`, `(a|a)*b`). A crafted input string can cause the regex\n * engine to run in exponential time, blocking the worker thread and\n * effectively denying service. All patterns should be reviewed for\n * super-linear time complexity before deployment. Consider using atomic\n * patterns, possessive quantifiers (where supported), or testing patterns\n * with a ReDoS detection tool.\n *\n * @example\n * ```ts\n * import { regexThreatProtection } from \"@vivero/stoma\";\n *\n * regexThreatProtection({\n * patterns: [\n * { regex: \"(union|select|insert|delete|drop)\\\\s\", targets: [\"path\", \"query\", \"body\"], message: \"SQL injection detected\" },\n * { regex: \"<script[^>]*>\", targets: [\"body\", \"headers\"], message: \"XSS detected\" },\n * ],\n * });\n * ```\n */\nexport const regexThreatProtection =\n /*#__PURE__*/ definePolicy<RegexThreatProtectionConfig>({\n name: \"regex-threat-protection\",\n priority: Priority.EARLY,\n defaults: {\n patterns: [],\n flags: \"i\",\n contentTypes: [\"application/json\", \"text/plain\"],\n maxBodyScanLength: 65536,\n },\n handler: async (c, next, { config, debug }) => {\n const compiled = getCompiledPatterns(\n config.patterns,\n config.flags ?? \"i\"\n );\n\n if (compiled.length === 0) {\n debug(\"no patterns configured - passing through\");\n await next();\n return;\n }\n\n // Pre-compute whether any pattern targets body so we only read it once\n const anyTargetsBody = compiled.some((p) => p.targets.includes(\"body\"));\n let bodyText: string | null = null;\n\n if (anyTargetsBody) {\n const contentType = c.req.header(\"content-type\") ?? \"\";\n const matchedType = config.contentTypes!.some((ct) =>\n contentType.includes(ct)\n );\n\n if (matchedType) {\n const cloned = c.req.raw.clone();\n const reader = cloned.body?.getReader();\n if (reader) {\n let text = \"\";\n const maxLen = config.maxBodyScanLength!;\n let done = false;\n const decoder = new TextDecoder();\n\n while (!done && text.length < maxLen) {\n const result = await reader.read();\n if (result.done) {\n done = true;\n } else {\n text += decoder.decode(result.value, { stream: true });\n }\n }\n reader.cancel();\n\n if (text.length > maxLen) {\n text = text.slice(0, maxLen);\n }\n\n bodyText = text || null;\n }\n }\n }\n\n for (const pattern of compiled) {\n // Check path\n if (pattern.targets.includes(\"path\")) {\n if (pattern.regex.test(c.req.path)) {\n debug(\"path matched pattern: %s\", pattern.regex.source);\n throw new GatewayError(400, \"threat_detected\", pattern.message);\n }\n }\n\n // Check query string (decoded so URL-encoded payloads are caught)\n if (pattern.targets.includes(\"query\")) {\n const url = new URL(c.req.url);\n const queryString = decodeURIComponent(url.search.slice(1));\n if (queryString && pattern.regex.test(queryString)) {\n debug(\"query matched pattern: %s\", pattern.regex.source);\n throw new GatewayError(400, \"threat_detected\", pattern.message);\n }\n }\n\n // Check headers\n if (pattern.targets.includes(\"headers\")) {\n for (const [, value] of c.req.raw.headers.entries()) {\n if (pattern.regex.test(value)) {\n debug(\"header matched pattern: %s\", pattern.regex.source);\n throw new GatewayError(400, \"threat_detected\", pattern.message);\n }\n }\n }\n\n // Check body (using the pre-read body text)\n if (pattern.targets.includes(\"body\") && bodyText) {\n if (pattern.regex.test(bodyText)) {\n debug(\"body matched pattern: %s\", pattern.regex.source);\n throw new GatewayError(400, \"threat_detected\", pattern.message);\n }\n }\n }\n\n debug(\"all patterns passed\");\n await next();\n },\n });\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { g as PolicyConfig, P as Policy } from '../../protocol-2fD3DJrL.js';
|
|
2
|
+
import 'hono';
|
|
3
|
+
import '../sdk/trace.js';
|
|
4
|
+
import '@vivero/stoma-core';
|
|
5
|
+
|
|
6
|
+
interface RequestLimitConfig extends PolicyConfig {
|
|
7
|
+
/** Maximum allowed body size in bytes (based on Content-Length). */
|
|
8
|
+
maxBytes: number;
|
|
9
|
+
/** Custom error message. Default: "Request body too large". */
|
|
10
|
+
message?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Reject requests whose declared Content-Length exceeds `maxBytes`.
|
|
14
|
+
*
|
|
15
|
+
* This policy checks only the `Content-Length` header. If the header is
|
|
16
|
+
* absent or invalid, the request passes through. Notably, requests using
|
|
17
|
+
* chunked transfer encoding (`Transfer-Encoding: chunked`) do not include
|
|
18
|
+
* a `Content-Length` header and will bypass this check entirely. For strict
|
|
19
|
+
* body size enforcement, combine this policy with a body-reading policy
|
|
20
|
+
* that enforces limits on the actual stream length.
|
|
21
|
+
*
|
|
22
|
+
* @param config - Maximum byte limit and optional custom message.
|
|
23
|
+
* @returns A {@link Policy} at priority 5 (EARLY).
|
|
24
|
+
*/
|
|
25
|
+
declare const requestLimit: (config: RequestLimitConfig) => Policy;
|
|
26
|
+
|
|
27
|
+
export { type RequestLimitConfig, requestLimit };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { GatewayError } from "../../core/errors";
|
|
2
|
+
import { definePolicy, Priority } from "../sdk";
|
|
3
|
+
const requestLimit = /* @__PURE__ */ definePolicy({
|
|
4
|
+
name: "request-limit",
|
|
5
|
+
priority: Priority.EARLY,
|
|
6
|
+
phases: ["request-headers"],
|
|
7
|
+
defaults: {
|
|
8
|
+
message: "Request body too large"
|
|
9
|
+
},
|
|
10
|
+
handler: async (c, next, { config }) => {
|
|
11
|
+
const contentLength = c.req.header("content-length");
|
|
12
|
+
if (contentLength !== void 0) {
|
|
13
|
+
const length = Number.parseInt(contentLength, 10);
|
|
14
|
+
if (!Number.isNaN(length) && length > config.maxBytes) {
|
|
15
|
+
throw new GatewayError(413, "request_too_large", config.message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
await next();
|
|
19
|
+
},
|
|
20
|
+
evaluate: {
|
|
21
|
+
onRequest: async (input, { config }) => {
|
|
22
|
+
const contentLength = input.headers.get("content-length");
|
|
23
|
+
if (contentLength) {
|
|
24
|
+
const length = Number.parseInt(contentLength, 10);
|
|
25
|
+
if (!Number.isNaN(length) && length > config.maxBytes) {
|
|
26
|
+
return {
|
|
27
|
+
action: "reject",
|
|
28
|
+
status: 413,
|
|
29
|
+
code: "request_too_large",
|
|
30
|
+
message: config.message
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { action: "continue" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
export {
|
|
39
|
+
requestLimit
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=request-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/policies/traffic/request-limit.ts"],"sourcesContent":["/**\n * Request size limit policy - enforce maximum request body size via Content-Length.\n *\n * @module request-limit\n */\n\nimport { GatewayError } from \"../../core/errors\";\nimport { definePolicy, Priority } from \"../sdk\";\nimport type { PolicyConfig } from \"../types\";\n\nexport interface RequestLimitConfig extends PolicyConfig {\n /** Maximum allowed body size in bytes (based on Content-Length). */\n maxBytes: number;\n /** Custom error message. Default: \"Request body too large\". */\n message?: string;\n}\n\n/**\n * Reject requests whose declared Content-Length exceeds `maxBytes`.\n *\n * This policy checks only the `Content-Length` header. If the header is\n * absent or invalid, the request passes through. Notably, requests using\n * chunked transfer encoding (`Transfer-Encoding: chunked`) do not include\n * a `Content-Length` header and will bypass this check entirely. For strict\n * body size enforcement, combine this policy with a body-reading policy\n * that enforces limits on the actual stream length.\n *\n * @param config - Maximum byte limit and optional custom message.\n * @returns A {@link Policy} at priority 5 (EARLY).\n */\nexport const requestLimit = /*#__PURE__*/ definePolicy<RequestLimitConfig>({\n name: \"request-limit\",\n priority: Priority.EARLY,\n phases: [\"request-headers\"],\n defaults: {\n message: \"Request body too large\",\n },\n handler: async (c, next, { config }) => {\n const contentLength = c.req.header(\"content-length\");\n if (contentLength !== undefined) {\n const length = Number.parseInt(contentLength, 10);\n if (!Number.isNaN(length) && length > config.maxBytes) {\n throw new GatewayError(413, \"request_too_large\", config.message!);\n }\n }\n await next();\n },\n evaluate: {\n onRequest: async (input, { config }) => {\n const contentLength = input.headers.get(\"content-length\");\n if (contentLength) {\n const length = Number.parseInt(contentLength, 10);\n if (!Number.isNaN(length) && length > config.maxBytes) {\n return {\n action: \"reject\",\n status: 413,\n code: \"request_too_large\",\n message: config.message!,\n };\n }\n }\n return { action: \"continue\" };\n },\n },\n});\n"],"mappings":"AAMA,SAAS,oBAAoB;AAC7B,SAAS,cAAc,gBAAgB;AAuBhC,MAAM,eAA6B,6BAAiC;AAAA,EACzE,MAAM;AAAA,EACN,UAAU,SAAS;AAAA,EACnB,QAAQ,CAAC,iBAAiB;AAAA,EAC1B,UAAU;AAAA,IACR,SAAS;AAAA,EACX;AAAA,EACA,SAAS,OAAO,GAAG,MAAM,EAAE,OAAO,MAAM;AACtC,UAAM,gBAAgB,EAAE,IAAI,OAAO,gBAAgB;AACnD,QAAI,kBAAkB,QAAW;AAC/B,YAAM,SAAS,OAAO,SAAS,eAAe,EAAE;AAChD,UAAI,CAAC,OAAO,MAAM,MAAM,KAAK,SAAS,OAAO,UAAU;AACrD,cAAM,IAAI,aAAa,KAAK,qBAAqB,OAAO,OAAQ;AAAA,MAClE;AAAA,IACF;AACA,UAAM,KAAK;AAAA,EACb;AAAA,EACA,UAAU;AAAA,IACR,WAAW,OAAO,OAAO,EAAE,OAAO,MAAM;AACtC,YAAM,gBAAgB,MAAM,QAAQ,IAAI,gBAAgB;AACxD,UAAI,eAAe;AACjB,cAAM,SAAS,OAAO,SAAS,eAAe,EAAE;AAChD,YAAI,CAAC,OAAO,MAAM,MAAM,KAAK,SAAS,OAAO,UAAU;AACrD,iBAAO;AAAA,YACL,QAAQ;AAAA,YACR,QAAQ;AAAA,YACR,MAAM;AAAA,YACN,SAAS,OAAO;AAAA,UAClB;AAAA,QACF;AAAA,MACF;AACA,aAAO,EAAE,QAAQ,WAAW;AAAA,IAC9B;AAAA,EACF;AACF,CAAC;","names":[]}
|