mirror of
https://github.com/mvschwarz/openrig-studio.git
synced 2026-11-07 05:56:21 -10:00
Handlers get a response facade, and an ack must be a recorded generation
sdk-qa re-round on cb6dfac — two MUST-FIX, both reproduced on that tip first.
MF1 — the request confinement leaked through the response object. Removing req
from ctx was real, but handlers still received Node's res, whose res.req
back-references the request: an ordinary handler read the url via res.req.url
(reproduced: it returned "/api/qa-leak?secret=xyz"). The design note overstated
"cannot reach in any spelling" — false while res.req was reachable. Handlers now
receive a RESPONSE FACADE exposing only writeHead/setHeader/write/end/pipeFrom,
closing over res in a scope they cannot reach; every method returns the facade,
a primitive, or undefined — never res. No object in handler scope retains the
request. files/raw streams via res.pipeFrom instead of piping to the raw res.
Control plants the back-reference read (res.req.url, the socket parser, the
connection) into a real handler, boots, and asserts every path yields nothing.
The note is corrected to state the facade is the mechanism and to stop
overstating. The req-confinement guard and adapter count are unchanged (the
facade uses res, not req).
MF2 — monotonic+bounded is not membership. Bounding the ack by the surface's
own latest gen still admitted a foreign one: with A=1, B=2, A=3 recorded, A's
latest is 3 and 2 <= 3, so acking 2 (recorded for B) on A passed. Each surface
now tracks the set of generations recorded FOR IT; an ack must be a MEMBER of
that set and forward of the watermark, so a future gen (never recorded) and a
foreign gen (recorded for another surface) are both refused. Generations at or
below the watermark are pruned. The interleaved discriminator (A=1,B=2,A=3, ack
2 on A) is added to the bounded-ack control and refused; A's own gen 3 accepted.
367 -> 368 tests, 368/368 green.
This commit is contained in:
1 parent
cb6dfacdd2
commit
5d4846877a
4 files changed
+94
-23
No files matched your search
+36
-12
@@ -836,7 +836,11 @@ let driveSeq = 0;
|
||||
let driveReads = 0; // total polls across all surfaces (contract report)
|
||||
const driveSlot = (surface) => {
|
||||
let slot = driveSlots.get(surface);
|
||||
if (!slot) { slot = { op: null, appliedGen: 0, reads: 0 }; driveSlots.set(surface, slot); }
|
||||
// `recorded` is the set of generations actually recorded FOR THIS surface,
|
||||
// above its applied watermark — an ack must be a MEMBER of it, not merely
|
||||
// monotonic and below the latest gen. `gen` is global, so A=1, B=2, A=3 leaves
|
||||
// A's recorded set {1,3}: acking 2 (B's gen) is a foreign gen and refused.
|
||||
if (!slot) { slot = { op: null, appliedGen: 0, reads: 0, recorded: new Set() }; driveSlots.set(surface, slot); }
|
||||
return slot;
|
||||
};
|
||||
// Per-surface marker: a surface watches its OWN slot's latest generation.
|
||||
@@ -861,6 +865,7 @@ function writeDrive(surface, op) {
|
||||
driveSeq += 1;
|
||||
const slot = driveSlot(surface);
|
||||
slot.op = { ...op, gen: driveSeq, at: new Date().toISOString() };
|
||||
slot.recorded.add(driveSeq); // this generation IS recorded for this surface
|
||||
// ACCEPTED IS NOT DELIVERED, AND THE CALLER FINDS OUT HERE. `listening` is
|
||||
// false when THIS surface has never polled: the op is recorded and that surface
|
||||
// will not act on it. Reported in the response the caller is already reading.
|
||||
@@ -1127,16 +1132,19 @@ const API_ROUTES = [
|
||||
const slot = driveSlot(surface);
|
||||
slot.reads += 1;
|
||||
driveReads += 1;
|
||||
// AN ACK IS BOUNDED TO A GENERATION ACTUALLY RECORDED FOR THIS SURFACE, and
|
||||
// monotonic within that bound (review.r2 MF2). Without the upper bound a
|
||||
// caller that knows a surface id could POST `?applied=999999` and move the
|
||||
// acknowledgement permanently ahead of every future op — externally
|
||||
// reachable state mutation that made the record-to-apply gap false without
|
||||
// any apply. A gen above the latest recorded op (a FUTURE gen, or a FOREIGN
|
||||
// gen recorded for another surface, since `gen` is global) is ignored.
|
||||
// AN ACK MUST BE A GENERATION ACTUALLY RECORDED FOR THIS SURFACE — MEMBERSHIP,
|
||||
// not merely monotonic and below the latest gen (review.r2 MF2, re-round).
|
||||
// `gen` is global, so bounding by the surface's own latest gen still admits
|
||||
// a FOREIGN gen: A=1, B=2, A=3 leaves A's recorded set {1,3}, and 2 <= 3 —
|
||||
// acking 2 on A once passed. Now the ack must be IN A's recorded set and
|
||||
// forward of the watermark; a future gen (never recorded) and a foreign gen
|
||||
// (recorded for another surface) are both refused. Once acked, generations
|
||||
// at or below the watermark are pruned — monotonicity keeps them un-ackable.
|
||||
const applied = Number(ctx.searchParams.get("applied"));
|
||||
const recorded = slot.op ? slot.op.gen : 0;
|
||||
if (Number.isInteger(applied) && applied > slot.appliedGen && applied <= recorded) slot.appliedGen = applied;
|
||||
if (Number.isInteger(applied) && applied > slot.appliedGen && slot.recorded.has(applied)) {
|
||||
slot.appliedGen = applied;
|
||||
for (const g of slot.recorded) if (g <= slot.appliedGen) slot.recorded.delete(g);
|
||||
}
|
||||
const since = ctx.searchParams.get("since");
|
||||
const marker = driveMarker(slot);
|
||||
return sendJson(res, 200, { ok: true, surface, changed: since === null || since !== marker,
|
||||
@@ -1266,7 +1274,7 @@ const API_ROUTES = [
|
||||
}
|
||||
res.writeHead(200, { "content-type": TYPES[path.extname(r).toLowerCase()] || "application/octet-stream",
|
||||
"content-length": st.size, "cache-control": "no-store" });
|
||||
return fs.createReadStream(r).pipe(res);
|
||||
return res.pipeFrom(fs.createReadStream(r));
|
||||
} },
|
||||
{ verb: "/api/files/search", handler: (ctx, res) => {
|
||||
const q = (ctx.searchParams.get("q") || "").trim();
|
||||
@@ -1306,7 +1314,23 @@ const handleRequest = async (req, res) => {
|
||||
readBody: () => readBody(req),
|
||||
onClose: (cb) => req.on("close", cb),
|
||||
};
|
||||
await row.handler(ctx, res);
|
||||
// A RESPONSE FACADE, not the raw `res` (MF1, sdk-qa re-round). Node's
|
||||
// ServerResponse back-references the request as `res.req` (and via the
|
||||
// socket), so handing handlers `res` let an ordinary handler read the
|
||||
// url through `res.req.url` — the ctx confinement was real but the
|
||||
// response object leaked around it. This facade exposes ONLY the
|
||||
// response methods handlers use and closes over `res` in a scope they
|
||||
// cannot reach; every method returns `resFacade`, a primitive, or
|
||||
// undefined — NEVER `res` — so no object in handler scope retains the
|
||||
// request. `sendJson(resFacade, …)` works (writeHead + end).
|
||||
const resFacade = {
|
||||
writeHead(...a) { res.writeHead(...a); return resFacade; },
|
||||
setHeader(...a) { res.setHeader(...a); return resFacade; },
|
||||
write(...a) { return res.write(...a); },
|
||||
end(...a) { res.end(...a); },
|
||||
pipeFrom(readable) { readable.pipe(res); },
|
||||
};
|
||||
await row.handler(ctx, resFacade);
|
||||
} else sendJson(res, 404, m.notFound);
|
||||
} catch (e) { return sendJson(res, 500, { ok: false, error: String(e.message || e) }); }
|
||||
return;
|
||||
|
||||
@@ -25,12 +25,18 @@ never holds a pathname at all.
|
||||
- **The raw request is CONFINED to the routing adapter** (MF1, sdk-qa).
|
||||
`handleRequest` references `req` only to route (`routeRequest(req.url, …)`) and
|
||||
to build a handler `ctx`. Handlers receive
|
||||
`{ method, headers, searchParams, verb, rest, readBody, onClose }` — **no url
|
||||
and no `req`** — so response-capable handler code cannot reach the raw request
|
||||
in ANY spelling (`req.url`, `req["url"]`, destructuring, aliasing).
|
||||
**This is the mechanism: the boundary is that the url is not in scope, not that
|
||||
a guard forbids one spelling of reading it.** An earlier fix counted the literal
|
||||
`req.url` and a property-access spelling walked straight past it.
|
||||
`{ method, headers, searchParams, verb, rest, readBody, onClose }` — no url and
|
||||
no `req` — **and a RESPONSE FACADE, not the raw `res`.** The facade matters:
|
||||
Node's `ServerResponse` back-references the request as `res.req`, so a handler
|
||||
holding the real `res` could read `res.req.url` around the ctx — the ctx
|
||||
confinement was real but the response object leaked. The facade exposes only
|
||||
`writeHead`/`setHeader`/`write`/`end`/`pipeFrom`, closes over `res` in a scope
|
||||
the handler cannot reach, and every method returns the facade, a primitive, or
|
||||
`undefined` — never `res`. So **no object in handler scope retains the
|
||||
request.** This is the mechanism: the boundary is that neither the url nor
|
||||
anything reaching it is in handler scope. (Two earlier fixes were narrower — one
|
||||
counted the literal `req.url` and a property-access spelling walked past it;
|
||||
one removed `req` from ctx but left the request reachable through `res.req`.)
|
||||
- Handlers receive `ctx`, never a pathname or a URL object. A handler needing
|
||||
path data uses `rest`, which is already scoped to its own matched verb.
|
||||
- `handleRequest` calls `routeRequest` and dispatches the indexed row. Neither
|
||||
@@ -60,9 +66,11 @@ without a row" is a fact about the data flow, not a promise a guard checks.
|
||||
is live while API requests are in flight — the exact material of the round-5
|
||||
finding. The unwritability control in `test/reserved-verbs.test.mjs` will
|
||||
fail if you do this.
|
||||
- **Handlers take `ctx`, not `req` or `u`.** Widening a handler back to
|
||||
`(request, req, res)` — or passing the URL object — hands it the raw request
|
||||
and its url again, and the MF1 boundary is gone. The confinement guard fails.
|
||||
- **Handlers take `ctx` and a response FACADE, not `req`, `u`, or the raw `res`.**
|
||||
Widening a handler back to `(request, req, res)`, passing the URL object, or
|
||||
handing it the raw `res` (whose `res.req` back-references the request) all
|
||||
reopen the MF1 leak. The confinement guard and the response-back-reference
|
||||
control fail.
|
||||
- **`matchApi` and `routeRequest` live in `app/verbs.mjs`, not beside the table.** The grammar
|
||||
module is the routing definition; a special case must be written into the
|
||||
definition of routing itself, where the `routes=[]` property test and review
|
||||
@@ -83,6 +91,10 @@ exist to catch a change that reintroduces it.
|
||||
`req`, so the identifier count catches the property-access equivalents the old
|
||||
`req.url` text-match missed (MF1). Its control plants literal, bracket,
|
||||
destructured, and aliased raw-url reads and asserts each is caught.
|
||||
- **response-back-reference control** (`test/reserved-verbs.test.mjs`): plants a
|
||||
handler that tries to read the url through the response object (`res.req.url`,
|
||||
the socket's parser, the connection) and asserts every path yields nothing —
|
||||
the runtime proof that the facade retains no reference to the request.
|
||||
- **Unwritability control** (`test/reserved-verbs.test.mjs`): plants sdk-qa's
|
||||
round-4 literal arm and round-5 derived-prefix arm at the top of the API flow
|
||||
and asserts each detonates as a `ReferenceError` naming the absent binding —
|
||||
|
||||
+13
-2
@@ -396,11 +396,22 @@ test("SETTLE-ACK is BOUNDED: a future or foreign generation cannot be acked", as
|
||||
const foreign = await s.readAt("A", { applied: "2" });
|
||||
assert.equal(foreign.appliedGen, 0, "a generation recorded for another surface was acked on this one");
|
||||
|
||||
// The legitimate ack (== the recorded gen for A) is accepted and monotonic.
|
||||
// The legitimate ack (a gen recorded for A) is accepted and monotonic.
|
||||
const ok = await s.readAt("A", { applied: "1" });
|
||||
assert.equal(ok.appliedGen, 1, "a valid ack bounded to the recorded generation was refused");
|
||||
assert.equal(ok.appliedGen, 1, "a valid ack of a recorded generation was refused");
|
||||
const back = await s.readAt("A", { applied: "0" });
|
||||
assert.equal(back.appliedGen, 1, "appliedGen went backwards — monotonicity lost");
|
||||
|
||||
// THE INTERLEAVED DISCRIMINATOR (sdk-qa re-round): A=1, B=2, A=3. Gen 2 is
|
||||
// below A's latest recorded gen (3) but was NEVER recorded for A — membership,
|
||||
// not just an upper bound. Acking 2 on A must be refused; acking 3 (A's own)
|
||||
// accepted.
|
||||
await s.driveTo("A", { play: true }); // A now has gen 3 (B's gen 2 sits between)
|
||||
const interleaved = await s.readAt("A", { applied: "2" });
|
||||
assert.equal(interleaved.appliedGen, 1,
|
||||
"gen 2 (recorded for B, below A's latest) was acked on A — monotonic+bounded is not membership");
|
||||
const own = await s.readAt("A", { applied: "3" });
|
||||
assert.equal(own.appliedGen, 3, "A's own recorded generation 3 was refused");
|
||||
});
|
||||
|
||||
test("SETTLE-ACK: appliedGen exposes a real posted-but-not-yet-applied window", async (t) => {
|
||||
|
||||
@@ -569,6 +569,30 @@ test("MF1 CONTROL: every semantic-equivalent raw-url access at the adapter entry
|
||||
}
|
||||
});
|
||||
|
||||
test("MF1 CONFINEMENT: a handler cannot read the request URL through the response object", async () => {
|
||||
// review.r2 MF1 re-round: the ctx confinement was real, but handlers still
|
||||
// received Node's `res`, which back-references the request as `res.req` — an
|
||||
// ordinary handler read the url through `res.req.url` (reproduced on the prior
|
||||
// tip). The response the handler receives is now a FACADE that exposes only
|
||||
// response methods and retains no reference to the request. This control plants
|
||||
// the back-reference read into a real handler, boots, and asserts the url is
|
||||
// unreachable through every path off the response object.
|
||||
const src = read("app", "serve-studio.mjs");
|
||||
const anchor = 'return sendJson(res, 200, { ok: true, hits: q.length < 2 ? [] : filesSearch(q) });';
|
||||
assert.ok(src.includes(anchor), "the files/search handler moved — re-anchor this control before trusting it");
|
||||
const planted = src.replace(anchor,
|
||||
'return sendJson(res, 200, { ok: true, hits: [], ' +
|
||||
'qaLeak: (res.req && res.req.url) || (res.socket && res.socket.parser && res.socket.parser.incoming && res.socket.parser.incoming.url) || (res.connection && res.connection.parser) || null });');
|
||||
assert.notEqual(planted, src, "the leak plant did not apply");
|
||||
|
||||
const { statuses, bodies } = await bootMutated(planted, ["/api/files/search?secret=leak-me"]);
|
||||
assert.equal(statuses["/api/files/search?secret=leak-me"], 200, "the planted handler did not answer");
|
||||
const body = JSON.parse(bodies["/api/files/search?secret=leak-me"]);
|
||||
assert.equal(body.qaLeak, null,
|
||||
`a handler read the request URL through the response object (${JSON.stringify(body.qaLeak)}) — ` +
|
||||
`the confinement leaks around ctx via the response's request back-reference`);
|
||||
});
|
||||
|
||||
test("I6: /api/files/raw honours the stable error contract — 4xx JSON, and a directory is refused before any 200", async () => {
|
||||
// review.r2 IMPORTANT: a bare empty 404 violated "all client errors are 4xx
|
||||
// JSON { ok:false, error }", and a directory passed existsSync, got 200 headers
|
||||
|
||||
Reference in new issue
Block a user