From eba536674bd21633b28becdd9fc16c48e0f04369 Mon Sep 17 00:00:00 2001 From: maho0638 <104829390+maho0638@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:27:06 +0300 Subject: [PATCH 1/3] fix(mcp): fail closed on signed-post redirects --- CHANGELOG.md | 1 + mcp/src/technocore.ts | 1 + mcp/tests/transport.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d235e..9adfb57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project are documented here. Format follows ### Fixed +- Signed Technocore POSTs now use manual redirect handling, so an HTTP 301/302/303 cannot be followed as a body-dropping GET and then reported as a successful `tclk_post_frame` write (#202). - `tclk_post_frame` now accepts exact decimal-string nonces in addition to safe integer numbers, so signed Technocore nonces above JavaScript's safe-integer range are preserved without precision loss. Unsafe numeric nonces (> 2^53 - 1) are rejected at the MCP schema diff --git a/mcp/src/technocore.ts b/mcp/src/technocore.ts index cff5c3d..2222f99 100644 --- a/mcp/src/technocore.ts +++ b/mcp/src/technocore.ts @@ -74,6 +74,7 @@ export function createClient(opts: { baseUrl?: string; fetch?: FetchLike } = {}) assertRoomName(room); const response = await doFetch(`${baseUrl}/r/${room}`, { method: "POST", + redirect: "manual", headers: { "content-type": "application/json" }, body: JSON.stringify({ did: post.did, diff --git a/mcp/tests/transport.test.ts b/mcp/tests/transport.test.ts index 501103b..ef5f5f1 100644 --- a/mcp/tests/transport.test.ts +++ b/mcp/tests/transport.test.ts @@ -144,6 +144,37 @@ describe("tclk_post_frame — tier 2, server-signed", () => { /failed with 403: 403 bad sig/, ); }); + + it("fails closed on redirects before fetch can rewrite a signed POST as GET", async () => { + const calls: { url: string; init?: RequestInit }[] = []; + const fetchLike = async (url: string, init?: RequestInit): Promise => { + calls.push({ url, init }); + if (init?.redirect !== "manual") { + // Model default fetch redirect handling: a 301/302/303 POST can become a GET whose + // successful response says nothing about whether the signed write landed. + return new Response("room read", { status: 200 }); + } + return new Response("", { + status: 302, + headers: { location: `https://technocore.chat/r/${ROOM}` }, + }); + }; + const h = createHandlers({ + env: { + TECHNOCORE_SIGNING_KEY: PAYER_SEED, + TECHNOCORE_URL: "http://technocore.chat", + }, + fetch: fetchLike, + }); + + await expect(h.tclk_post_frame({ room: ROOM, line: offerLine() })).rejects.toThrow( + /POST .* failed with 302/, + ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe(`http://technocore.chat/r/${ROOM}`); + expect(calls[0].init?.method).toBe("POST"); + expect(calls[0].init?.redirect).toBe("manual"); + }); }); describe("tclk_read_room", () => { From 869bfd224721ebd7619722d09e046c54da4554b9 Mon Sep 17 00:00:00 2001 From: maho0638 <104829390+maho0638@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:20:56 +0300 Subject: [PATCH 2/3] fix(example): reject redirects on signed frame posts --- CHANGELOG.md | 2 +- examples/live-deal.mjs | 1 + tests/fixtures/live-deal-redirect-fetch.mjs | 16 ++++++++++++++ tests/live-deal-redirect.test.ts | 24 +++++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/live-deal-redirect-fetch.mjs create mode 100644 tests/live-deal-redirect.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9adfb57..37b908e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project are documented here. Format follows ### Fixed -- Signed Technocore POSTs now use manual redirect handling, so an HTTP 301/302/303 cannot be followed as a body-dropping GET and then reported as a successful `tclk_post_frame` write (#202). +- Signed Technocore POSTs in the MCP client and live-deal example now use manual redirect handling, so an HTTP 301/302/303 cannot be followed as a body-dropping GET and reported as a successful frame write (#202). - `tclk_post_frame` now accepts exact decimal-string nonces in addition to safe integer numbers, so signed Technocore nonces above JavaScript's safe-integer range are preserved without precision loss. Unsafe numeric nonces (> 2^53 - 1) are rejected at the MCP schema diff --git a/examples/live-deal.mjs b/examples/live-deal.mjs index 438f7f7..fc0226b 100644 --- a/examples/live-deal.mjs +++ b/examples/live-deal.mjs @@ -138,6 +138,7 @@ async function post(signer, room, frame) { `${BASE}/r/${room}`, { method: "POST", + redirect: "manual", headers: { "content-type": "application/json" }, body: JSON.stringify({ did: signer.did, sig, nonce: String(nonce), text }), }, diff --git a/tests/fixtures/live-deal-redirect-fetch.mjs b/tests/fixtures/live-deal-redirect-fetch.mjs new file mode 100644 index 0000000..8f22076 --- /dev/null +++ b/tests/fixtures/live-deal-redirect-fetch.mjs @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// All HTTP is intercepted: a default-follow signed POST models the misleading final GET 200. +globalThis.fetch = async (_url, init) => { + if (init?.method === "POST") { + if (init.redirect === "manual") { + return new Response("", { + status: 302, + headers: { location: "https://example.invalid/r/tclk-offers" }, + }); + } + return new Response("followed GET, no signed write", { status: 200 }); + } + // Allow the example's initial note write to reach its first signed POST. + return new Response("ok", { status: 200 }); +}; diff --git a/tests/live-deal-redirect.test.ts b/tests/live-deal-redirect.test.ts new file mode 100644 index 0000000..3a2cd71 --- /dev/null +++ b/tests/live-deal-redirect.test.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Execute the real example with a mocked venue; no network requests or room writes. +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const example = fileURLToPath(new URL("../examples/live-deal.mjs", import.meta.url)); +const preload = fileURLToPath(new URL("./fixtures/live-deal-redirect-fetch.mjs", import.meta.url)); + +describe("live-deal signed POST transport", () => { + it("refuses a redirect instead of reporting an unpersisted offer as posted", () => { + const run = spawnSync(process.execPath, ["--import", preload, example], { + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, TECHNOCORE_URL: "http://localhost:8080" }, + }); + + expect(run.error).toBeUndefined(); + expect(run.status).toBe(1); + expect(run.stderr).toContain("The venue refused. post to tclk-offers: 302"); + expect(run.stdout).not.toMatch(/offer\s+posted to/); + }); +}); From 2b6468aca6bce74e6249b03eb4940dbb875daa84 Mon Sep 17 00:00:00 2001 From: maho0638 <104829390+maho0638@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:00:55 +0300 Subject: [PATCH 3/3] test(mcp): cover redirect behavior with native loopback fetch --- mcp/tests/redirects.test.ts | 99 +++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 mcp/tests/redirects.test.ts diff --git a/mcp/tests/redirects.test.ts b/mcp/tests/redirects.test.ts new file mode 100644 index 0000000..e1001a1 --- /dev/null +++ b/mcp/tests/redirects.test.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// Native fetch against disposable loopback endpoints: no live venue is contacted. + +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createClient } from "../src/technocore.js"; +import { createHandlers } from "../src/tools.js"; +import { HASH_OFFER } from "./fixtures.js"; + +const servers: Server[] = []; +const post = { did: "transport-test", sig: "transport-test", nonce: "7", text: "test frame" }; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + server.closeAllConnections(); + }))); +}); + +async function venue(status: number, targetBody = "ok 7") { + const calls: { method: string; url: string; body: string }[] = []; + const stored: string[] = []; + const server = createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { body += chunk; }); + request.on("end", () => { + calls.push({ method: request.method!, url: request.url!, body }); + if (request.method === "POST") stored.push(body); + if (request.url!.startsWith("/r/") && status >= 300 && status < 400) { + response.writeHead(status, { location: "/target" }); + response.end("redirected\n"); + } else { + response.writeHead(request.url === "/target" ? 200 : status); + response.end(targetBody); + } + }); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing loopback address"); + const baseUrl = `http://127.0.0.1:${address.port}`; + return { baseUrl, client: createClient({ baseUrl }), calls, stored }; +} + +describe("signed-write redirects", () => { + it.each([301, 302, 303, 307, 308])("rejects HTTP %i without following or retrying", async (status) => { + const { client, calls } = await venue(status); + await expect(client.postSigned("test", post)).rejects.toThrow(`failed with ${status}`); + expect(calls).toEqual([{ method: "POST", url: "/r/test", body: JSON.stringify(post) }]); + }); + + it("keeps a direct successful POST response and body", async () => { + const { client, calls } = await venue(201); + await expect(client.postSigned("test", post)).resolves.toBe("ok 7"); + expect(calls).toEqual([{ method: "POST", url: "/r/test", body: JSON.stringify(post) }]); + }); + + it("does not report a redirected tool write as confirmed even if the origin stored it", async () => { + const { baseUrl, calls, stored } = await venue(302); + const handlers = createHandlers({ env: { TECHNOCORE_URL: baseUrl } }); + const line = handlers.tclk_make_offer(HASH_OFFER).line; + await expect(handlers.tclk_post_frame({ room: "test", line, did: post.did, sig: post.sig, nonce: post.nonce })) + .rejects.toThrow("failed with 302"); + // An error cannot prove that the first endpoint did not accept the write. + expect(stored).toHaveLength(1); + expect(JSON.parse(stored[0]).text).toBe(line); + expect(calls).toHaveLength(1); + }); +}); + +describe("ordinary read redirects", () => { + it("still follows a room read redirect", async () => { + const view = { room: "test", count: 0, last_seq: 0, messages: [] }; + const { client, calls } = await venue(302, JSON.stringify(view)); + await expect(client.readRoom("test", 7)).resolves.toEqual(view); + expect(calls.map(({ method, url }) => ({ method, url }))).toEqual([ + { method: "GET", url: "/r/test?format=json&since=7" }, + { method: "GET", url: "/target" }, + ]); + }); + + it("still follows an export redirect", async () => { + const row = { seq: 1, ts: "2026-10-10T00:00:00Z", from: "~test", text: "hello" }; + const { client, calls } = await venue(302, JSON.stringify(row) + "\n"); + const records = await client.exportRoom("test"); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ room: "test", seq: 1, line: "hello" }); + expect(calls.map(({ method, url }) => ({ method, url }))).toEqual([ + { method: "GET", url: "/r/test/export" }, + { method: "GET", url: "/target" }, + ]); + }); +});