Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions packages/endpoint-attest/src/ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,42 @@ export interface OwnershipCheckDeps {
maxFileBytes?: number;
}

/**
* WAAROM `token` HIERIN ZIT, en dat is geen uitbreiding maar een reparatie.
*
* Het gepubliceerde 402-contract van de betaalde route eist `token` als verplicht
* uitvoerveld, en dit type gaf het nooit terug. Gemeten op 2026-10-18: ons eigen
* leveringscontroleur leest precies dat veld, dus hij kon geen enkel echt antwoord
* van onze eigen dienst verwerken. Een belofte in een catalogus die de route niet
* nakomt is erger dan een ontbrekende belofte.
*
* WAAROM DIT VEILIG IS, EN DE EERSTE VERSIE VAN DEZE ONDERBOUWING WAS ONJUIST. Er stond
* dat het token "per constructie een publieke waarde" is omdat de eigenaar hem zelf moet
* publiceren. Dat klopt bij een GESLAAGDE controle en is bij een MISLUKTE per definitie
* onwaar: mislukt betekent juist dat hij hem NIET gepubliceerd heeft. Onze eigen tweede
* keuring wees dat aan, en de verkeerde formulering stond toen ook al in het publieke
* Bazaar-schema van de betaalde route.
*
* De echte reden dat dit niets weggeeft is gemeten en luidt anders. `/api/challenge`
* geeft precies hetzelfde token al terug voor een WILLEKEURIG paar van eindpunt en
* eigenaar, zonder enig bewijs, met een RUIMERE limiet (30 per 5 minuten tegen 10) en een
* LOSSERE gastheercontrole. Wie het token wil hebben, heeft deze route dus niet nodig en
* neemt de goedkopere. Drie scenario's zijn nagelopen (een overgenomen subdomein, een
* zwevend dns-record, een plek waar een ander een bestand kan zetten) en geen van de
* drie geeft een aanvaller iets extra: het token bindt aan het VOLLEDIGE eindpunt, dus
* schema plus host plus poort plus pad, en publiceren kan alleen wie die plek beheert.
*
* DE KOPPELING DIE HIERUIT VOLGT, en die is nergens anders vastgelegd dan hier en in de
* test ernaast: zet iemand `/api/challenge` later achter een inlog, dan wordt deze
* functie stil het goedkoopste orakel voor hetzelfde token. Dan hoort deze afweging
* opnieuw gemaakt te worden, en niet overgenomen.
*
* Het token gaat ook mee bij een MISLUKTE controle, want dan is hij het antwoord op "wat
* had er moeten staan", en dat is wat een eigenaar nodig heeft om het te repareren.
*/
export type OwnershipResult =
| { ok: true; method: OwnershipMethod; location: string; verifiedAt: string }
| { ok: false; method: OwnershipMethod; location: string; reason: string };
| { ok: true; method: OwnershipMethod; token: string; location: string; verifiedAt: string }
| { ok: false; method: OwnershipMethod; token: string; location: string; reason: string };

