Skip to content

Commit 7fc6383

Browse files
committed
ots: proxy /digest through the backend -- full-shield stamp submissions
Previously, ots-stamp.component.ts fetched calendar/digest directly from the browser. Each calendar operator saw the user's IP at submission time. Combined with the existing /upgrade proxy and the fully-local verify flow, that was the last hop where the user could be observed. Add POST /api/v1/ordpool/ots/digest/:calendar in ordpool.routes.ts: - 256-byte body cap (real OTS digests are 32; cap blocks open-relay abuse). - Hostname whitelist via getOtsCalendarHosts(), same as /upgrade. - 10s AbortController timeout. - Forwards bytes verbatim; 502 on non-200 / network error. Route-local express.raw middleware so we receive the digest as a Buffer (not text-mangled by the global express.text body-parser). Frontend ots-stamp.component.ts::postDigestToCalendar now POSTs to `${apiBaseUrl}/api/v1/ordpool/ots/digest/<host>` instead of `<calendar-uri>/digest`. Same response semantics. 7 regression tests pin the proxy behaviour: forwarding, host whitelist, body validation (empty / oversize / non-Buffer), upstream 5xx → 502, fetch reject → 502. Backend baseline 343 → 350.
1 parent d9391e5 commit 7fc6383

4 files changed

Lines changed: 196 additions & 6 deletions

File tree

backend/.test-count-baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"passing": 343,
2+
"passing": 350,
33
"note": "Floor for .github/workflows/test-count-floor-backend.yml. Count of passing tests from the single jest config (node). Only an ordpool core maintainer is authorised to lower this number; every other change that reduces test count is treated as an accident and rejected. To raise the floor after legitimately adding tests, bump this value and commit. See also workspace CLAUDE.md, HARD RULE 'Never delete a passing test without explicit permission'."
44
}

