@metamynd/agentsafe-http-gateway 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/gateway.mjs +85 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -15,9 +15,10 @@ agent → [ HTTP gateway ] → upstream service
|
|
|
15
15
|
│
|
|
16
16
|
├─ route not protected → forward as-is
|
|
17
17
|
└─ route protected:
|
|
18
|
+
no signed request → 401
|
|
19
|
+
payload ≠ signed value → 403 PAYLOAD_NOT_BOUND (verifyRequest never called)
|
|
18
20
|
verifyRequest(signed) → allow/observe → forward upstream (+ x-agentsafe-decision)
|
|
19
21
|
→ block/escalate → 403 (upstream never called)
|
|
20
|
-
→ no signed request → 401
|
|
21
22
|
→ gate error → 502 (fail closed)
|
|
22
23
|
```
|
|
23
24
|
|
|
@@ -46,6 +47,38 @@ node server.mjs # listens on PORT (default 4000)
|
|
|
46
47
|
`denyByDefault: true` (in `createHttpGateway`) switches to an **allow-list** posture — an unmatched
|
|
47
48
|
route is blocked (`ROUTE_NOT_ALLOWED`) instead of forwarded.
|
|
48
49
|
|
|
50
|
+
## Payload binding (confused deputy) — on by default since 0.2.0
|
|
51
|
+
|
|
52
|
+
The signed request authorizes *specific values*; the bytes forwarded upstream are the request
|
|
53
|
+
body — a different object. Before 0.2.0, nothing compared them: an agent could sign a cheap,
|
|
54
|
+
in-policy request in the `x-magp-request` header while shipping an expensive, out-of-policy body,
|
|
55
|
+
and the gateway would verify the header, then forward the body unchanged. Signed $250, executed
|
|
56
|
+
$5000 — a real, confirmed finding, not a hypothetical.
|
|
57
|
+
|
|
58
|
+
`bind` (default `defaultBindPayload`) pulls `amount`/`currency`/`merchant` out of a **JSON** body
|
|
59
|
+
and compares each to the value that was actually signed — checked *before* `verifyRequest()`, so
|
|
60
|
+
a tampered request is refused locally: no issuer round trip, no nonce consumed. A mismatch
|
|
61
|
+
returns `403 PAYLOAD_NOT_BOUND` naming the offending field. A value the signature never mentioned
|
|
62
|
+
at all also counts as a mismatch — `verifyRequest()` defaults an absent `amount`/`merchant` to
|
|
63
|
+
`0`/`''`, so a body that introduces one against a signature covering neither is the same attack
|
|
64
|
+
wearing a different hat.
|
|
65
|
+
|
|
66
|
+
**This is secure by default, not opt-in** — the alternative leaves every existing deployment
|
|
67
|
+
carrying the gap, which is the vulnerability rather than a fix for it. Pass `bind: false`
|
|
68
|
+
(globally, or per route) only for a route whose body carries no value fields worth binding.
|
|
69
|
+
|
|
70
|
+
**Known limitation, deliberate:** a non-JSON body (protobuf, multipart, form-encoded) or a
|
|
71
|
+
nested one (`{ booking: { amount } }`) binds nothing under the default binder and passes on the
|
|
72
|
+
signature alone — failing those closed would brick every reverse-proxy deployment fronting a
|
|
73
|
+
non-JSON or differently-shaped upstream. Give such a route its own binder instead:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
routes: [{
|
|
77
|
+
method: 'POST', path: '/book/*', action: 'flight-purchase',
|
|
78
|
+
bind: (req) => { const b = JSON.parse(req.rawBody.toString('utf8')); return { amount: b.booking?.amount }; },
|
|
79
|
+
}]
|
|
80
|
+
```
|
|
81
|
+
|
|
49
82
|
## Embed the core
|
|
50
83
|
|
|
51
84
|
```js
|
|
@@ -55,4 +88,7 @@ const result = await handle({ method, path, headers, body }); // { stat
|
|
|
55
88
|
```
|
|
56
89
|
|
|
57
90
|
Self-check: `node gateway.smoke.mjs` (route matching, pass-through, allow→forward, block→403,
|
|
58
|
-
missing-governance→401, fail-closed, action-pinning, allow-list posture)
|
|
91
|
+
missing-governance→401, fail-closed, action-pinning, allow-list posture) and
|
|
92
|
+
`node bind-payload.smoke.mjs` (tampered field detection, unmentioned-value detection, runs before
|
|
93
|
+
the guard, numeric-string coercion, empty/non-JSON bodies, nested-value binders, the `bind: false`
|
|
94
|
+
opt-out, a throwing binder failing closed).
|
package/gateway.mjs
CHANGED
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
// This is the framework-agnostic CORE — a pure-ish request handler with the guard + the upstream
|
|
6
6
|
// forwarder INJECTED, so it is testable with fakes. `server.mjs` binds it to node:http + fetch.
|
|
7
7
|
//
|
|
8
|
-
// A protected route: { method, path, action, extract? }. The gateway needs the agent's
|
|
9
|
-
// MAGP request to govern the call — by default it reads header `x-magp-request` (JSON of
|
|
8
|
+
// A protected route: { method, path, action, extract?, bind? }. The gateway needs the agent's
|
|
9
|
+
// SIGNED MAGP request to govern the call — by default it reads header `x-magp-request` (JSON of
|
|
10
10
|
// { agentDid, amount, merchant, itinerary, nonce, issuedAt, signature }); a route may override
|
|
11
11
|
// with its own `extract(req)`. The route's `action` is authoritative (the client can't pick it).
|
|
12
|
+
//
|
|
13
|
+
// PAYLOAD BINDING (see `bind` below): the signed request authorizes SPECIFIC VALUES, but the
|
|
14
|
+
// bytes we forward upstream are the request body — a different object. Governing the header
|
|
15
|
+
// while executing the body is a confused-deputy gap: an agent signs a cheap, in-policy request
|
|
16
|
+
// and ships an expensive one. The gateway therefore refuses to forward a body whose governed
|
|
17
|
+
// value fields disagree with the ones that were signed.
|
|
12
18
|
|
|
13
19
|
import { matchRoute } from './route-match.mjs';
|
|
14
20
|
|
|
@@ -23,6 +29,54 @@ export function defaultExtractGovernance(req) {
|
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The value fields the canonical signed message actually covers (policy-core buildAuthMessage:
|
|
34
|
+
* `agentDid|action|amount|currency|merchant|nonce|issuedAt`). These — and only these — are the
|
|
35
|
+
* fields a signature can be said to authorize, so these are what we bind the payload to.
|
|
36
|
+
*/
|
|
37
|
+
export const BOUND_FIELDS = ['amount', 'currency', 'merchant'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default payload binder: pull the governed value fields out of a JSON body.
|
|
41
|
+
*
|
|
42
|
+
* Returns null when there is nothing to bind — an empty body, a body that is not JSON, or JSON
|
|
43
|
+
* that mentions none of the governed fields. That is deliberate: a reverse proxy fronts upstreams
|
|
44
|
+
* whose payloads it cannot parse (protobuf, multipart, form-encoded), and failing those closed
|
|
45
|
+
* would brick every such deployment. It also means an OPAQUE BODY IS NOT BOUND — if your governed
|
|
46
|
+
* route carries the amount somewhere this cannot see (a non-JSON encoding, or nested JSON such as
|
|
47
|
+
* `{ booking: { amount } }`), give the route its own `bind(req)` that digs it out. Without one,
|
|
48
|
+
* the signature still gates the call but does not constrain that payload.
|
|
49
|
+
*/
|
|
50
|
+
export function defaultBindPayload(req) {
|
|
51
|
+
const raw = req.rawBody ?? req.body;
|
|
52
|
+
if (raw == null) return null;
|
|
53
|
+
let parsed = raw;
|
|
54
|
+
if (typeof raw === 'string' || raw instanceof Uint8Array) {
|
|
55
|
+
const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8');
|
|
56
|
+
if (!text.trim()) return null;
|
|
57
|
+
try { parsed = JSON.parse(text); } catch { return null; } // not JSON → nothing to bind
|
|
58
|
+
}
|
|
59
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const f of BOUND_FIELDS) if (parsed[f] !== undefined) out[f] = parsed[f];
|
|
62
|
+
return Object.keys(out).length ? out : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a payload value matches the value that was signed. `amount` is compared numerically
|
|
67
|
+
* (a JSON body may carry "500" where the signer sent 500); the rest as strings. Note the signed
|
|
68
|
+
* defaults verifyRequest() itself applies — amount 0, merchant '' — so a payload that introduces
|
|
69
|
+
* a value the signed request never mentioned counts as a MISMATCH, which is the whole point.
|
|
70
|
+
*/
|
|
71
|
+
export function boundValueMatches(field, signedValue, payloadValue) {
|
|
72
|
+
if (field === 'amount') {
|
|
73
|
+
const a = Number(signedValue ?? 0);
|
|
74
|
+
const b = Number(payloadValue);
|
|
75
|
+
return Number.isFinite(a) && Number.isFinite(b) && a === b;
|
|
76
|
+
}
|
|
77
|
+
return String(signedValue ?? '') === String(payloadValue ?? '');
|
|
78
|
+
}
|
|
79
|
+
|
|
26
80
|
/**
|
|
27
81
|
* Build the governed request handler.
|
|
28
82
|
* guard — anything with `verifyRequest(signed) => { decision, reasonCode, ... }` (an MCP guard).
|
|
@@ -30,11 +84,14 @@ export function defaultExtractGovernance(req) {
|
|
|
30
84
|
* forward — async (req) => { status, headers, body }: performs the upstream call. Injected for tests.
|
|
31
85
|
* extractGovernance — override the signed-request extractor (default: x-magp-request header).
|
|
32
86
|
* denyByDefault — when true, an UNMATCHED route is blocked (allow-list posture) instead of forwarded.
|
|
87
|
+
* bind — payload binder (default: defaultBindPayload). A route's own `bind` wins. Pass
|
|
88
|
+
* `false` to disable binding entirely and restore pre-0.1.2 behaviour — do that only
|
|
89
|
+
* for routes that carry no value fields, since it reopens the confused-deputy gap.
|
|
33
90
|
*
|
|
34
91
|
* Returns async (req) => { status, headers?, body, governance? }, where req is a normalized
|
|
35
92
|
* { method, path, headers, body }.
|
|
36
93
|
*/
|
|
37
|
-
export function createHttpGateway({ guard, routes = [], forward, extractGovernance = defaultExtractGovernance, denyByDefault = false } = {}) {
|
|
94
|
+
export function createHttpGateway({ guard, routes = [], forward, extractGovernance = defaultExtractGovernance, denyByDefault = false, bind = defaultBindPayload } = {}) {
|
|
38
95
|
if (typeof forward !== 'function') throw new Error('createHttpGateway requires a forward(req) function');
|
|
39
96
|
|
|
40
97
|
return async function handle(req) {
|
|
@@ -56,6 +113,31 @@ export function createHttpGateway({ guard, routes = [], forward, extractGovernan
|
|
|
56
113
|
// The route pins the action — a client cannot relabel a governed call as something cheaper.
|
|
57
114
|
const request = { ...signed, action: route.action ?? signed.action };
|
|
58
115
|
|
|
116
|
+
// Bind the payload to the signature BEFORE asking the issuer anything: a request whose body
|
|
117
|
+
// contradicts what was signed is refused here, so it costs no round trip and consumes no
|
|
118
|
+
// nonce. A decision obtained for one set of values must not authorize another set.
|
|
119
|
+
const binder = route.bind !== undefined ? route.bind : bind;
|
|
120
|
+
if (binder) {
|
|
121
|
+
let payload;
|
|
122
|
+
try {
|
|
123
|
+
payload = binder(req);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Fail CLOSED: if we cannot read the payload, we cannot claim the signature covers it.
|
|
126
|
+
return { status: 502, body: { decision: 'block', reasonCode: 'BIND_ERROR', error: String(err?.message ?? err) } };
|
|
127
|
+
}
|
|
128
|
+
if (payload) {
|
|
129
|
+
for (const field of BOUND_FIELDS) {
|
|
130
|
+
if (payload[field] === undefined) continue;
|
|
131
|
+
if (!boundValueMatches(field, request[field], payload[field])) {
|
|
132
|
+
return {
|
|
133
|
+
status: 403,
|
|
134
|
+
body: { decision: 'block', reasonCode: 'PAYLOAD_NOT_BOUND', field, action: route.action },
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
59
141
|
let decision;
|
|
60
142
|
try {
|
|
61
143
|
decision = await guard.verifyRequest(request);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-http-gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Generic HTTP interception gateway (SAFR §17) — a zero-dependency reverse proxy that governs arbitrary HTTP calls through the AgentSafe gate before forwarding upstream.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "gateway.mjs",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
21
|
"start": "node server.mjs",
|
|
22
|
-
"test": "node gateway.smoke.mjs"
|
|
22
|
+
"test": "node gateway.smoke.mjs && node bind-payload.smoke.mjs"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@metamynd/agentsafe-mcp-guard": "^0.1.0"
|