/**
* Verify control of `endpoint` by the holder of `ownerAddress`, using one
Expand Down Expand Up @@ -260,16 +293,16 @@ async function verifyTokenPublished(
try {
records = await resolver(location);
} catch (e) {
return { ok: false, method, location, reason: `dns lookup failed: ${(e as Error).message}` };
return { ok: false, method, token: expected, location, reason: `dns lookup failed: ${(e as Error).message}` };
}
// A TXT record can be split into multiple strings by the resolver; the
// published value is their concatenation, which is how every DNS client
// reassembles long records.
const values = records.map((chunks) => chunks.join("").trim());
if (!values.includes(expected)) {
return { ok: false, method, location, reason: "no TXT record at that name matched the expected token" };
return { ok: false, method, token: expected, location, reason: "no TXT record at that name matched the expected token" };
}
return { ok: true, method, location, verifiedAt: now() };
return { ok: true, method, token: expected, location, verifiedAt: now() };
}

const fetchImpl = deps.fetchImpl;
Expand All @@ -285,25 +318,25 @@ async function verifyTokenPublished(
// merely has an open redirect at /.well-known/endpoint-attest.txt.
response = await fetchImpl(location, { signal: AbortSignal.timeout(timeoutMs), redirect: "error" });
} catch (e) {
return { ok: false, method, location, reason: `fetch failed: ${(e as Error).message}` };
return { ok: false, method, token: expected, location, reason: `fetch failed: ${(e as Error).message}` };
}
if (!response.ok) {
return { ok: false, method, location, reason: `responded ${response.status} ${response.statusText}` };
return { ok: false, method, token: expected, location, reason: `responded ${response.status} ${response.statusText}` };
}

let body: string;
try {
body = await readBounded(response, maxFileBytes);
} catch (e) {
return { ok: false, method, location, reason: (e as Error).message };
return { ok: false, method, token: expected, location, reason: (e as Error).message };
}
// Exact match on the trimmed content, not a substring search: a token
// buried in an otherwise unrelated page does not demonstrate that the
// operator deliberately published it.
if (body.trim() !== expected) {
return { ok: false, method, location, reason: "file content did not exactly match the expected token" };
return { ok: false, method, token: expected, location, reason: "file content did not exactly match the expected token" };
}
return { ok: true, method, location, verifiedAt: now() };
return { ok: true, method, token: expected, location, verifiedAt: now() };
}

/** Read a response body up to maxBytes, refusing the moment the limit is crossed instead of buffering the rest. */
Expand Down
163 changes: 160 additions & 3 deletions packages/self-check-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export interface CreateAppOptions {
trustProxy?: boolean | number | string;
/** Rate limit for the (real network I/O) verify-ownership route. Defaults to 10 requests / 5 minutes per IP. */
verifyRateLimit?: { windowMs: number; max: number };
/**
* Rate limit for the PAID verify-ownership route, with its OWN bucket.
*
* It shared the free route's bucket, which let anyone spend a paying customer's
* budget with free calls. Falls back to verifyRateLimit when unset, so nothing
* changes for an existing caller except that the two no longer drain each other.
*/
paidRateLimit?: { windowMs: number; max: number };
/** Rate limit for the (no network I/O) challenge route. Defaults to 30 requests / 5 minutes per IP — looser, since it is pure local HMAC, same posture trust-attest-server's own comments describe for get_ownership_challenge. */
challengeRateLimit?: { windowMs: number; max: number };
}
Expand Down Expand Up @@ -177,6 +185,73 @@ function guardEndpointLiteralOnly(endpoint: string, res: Response): boolean {
return true;
}

/**
* Zet een storing in de afrekening om in een eerlijk antwoord, en zet de res daarna terug.
*
* OP MODULEBEREIK EN GEEXPORTEERD, met opzet. Deze twee sluiten over niets uit createApp
* heen, en binnen createApp waren ze niet te toetsen: onze eigen keuring moest de wrapper
* letterlijk kopieren om hem te kunnen meten, en een test op een kopie meet de kopie. Nu
* meet de test het echte ding.
*/
export const BETAAL_STORING = {
ok: false,
error: "settlement is temporarily unavailable, so no payment was taken and no receipt was issued",
};

export const betaalstoringNaar503 = (_req: Request, res: Response, next: NextFunction): void => {
const origJson = res.json.bind(res);
const origSend = res.send.bind(res);
const origSendStatus = res.sendStatus.bind(res);

// ALLE DRIE DE UITGANGEN, niet alleen res.json. Gemeten: @x402/express antwoordt met
// res.send voor een html-lichaam, dus die weg liep gewoon om deze omzetting heen en
// leverde de koper een kale 500. res.sendStatus idem.
const naar503 = (watErGebeurde: unknown): boolean => {
if (res.headersSent) return false;
if (res.statusCode !== 500) return false;
console.error("[app] the paid route answered 500, reported to the buyer as 503:", watErGebeurde);
res.status(503);
origJson(BETAAL_STORING);
return true;
};

res.json = ((lichaam: unknown) => (naar503(lichaam) ? res : origJson(lichaam as never))) as typeof res.json;
res.send = ((lichaam: unknown) => (naar503(lichaam) ? res : origSend(lichaam as never))) as typeof res.send;
res.sendStatus = ((code: number) => {
if (code === 500 && !res.headersSent) {
console.error("[app] the paid route answered sendStatus 500, reported to the buyer as 503");
res.status(503);
origJson(BETAAL_STORING);
return res;
}
return origSendStatus(code);
}) as typeof res.sendStatus;

// En de terugzetter hangt aan dit verzoek, zodat de volgende middleware hem kan
// aanroepen zonder de sluitingen hierboven nog eens op te bouwen.
(res as unknown as { __herstelAntwoord?: () => void }).__herstelAntwoord = () => {
res.json = origJson as typeof res.json;
res.send = origSend as typeof res.send;
res.sendStatus = origSendStatus as typeof res.sendStatus;
};
next();
};