backend/src/api/explorer/_ordpool/ordpool.routes.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,116 @@ describe('$isOtsCommit route handler', () => {
281281
expect(ordpoolOtsTxidSet.has).not.toHaveBeenCalled();
282282
});
283283
});
284+
285+
// Mock the calendars config so we know exactly which hostnames are
286+
// whitelisted. The real config reads a JSON file; for tests we just
287+
// declare two hosts and assert that anything else 400s.
288+
jest.mock('./ots-calendars-config', () => ({
289+
__esModule: true,
290+
getOtsCalendars: jest.fn(),
291+
getOtsCalendarHosts: jest.fn(() => new Set([
292+
'alice.btc.calendar.opentimestamps.org',
293+
'bob.btc.calendar.opentimestamps.org',
294+
])),
295+
}));
296+
297+
describe('$proxyOtsDigest route handler (privacy shield for stamp submissions)', () => {
298+
299+
let fetchSpy: jest.SpyInstance;
300+
301+
beforeEach(() => {
302+
jest.resetAllMocks();
303+
// Re-arm the calendar-host whitelist because resetAllMocks clears it.
304+
const cfg = require('./ots-calendars-config');
305+
(cfg.getOtsCalendarHosts as jest.Mock).mockReturnValue(new Set([
306+
'alice.btc.calendar.opentimestamps.org',
307+
'bob.btc.calendar.opentimestamps.org',
308+
]));
309+
fetchSpy = jest.spyOn(globalThis, 'fetch' as any);
310+
});
311+
312+
afterEach(() => {
313+
fetchSpy.mockRestore();
314+
});
315+
316+
async function call$proxyOtsDigest(calendar: string, body: any) {
317+
const res = makeRes();
318+
await (generalOrdpoolRoutes as any).$proxyOtsDigest(
319+
{ params: { calendar }, body } as unknown as Request,
320+
res,
321+
);
322+
return res as Response & { status: jest.Mock; setHeader: jest.Mock; send: jest.Mock; end: jest.Mock };
323+
}
324+
325+
it('forwards a 32-byte SHA-256 digest to the whitelisted calendar and returns the upstream bytes', async () => {
326+
const digest = Buffer.alloc(32, 0x42);
327+
const upstreamBody = Buffer.from([0xf0, 0x10, 0x42, 0x00, 0xff]);
328+
fetchSpy.mockResolvedValueOnce({
329+
status: 200,
330+
arrayBuffer: async () => upstreamBody.buffer.slice(upstreamBody.byteOffset, upstreamBody.byteOffset + upstreamBody.byteLength),
331+
} as any);
332+
333+
const res = await call$proxyOtsDigest('alice.btc.calendar.opentimestamps.org', digest);
334+
335+
expect(fetchSpy).toHaveBeenCalledWith(
336+
'https://alice.btc.calendar.opentimestamps.org/digest',
337+
expect.objectContaining({ method: 'POST', body: digest }),
338+
);
339+
expect(res.status).toHaveBeenCalledWith(200);
340+
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
341+
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.opentimestamps.v1');
342+
});
343+
344+
it('rejects an unknown calendar host with 400 (so this cannot be used as an open POST relay)', async () => {
345+
const res = await call$proxyOtsDigest('evil.example.org', Buffer.alloc(32, 0));
346+
347+
expect(res.status).toHaveBeenCalledWith(400);
348+
expect(res.send).toHaveBeenCalledWith('unknown calendar');
349+
expect(fetchSpy).not.toHaveBeenCalled();
350+
});
351+
352+
it('rejects an empty body', async () => {
353+
const res = await call$proxyOtsDigest('alice.btc.calendar.opentimestamps.org', Buffer.alloc(0));
354+
355+
expect(res.status).toHaveBeenCalledWith(400);
356+
expect(res.send).toHaveBeenCalledWith('invalid digest body');
357+
expect(fetchSpy).not.toHaveBeenCalled();
358+
});
359+
360+
it('rejects oversize bodies (cap 256 bytes, real OTS digests are 32)', async () => {
361+
const res = await call$proxyOtsDigest('alice.btc.calendar.opentimestamps.org', Buffer.alloc(1024, 0));
362+
363+
expect(res.status).toHaveBeenCalledWith(400);
364+
expect(res.send).toHaveBeenCalledWith('invalid digest body');
365+
expect(fetchSpy).not.toHaveBeenCalled();
366+
});
367+
368+
it('rejects non-Buffer bodies (middleware bypass guard)', async () => {
369+
// If express.raw didn't run (or was bypassed), req.body might be the
370+
// body-parser default of {} or undefined. The handler must refuse to
371+
// forward in that case rather than POSTing nonsense to the calendar.
372+
const res = await call$proxyOtsDigest('alice.btc.calendar.opentimestamps.org', { not: 'a buffer' });
373+
374+
expect(res.status).toHaveBeenCalledWith(400);
375+
expect(res.send).toHaveBeenCalledWith('invalid digest body');
376+
expect(fetchSpy).not.toHaveBeenCalled();
377+
});
378+
379+
it('maps a non-200 upstream response to 502', async () => {
380+
fetchSpy.mockResolvedValueOnce({ status: 503, arrayBuffer: async () => new ArrayBuffer(0) } as any);
381+
382+
const res = await call$proxyOtsDigest('bob.btc.calendar.opentimestamps.org', Buffer.alloc(32, 0));
383+
384+
expect(res.status).toHaveBeenCalledWith(502);
385+
expect(res.send).toHaveBeenCalledWith('upstream returned 503');
386+
});
387+
388+
it('maps a thrown fetch (network failure / abort) to 502', async () => {
389+
fetchSpy.mockRejectedValueOnce(new Error('network down'));
390+
391+
const res = await call$proxyOtsDigest('alice.btc.calendar.opentimestamps.org', Buffer.alloc(32, 0));
392+
393+
expect(res.status).toHaveBeenCalledWith(502);
394+
expect(res.send).toHaveBeenCalledWith('upstream error');
395+
});
396+
});

backend/src/api/explorer/_ordpool/ordpool.routes.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Application, Request, Response } from 'express';
1+
import express, { Application, Request, Response } from 'express';
22
import { AtomicalFile, getFirstInscriptionHeight, InscriptionPreviewService, isValidTxid, ParsedInscription, ParsedStamp, PreviewInstructions } from 'ordpool-parser';
33

44
import config from '../../../config';
@@ -32,6 +32,13 @@ class GeneralOrdpoolRoutes {
3232
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/tx/:txid', this.$getOtsTx)
3333
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/block/:height', this.$getOtsBlock)
3434
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/upgrade/:calendar/:hash', this.$proxyOtsUpgrade)
35+
// Route-local express.raw so we receive the digest as a Buffer (not
36+
// body-parser-mangled text). 256-byte cap; real OTS digests are 32 bytes.
37+
.post(
38+
config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/digest/:calendar',
39+
express.raw({ type: '*/*', limit: 256 }),
40+
this.$proxyOtsDigest,
41+
)
3542
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/stamp-calendars', this.$getOtsStampCalendars)
3643
.get(config.MEMPOOL.API_URL_PREFIX + 'ordpool/ots/is-commit/:txid', this.$isOtsCommit)
3744
.get('/content/:inscriptionId', this.getInscriptionContent)
@@ -136,6 +143,67 @@ class GeneralOrdpoolRoutes {
136143
}
137144
}
138145

