Skip to content

Commit 569fabc

Browse files
committed
Gate the bundler route at the edge, not p4; revert the write-gate subject rule
1 parent 82803c2 commit 569fabc

4 files changed

Lines changed: 71 additions & 113 deletions

File tree

ao/runtime/write-gate.lua

Lines changed: 1 addition & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -85,88 +85,6 @@ function gate.configuredIds(base)
8585
return out
8686
end
8787

88-
--- The node's OWN uploads arrive with an UNSIGNED ENVELOPE.
89-
---
90-
--- `dev_arweave:post_tx/4`'s `ans104@1.0` clause posts
91-
--- hb_http:post(<bundler-ans104>, #{ path, `bundler-subject` = `body`, body = <the item> })
92-
--- and `hb_http:post` never commits what it sends — there is no `hb_message:commit` anywhere on
93-
--- the outbound request path. The signature rides on the nested item, which is precisely what
94-
--- `bundler-subject` tells `dev_bundler:verify_message` to check INSTEAD of the envelope. Measured
95-
--- shape of `req.request` for one of these:
96-
---
97-
--- Message < Path: /~bundler@1.0/tx > {
98-
--- commitments (absent — the envelope is unsigned)
99-
--- bundler-subject => `body`
100-
--- body => the SIGNED item, with its own commitments and a `target` or `process`
101-
--- }
102-
---
103-
--- So a scheduler upload reaches p4 unsigned. `dev_faff` admits it (`lists:all` over an empty
104-
--- signer list is vacuously true) and this gate's `n == 0` rule refuses it. That asymmetry is why
105-
--- self-bundling worked on dev and silently stopped publishing on stage and live: the scheduler
106-
--- DISCARDS the upload result (`dev_scheduler_server.erl:266-269`), so a refusal is dropped and
107-
--- never retried and the slot's assignment never reaches Arweave. Eight stage slots were lost
108-
--- that way on 2026-08-27 before it was reverted.
109-
---
110-
--- ⚠️ This is the SECOND of two independent causes, and it was invisible until the first was
111-
--- fixed. Before hyperbeam-docker patch 0005, `dev_lua` could not encode a cache link, so the
112-
--- request never reached this function at all and every gate variant failed identically. Both
113-
--- fixes are required; neither alone is enough.
114-
---
115-
--- ⚠️ This does NOT weaken the gate, and specifically it is not the forbidden shortcut of putting
116-
--- `^/~bundler@1.0` in `p4-non-chargable-routes`. The subject is judged by exactly the same rule
117-
--- as a write: our own wallets, or an address the target contract already admits. An unsigned
118-
--- subject still refuses, and a stranger's signed item names no gated target and refuses too.
119-
---
120-
--- Asserted by scripts/probe/gated-bundler-repro.ts, which runs locally and costs no slots.
121-
--- 🚨 Is this the bundler route? The subject rule below MUST NOT apply anywhere else.
122-
---
123-
--- A `committer` is a FIELD, not a signature check. Whether it can be trusted depends on who
124-
--- verified the message before p4 ran, and that is codec-dependent
125-
--- (`hb_http:req_to_tabm_singleton`): `ans104@1.0` is verified unconditionally by
126-
--- `ar_bundles:verify_item`, `tx@1.0` by `ar_tx:verify`, any other codec by `hb_message:verify`
127-
--- — but `httpsig@1.0` is verified ONLY when `force_signed_requests` is set, and it is not set on
128-
--- any of our nodes (checked on stage and live: absent, so upstream's `false`).
129-
---
130-
--- The bundler envelope is unsigned httpsig, so its nested subject's `committer` is UNVERIFIED
131-
--- data at this point. Without this path check, anyone could put `bundler-subject` on an unsigned
132-
--- httpsig POST to `/<pid>~process@1.0/push`, declare a `committer` of one of our deploy wallets
133-
--- in the body, and be admitted — consuming the slot the gate exists to protect.
134-
---
135-
--- Confined to `~bundler@1.0`, a forged subject buys nothing: the request reaches `dev_bundler`,
136-
--- whose own `verify_message` checks the subject's signature properly and rejects it, and no slot
137-
--- is created and no AR is spent on that path. The gate is still the cost filter; the bundler is
138-
--- the authority for what it bundles, exactly as each contract's ACL is for what it accepts.
139-
---
140-
--- Prefix-compared with `string.sub`, no pattern engine — same reasoning as `targetOf`.
141-
function gate.isBundlerPath(path)
142-
if type(path) ~= 'string' then return false end
143-
local want = '/~bundler@1.0/'
144-
if string.sub(path, 1, #want) == want then return true end
145-
-- Some callers present the path without its leading slash.
146-
return string.sub(path, 1, #want - 1) == string.sub(want, 2)
147-
end
148-
149-
function gate.subjectOf(m)
150-
if type(m) ~= 'table' then return nil end
151-
local key = m['bundler-subject']
152-
if type(key) ~= 'string' or key == '' then return nil end
153-
local subject = m[key]
154-
if type(subject) ~= 'table' then return nil end
155-
return subject
156-
end
157-
158-
--- Which gated contract is this SUBJECT aimed at? An assignment names it in `process`, a message
159-
--- in `target`. There is nothing to prefix-match as there is for a path: the value either IS one
160-
--- of our ids or it is not.
161-
function gate.subjectTargetOf(subject, ids)
162-
if type(subject) ~= 'table' then return nil end
163-
local p = subject.process
164-
if type(p) == 'string' and ids[p] then return p end
165-
local t = subject.target
166-
if type(t) == 'string' and ids[t] then return t end
167-
return nil
168-
end
169-
17088
--- Which gated contract is this request writing to?
17189
---
17290
--- Matched as a PREFIX (`/<id>~process@1.0`), not a substring: an id appearing anywhere else in
@@ -244,15 +162,6 @@ function estimate(base, req, opts)
244162
local request = type(req) == 'table' and req.request or nil
245163
local signers, n = gate.committersOf(request)
246164

247-
-- An unsigned ENVELOPE is not the same thing as an unsigned request. The bundler route carries
248-
-- its signature on the subject the envelope names, so look there before refusing — otherwise the
249-
-- node's own uploads are refused and their assignments are lost silently.
250-
local subject = nil
251-
if n == 0 and gate.isBundlerPath(request and (request.path or request['request-path'])) then
252-
subject = gate.subjectOf(request)
253-
if subject ~= nil then signers, n = gate.committersOf(subject) end
254-
end
255-
256165
-- Deny-by-default on unsigned. Deliberately stricter than dev_faff, whose `lists:all` over an
257166
-- empty signer list is vacuously true — stock faff ADMITS unsigned requests.
258167
if n == 0 then return 'ok', REFUSE end
@@ -263,15 +172,8 @@ function estimate(base, req, opts)
263172
for i = 1, n do if not deploy[signers[i]] then allDeploy = false end end
264173
if allDeploy then return 'ok', ADMIT end
265174

266-
-- A bundler POST's path is `/~bundler@1.0/tx` and names no contract, so the target has to come
267-
-- from the subject. Everything else is still matched on the path.
268175
local ids = gate.configuredIds(base)
269-
local pid
270-
if subject ~= nil then
271-
pid = gate.subjectTargetOf(subject, ids)
272-
else
273-
pid = gate.targetOf(request and (request.path or request['request-path']), ids)
274-
end
176+
local pid = gate.targetOf(request and (request.path or request['request-path']), ids)
275177
if not pid then return 'ok', REFUSE end
276178

277179
local opreg = base and base['operator-registry']

ao/scripts/probe/gated-bundler-repro.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ const writeConfig = (opts: {
127127
{ template: '^/~meta@1.0' },
128128
{ template: '^/~hyperbuddy@1.0' },
129129
{ template: '^/~query@1.0' },
130+
// Mirrors stage and live: the bundler route is exempt from pricing, and the EDGE is what
131+
// keeps it off the internet. Legitimate only because those edges refuse `~bundler@1.0`;
132+
// dev, whose edge is open, keeps p4 in front of it instead.
133+
{ template: '^/~bundler@1.0/(tx|item)$' },
130134
],
131135
'rate-limit-requests': 100000,
132136
'rate-limit-max': 100000,

ao/scripts/probe/p4-gate-e2e.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ const writeConfig = (gatedPids: string[], opreg = '', gateModuleId = '', deployW
110110
{ template: '^/~meta@1.0' },
111111
{ template: '^/~hyperbuddy@1.0' },
112112
{ template: '^/~query@1.0' },
113+
// NO bundler carve-out here, deliberately, even though stage and live have one. That
114+
// carve-out is only safe because those edges refuse `~bundler@1.0` outright, and this
115+
// probe's node has no edge at all — so p4 must stay in front of the bundler, exactly as it
116+
// does on dev. Mirroring production here was tried and `verify-access-policy` correctly
117+
// failed it twice over: the carve-out was unpaired, and a stranger's item was accepted.
118+
// See scripts/probe/gated-bundler-repro.ts, which does carry it, for the reasoning.
113119
],
114120
'rate-limit-requests': 100000, 'rate-limit-max': 100000, 'rate-limit-period': 60,
115121
// faff is still the LEDGER device; its list governs everything the gate does not.

ao/scripts/verify-access-policy.ts

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -288,14 +288,29 @@ for (const env of targets) {
288288
}), 'deploy-wallets is non-empty and EIP-55, excluding the node self-entry (spawns are impossible without it)',
289289
`${dwEvm.length} entries${dwSelf.length ? ' + node self-entry' : ''}`)
290290

291-
// Assert the PAIRING rather than each half. A node pointed at its own bundler without the
292-
// self-entry silently refuses its own uploads; the self-entry without the loopback target is
293-
// a widening that buys nothing. Either alone is a misconfiguration.
291+
// Assert the PAIRING rather than each half: a loopback bundler needs the route carve-out to
292+
// let the node's own upload through, and a carve-out without a loopback target is a widening
293+
// that buys nothing. Either alone is a misconfiguration.
294+
//
295+
// ⚠️ The pairing used to be loopback <-> a node SELF-ENTRY in `deploy-wallets`, and that never
296+
// worked: the node signs its uploads on the nested item, never on the envelope, so p4 sees no
297+
// signer and no wallet list can match. Shipping it stalled publishing on stage and live and
298+
// cost 8 slots. The self-entry is now redundant and should be ABSENT — it granted the node
299+
// identity slot-consuming and spawn rights for nothing.
294300
const bundlerTarget = (await info(host))?.['bundler-ans104']
295301
const selfBundling = typeof bundlerTarget === 'string' && /127\.0\.0\.1|localhost/.test(bundlerTarget)
296-
check(selfBundling === (dwSelf.length === 1),
297-
'self-bundling configured coherently (loopback bundler <-> node self-entry in deploy-wallets)',
298-
`bundler=${bundlerTarget ?? '(unset)'} selfEntry=${dwSelf.length}`)
302+
// Read here rather than reusing the `bundlerRoutes` computed further down: that one lives
303+
// after this block, and a forward reference would be `undefined` at run time rather than a
304+
// compile error.
305+
const carveOuts = (await listOf(host, 'p4-non-chargable-routes'))
306+
.map(r => r?.template)
307+
.filter(r => typeof r === 'string' && /bundler/i.test(r))
308+
check(selfBundling === (carveOuts.length > 0),
309+
'self-bundling configured coherently (loopback bundler <-> ~bundler@1.0 route carve-out)',
310+
`bundler=${bundlerTarget ?? '(unset)'} carveOut=${carveOuts.length}`)
311+
check(dwSelf.length === 0,
312+
'deploy-wallets carries NO node self-entry (it never admitted the upload and grants slots + spawn)',
313+
dwSelf.length ? String(dwSelf[0]) : 'none')
299314
} else {
300315
check(p4?.['pricing-device'] === 'faff@1.0' && p4?.['ledger-device'] === 'faff@1.0',
301316
'final hook is p4 with faff pricing + ledger devices',
@@ -333,7 +348,13 @@ for (const env of targets) {
333348

334349
// --- native: p4 carve-outs ---------------------------------------------
335350
const routes = (await listOf(host, 'p4-non-chargable-routes')).map(r => r?.template)
336-
check(routes.length === 7, 'p4-non-chargable-routes has exactly 7 entries', `got ${routes.length}`)
351+
// 7 base entries, plus the bundler carve-out on an environment that self-bundles. The carve-out
352+
// is legitimate ONLY where the edge refuses `~bundler@1.0` outright — see the paired assertion
353+
// further down, which is the one that actually protects the wallet.
354+
const bundlerRoutes = routes.filter(r => typeof r === 'string' && /bundler/i.test(r))
355+
check(routes.length === 7 + bundlerRoutes.length,
356+
`p4-non-chargable-routes has exactly ${7 + bundlerRoutes.length} entries (7 base + ${bundlerRoutes.length} bundler)`,
357+
`got ${routes.length}`)
337358
// `.every()` is true for an empty list, so each assertion below is paired with a
338359
// length guard — otherwise a container that failed to parse reads as a clean pass.
339360
check(routes.length > 0 && routes.every(t => typeof t === 'string' && t.startsWith('^/')),
@@ -622,8 +643,14 @@ for (const env of targets) {
622643
// Treat any 2xx as acceptance regardless of body, so a future response-shape change
623644
// cannot quietly turn this into a pass.
624645
const accepted = res.status >= 200 && res.status < 300
646+
// ⚠️ WHAT THIS ACTUALLY ASSERTS depends on the node. Through a real edge that refuses
647+
// `~bundler@1.0` the request never reaches p4, so a pass here is evidence about the EDGE and
648+
// says nothing about the pricing device. On a node with no edge it is evidence about p4. It
649+
// is a genuine check either way — the property is "a stranger cannot make us spend AR" — but
650+
// do not read a pass as proof that p4 gates the bundler. It usually does not; on stage and
651+
// live the carve-out means it deliberately does not.
625652
check(!accepted,
626-
'bundler REFUSES an item from a non-allow-listed signer (acceptance would spend our AR)',
653+
'a stranger CANNOT get an item bundled (edge or p4, whichever is the control here)',
627654
`HTTP ${res.status}`)
628655

629656
// `verify_message` rejects `unsigned_item` before anything is queued or metered. This is
@@ -641,12 +668,31 @@ for (const env of targets) {
641668
`HTTP ${unsignedBundle.status}`)
642669
}
643670

644-
// A p4 carve-out for the bundler would exempt it from charging entirely, i.e. remove the
645-
// only gate in front of a spending endpoint. Nothing should ever put it here, so assert
646-
// the absence rather than trusting review to catch it.
647-
check(!routes.some(r => typeof r === 'string' && /bundler/i.test(r)),
648-
'no p4-non-chargable carve-out for ~bundler@1.0 (a carve-out would ungate spending)',
649-
routes.filter(r => typeof r === 'string' && /bundler/i.test(r)).join(' ') || 'none')
671+
// THE bundler invariant, and it is CONDITIONAL — an earlier version of this check asserted
672+
// the carve-out was never present, full stop, which is right for an open edge and wrong for a
673+
// closed one.
674+
//
675+
// `~bundler@1.0` accepts items this node pays an L1 reward to bundle, so exactly one control
676+
// must stand in front of it. p4 cannot be that control on a node that self-bundles: the node
677+
// signs its uploads on the nested item, never on the envelope (`hb_http:post` does not commit
678+
// what it sends), so the gate sees an unsigned request and would have to admit the route for
679+
// self-bundling to work at all. p4 is also the wrong layer for the actual risk, which is
680+
// VOLUME rather than identity — a large body is parsed and its nested item deserialized
681+
// before any pricing device runs.
682+
//
683+
// So the rule is a PAIR, and this asserts the pair rather than either half:
684+
// carve-out present -> the edge MUST refuse ~bundler@1.0 (stage, live)
685+
// carve-out absent -> p4 is the control, as on the open dev edge
686+
// Getting this wrong in the permissive direction puts a funded wallet behind an open
687+
// endpoint, which is why it is asserted behaviourally against the real edge, not from config.
688+
if (bundlerRoutes.length > 0) {
689+
const edge = await get(host, '/~bundler@1.0/tx')
690+
check(edge.status === 403 || edge.status === 404,
691+
'bundler carve-out is paired with an edge that REFUSES ~bundler@1.0',
692+
`carve-out ${bundlerRoutes.join(' ')} + edge HTTP ${edge.status}`)
693+
} else {
694+
check(true, 'no p4 carve-out for ~bundler@1.0 — p4 is the control on this node', 'none')
695+
}
650696
}
651697
}
652698

0 commit comments

Comments
 (0)