/**
* Zet de originele res-uitgangen terug zodra de betaalmiddleware heeft doorgegeven.
*
* DIT IS DE HELE REPARATIE, en zonder deze stap herschreef de wrapper ook een 500 uit
* ONZE EIGEN handler, die niets met afrekenen te maken heeft. Een echte fout kreeg dan
* een vals excuus mee, en dat is erger dan een kale 500: de koper krijgt te horen dat
* er niets is afgeschreven, terwijl de storing ergens anders zat. Van buitenaf is niet
* te zien welke code een 500 gaf, maar wel WIE het antwoord op dat moment in handen
* had, en dat is de grens die hier getrokken wordt.
*/
export const herstelAntwoord = (_req: Request, res: Response, next: NextFunction): void => {
(res as unknown as { __herstelAntwoord?: () => void }).__herstelAntwoord?.();
next();
};

export function createApp(options: CreateAppOptions = {}): Express {
const identity = options.identity ?? loadOwnershipSecret();
const allowedOrigins = options.allowedOrigins ?? DEFAULT_ALLOWED_ORIGINS;
Expand All @@ -194,6 +269,16 @@ export function createApp(options: CreateAppOptions = {}): Express {
windowMs: options.verifyRateLimit?.windowMs ?? 5 * 60 * 1000,
max: options.verifyRateLimit?.max ?? 10,
});
// EEN EIGEN EMMER VOOR DE BETAALDE ROUTE. Hij deelde die met de gratis route, en
// dat is tegen de regel die rate-limit.ts zelf opschrijft: een limiet PER AANROEPER
// op alles wat echt werk doet. De betaalde route doet MEER werk dan de gratis
// (hij praat ook met een facilitator), en een gedeelde emmer betekent dat iemand
// met gratis verzoeken het budget van de betalende klant kan opmaken. Dat is geen
// bescherming, dat is een dicht te drukken verdienroute.
const paidLimiter = createRateLimiter({
windowMs: options.paidRateLimit?.windowMs ?? options.verifyRateLimit?.windowMs ?? 5 * 60 * 1000,
max: options.paidRateLimit?.max ?? options.verifyRateLimit?.max ?? 10,
});