146+
/**
147+
* Proxy POST /digest on a public OTS calendar.
148+
*
149+
* Why we proxy: privacy. Without this, the browser submits the user's
150+
* file hash directly to alice/bob/finney/catallaxy and each calendar
151+
* operator learns the user's IP at submission time. Routing through
152+
* ordpool makes the calendar see our backend's IP instead -- combined
153+
* with the existing /upgrade proxy and the all-local verify flow,
154+
* calendar operators never observe the user at all.
155+
*
156+
* The proxied body is the raw 32-byte SHA-256 digest. We enforce
157+
* a tight 256-byte cap (route-local `express.raw` limit + a recheck
158+
* here in case the middleware was bypassed) so this can't become a
159+
* generic POST relay. Hostname is whitelisted, same as /upgrade.
160+
*
161+
* Calendar /digest responses are binary (the OpenTimestamps
162+
* commitment subtree); we forward bytes verbatim.
163+
*/
164+
// POST https://ordpool.space/api/v1/ordpool/ots/digest/alice.btc.calendar.opentimestamps.org
165+
private async $proxyOtsDigest(req: Request, res: Response): Promise<void> {
166+
const allowed = getOtsCalendarHosts();
167+
const calendar = String(req.params.calendar || '').toLowerCase();
168+
if (!allowed.has(calendar)) {
169+
res.status(400).send('unknown calendar');
170+
return;
171+
}
172+
const body = req.body;
173+
if (!Buffer.isBuffer(body) || body.length === 0 || body.length > 256) {
174+
res.status(400).send('invalid digest body');
175+
return;
176+
}
177+
const abort = new AbortController();
178+
const timeout = setTimeout(() => abort.abort(), 10_000);
179+
try {
180+
const upstream = await fetch(`https://${calendar}/digest`, {
181+
method: 'POST',
182+
// text/plain matches what the original direct-from-browser request
183+
// used and keeps the upstream's "simple request" code path; no
184+
// calendar validates Content-Type, they read the body verbatim.
185+
headers: { 'Content-Type': 'text/plain' },
186+
body,
187+
signal: abort.signal,
188+
});
189+
if (upstream.status === 200) {
190+
const out = Buffer.from(await upstream.arrayBuffer());
191+
// The response is a pending OTS commitment subtree -- different
192+
// every call, never cacheable.
193+
res.setHeader('Cache-Control', 'no-store');
194+
res.setHeader('Content-Type', 'application/vnd.opentimestamps.v1');
195+
res.status(200).end(out);
196+
} else {
197+
res.setHeader('Cache-Control', 'no-store');
198+
res.status(502).send(`upstream returned ${upstream.status}`);
199+
}
200+
} catch {
201+
res.status(502).send('upstream error');
202+
} finally {
203+
clearTimeout(timeout);
204+
}
205+
}
206+
139207
/**
140208
* Returns the URI list the frontend Stamp & Verify drop-zone fans out to.
141209
*

frontend/src/app/components/_ordpool/ots-stamp-verify/ots-stamp.component.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
hexEncode,
1616
} from './ots-store.service';
1717
import { OtsCalendarPickerService } from './ots-calendar-picker.service';
18+
import { environment } from 'src/environments/environment';
1819

1920
/*
2021
Test cases:
@@ -190,11 +191,19 @@ export class OtsStampComponent {
190191
}
191192

192193
private async postDigestToCalendar(uri: string, digest: Uint8Array): Promise<Uint8Array> {
193-
// text/plain is a CORS-safelisted content type, so no preflight is sent.
194-
// The OTS calendars don't validate Content-Type, they only read the body.
195-
const resp = await fetch(uri + '/digest', {
194+
// Privacy: we route /digest through the ordpool backend instead of
195+
// hitting the calendar directly, so the calendar operator sees our
196+
// backend's IP rather than the user's. The /upgrade poll is already
197+
// proxied (CORS reason), and verify is fully local; this closes the
198+
// last hop where the user's IP could leak.
199+
const apiBase = environment.apiBaseUrl || '';
200+
const calendarHost = (() => {
201+
try { return new URL(uri).hostname; } catch { return ''; }
202+
})();
203+
const proxyUrl = `${apiBase}/api/v1/ordpool/ots/digest/${calendarHost}`;
204+
const resp = await fetch(proxyUrl, {
196205
method: 'POST',
197-
headers: { 'Content-Type': 'text/plain' },
206+
headers: { 'Content-Type': 'application/octet-stream' },
198207
body: digest as BufferSource,
199208
});
200209
if (!resp.ok) throw new Error(uri + ' replied ' + resp.status);

0 commit comments

Comments
 (0)