const app = express();
app.disable("x-powered-by");
Expand Down Expand Up @@ -283,18 +368,90 @@ export function createApp(options: CreateAppOptions = {}): Express {
// the buyer's half of a delivery record has nothing to name while no seller
// emits receipts.
const seller = options.seller === undefined ? readSellerConfig(process.env) : options.seller;
// A THIRD PARTY MUST NOT BE ABLE TO CLOSE OUR FREE DOOR. Building the payment
// middleware constructs a facilitator client, so anything wrong out there (an
// unusable url, a client that reaches for /supported while being constructed)
// threw right here, at app construction, and took down every free route with it:
// health, challenge, verify-ownership, the badge and the conformance endpoint.
// The paid route is one route; the free ones are the reason this server exists.
//
// What we do NOT do is mount the paid route anyway. Advertising a price we cannot
// settle is a false promise, which is the same reason an unconfigured seller has
// no paid route at all. So: loud on the way out, and the paid door stays shut.
let betaalmiddleware: ReturnType<typeof buildPaymentMiddleware> | null = null;
if (seller) {
try {
betaalmiddleware = buildPaymentMiddleware(seller);
} catch (error) {
console.error(
"[app] the paid route could not be built, so it is NOT mounted. Every free route stays up:",
error,
);
}
}

/**
* Een onbereikbare facilitator is onze storing, niet die van de koper, en hij
* hoort dat te horen in plaats van een kale 500 te krijgen.
*
* GEMETEN, en anders dan de keuring vermoedde. Die dacht dat een kapotte
* facilitator het hele proces kon afsluiten. Dat reproduceert NIET: de
* betaalmiddleware wordt lui opgebouwd en reikt pas bij het eerste verzoek naar
* buiten. Wat er wel gebeurde, met een adres dat niet oplost: de betaalde route
* antwoordde `500 Internal Server Error` en niets meer. Voor een koper is dat niet
* te onderscheiden van "dit product is stuk", terwijl het ware antwoord is "wij
* kunnen nu niet afrekenen, er is niets betaald". 503 met een korte, ware regel.
* De details blijven in ons eigen log, niet in het antwoord: daarin staat de naam
* van onze facilitator en een stapeldump.
*/
/**
* Zet een storing in de afrekening om in een eerlijk antwoord, VOOR de
* betaalmiddleware aan het woord komt.
*
* WAAROM DIT GEEN FOUTOPVANGER IS, en dat is gemeten in plaats van aangenomen. Een
* express-foutopvanger kan hier niets, want de betaalmiddleware GOOIT niet: hij
* antwoordt zelf, letterlijk `500 {"error":"Internal Server Error"}`, met een
* onbereikbare facilitator. Voor een koper is dat niet te onderscheiden van "dit
* product is stuk", terwijl het ware antwoord is dat wij nu niet kunnen afrekenen en
* dat er niets is afgeschreven. Daarom wordt hier alleen dat ENE geval herschreven:
* status 500 op de betaalde route. Al het andere gaat ongemoeid door, want een
* middleware die meer herschrijft dan hij begrijpt verbergt de volgende storing.
*/


// Tweede net, voor het geval de middleware ooit wel GOOIT in plaats van zelf te
// antwoorden. Kost niets en vangt een vorm die de bovenstaande niet ziet.
const betaalfoutmelder = (
error: unknown,
_req: Request,
res: Response,
next: NextFunction,
): void => {
if (res.headersSent) {
next(error);
return;
}
console.error("[app] the paid route could not settle:", error);
res.status(503).json({
ok: false,
error: "settlement is temporarily unavailable, so no payment was taken and no receipt was issued",
});
};
if (seller && betaalmiddleware) {
// The payment middleware talks to a facilitator on every request it sees,
// so it is mounted ON the paid path and BEHIND the limiter, never globally
// with app.use. Mounted globally it would let an anonymous caller make this
// server call out repeatedly, which is someone else's rate limit to pay.
app.post(
"/api/paid/verify-ownership",
verifyLimiter,
buildPaymentMiddleware(seller),
paidLimiter,
betaalstoringNaar503,
betaalmiddleware,
herstelAntwoord,
async (req: Request, res: Response) => {
await handleVerifyOwnership(req, res);
},
betaalfoutmelder,
);

// A GET on the paid path used to fall through to Express's default handler,
Expand All @@ -308,7 +465,7 @@ export function createApp(options: CreateAppOptions = {}): Express {
// 405 is what the method actually is, and the body says where to read the
// terms. Naming the wrong status to look more x402-shaped would be the kind
// of small untruth this whole project exists to avoid.
app.get("/api/paid/verify-ownership", verifyLimiter, (_req: Request, res: Response) => {
app.get("/api/paid/verify-ownership", paidLimiter, (_req: Request, res: Response) => {
res
.status(405)
.set("Allow", "POST")
Expand Down
20 changes: 14 additions & 6 deletions packages/self-check-server/src/x402-bazaar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,20 @@ describe("waarheid tegenover de echte route", () => {
});

it("de drie velden die de route echt vereist staan erin, en geen verzonnen vierde", () => {
const platteTekst = JSON.stringify(declareBazaarDiscovery());
for (const veld of ["endpoint", "ownerAddress", "method"]) {
expect(platteTekst).toContain(veld);
}
expect(platteTekst).not.toContain("apiKey");
expect(platteTekst).not.toContain("challenge");
// GEMETEN OP DE VELDNAMEN, NIET OP DE HELE JSON, en dat is een correctie. Dit zocht
// eerder de losse woorden in JSON.stringify van de hele verklaring, dus ook in elke
// BESCHRIJVING. Een beschrijving die uitlegt waar een lezer datzelfde token gratis
// kan ophalen liet de test daardoor vallen, terwijl er geen enkel veld bij was
// gekomen. Dat is een proxy-meting: hij meet iets dat met de vraag samenhangt in
// plaats van de vraag zelf, en valt dan om op tekst die niets met velden te maken
// heeft.
const b = declareBazaarDiscovery().bazaar as any;
const schemaVelden = Object.keys(b.schema.properties.input.properties.body.properties);
expect(schemaVelden.sort()).toEqual(["endpoint", "method", "ownerAddress"]);
expect(Object.keys(b.info.input.body).sort()).toEqual(["endpoint", "method", "ownerAddress"]);
// En het schema mag geen extra velden toestaan, anders is de lijst hierboven een
// wens en geen grens.
expect(b.schema.properties.input.properties.body.additionalProperties).toBe(false);
});
});

Expand Down
Loading
Loading