From 20af66cbb45ad22aa54eaffb8a99a48b3a5cc4ca Mon Sep 17 00:00:00 2001 From: Ejirosoft Date: Tue, 25 Aug 2026 09:21:25 +0000 Subject: [PATCH 1/2] fix(api): require CID ownership to unpin IPFS content (IDOR) POST /api/ipfs/unpin previously authorized any wallet with a valid JWT to unpin any CID, since a fresh JWT costs nothing (challenge/response needs no on-chain history) and the wallet address was never checked against who actually pinned the content. Add a pin-ownership registry (api/_lib/pinOwnership.ts) populated by upload-json.ts / upload-file.ts at pin time, and a new unpin.ts endpoint that resolves a CID back to its pinning wallet before calling Pinata. A CID with no ownership record is denied by default rather than allowed, and every attempt (allowed or rejected) is audit-logged with address, cid, and outcome. Closes #1155 --- SECURITY.md | 31 +++--- api/_lib/pinOwnership.ts | 122 +++++++++++++++++++++++ api/_lib/schemaValidation.ts | 55 +++++++---- api/ipfs/unpin.test.ts | 163 +++++++++++++++++++++++++++++++ api/ipfs/unpin.ts | 129 ++++++++++++++++++++++++ api/ipfs/upload-file.ts | 183 ++++++++++++++++++++--------------- api/ipfs/upload-json.ts | 114 ++++++++++++++-------- 7 files changed, 642 insertions(+), 155 deletions(-) create mode 100644 api/_lib/pinOwnership.ts create mode 100644 api/ipfs/unpin.test.ts create mode 100644 api/ipfs/unpin.ts diff --git a/SECURITY.md b/SECURITY.md index 9260cf39..78be5a64 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,6 +10,7 @@ Instead, report it privately using one of the following channels: 2. **Email** — send details to `security@stellarforge.app` with the subject line `[SECURITY] `. Please include: + - A clear description of the vulnerability and its impact. - Steps to reproduce (proof-of-concept code or exploit path). - Affected contract addresses or frontend versions. @@ -19,24 +20,24 @@ We will acknowledge your report within **72 hours** and provide an estimated fix ## Scope -| Component | In scope | -|---|---| -| Token factory Soroban contract (mainnet + testnet) | ✅ | -| React frontend (wallet integration, transaction flow) | ✅ | -| IPFS / Pinata integration | ✅ | -| Admin key custody and access controls | ✅ | -| Dependency vulnerabilities with active exploit paths | ✅ | +| Component | In scope | +| ---------------------------------------------------------------- | ------------------------------------ | +| Token factory Soroban contract (mainnet + testnet) | ✅ | +| React frontend (wallet integration, transaction flow) | ✅ | +| IPFS / Pinata integration | ✅ | +| Admin key custody and access controls | ✅ | +| Dependency vulnerabilities with active exploit paths | ✅ | | Third-party services (Stellar network itself, Pinata, Freighter) | ❌ — report to the respective vendor | -| Theoretical issues with no practical exploit path | ❌ | +| Theoretical issues with no practical exploit path | ❌ | ## Severity definitions -| Severity | Description | -|---|---| +| Severity | Description | +| ------------ | ---------------------------------------------------------------------------------------------- | | **Critical** | Remote code execution, admin key theft, total loss of funds, contract upgrade to attacker WASM | -| **High** | Partial fund loss, admin privilege escalation, persistent denial of service | -| **Medium** | Temporary DoS, fee manipulation without fund loss, user-data leakage | -| **Low** | Minor information disclosure, UX security issues | +| **High** | Partial fund loss, admin privilege escalation, persistent denial of service | +| **Medium** | Temporary DoS, fee manipulation without fund loss, user-data leakage | +| **Low** | Minor information disclosure, UX security issues | ## Incident response @@ -59,6 +60,10 @@ The factory contract's `admin` address can upgrade the contract, change fees, re The `upgrade` function currently emits no Soroban event. Detection of a malicious WASM replacement currently requires active polling of the on-chain WASM hash. The monitoring script is documented in the [Incident Response Runbook](./docs/incident-response.md#22-wasm-hash-polling-required-until-issue-9-is-resolved). +### IPFS unpin requires CID ownership (issue #1155) + +`POST /api/ipfs/unpin` requires more than a valid JWT: any wallet can obtain one for free via the challenge/response flow, so JWT possession alone does not prove a right to delete someone else's pinned content. The endpoint additionally checks the requesting wallet address against an ownership record captured at upload time (`api/_lib/pinOwnership.ts`, populated by `upload-json.ts` / `upload-file.ts`). A CID with no ownership record on file is **denied by default** — it is never treated as unpinnable-by-anyone. See `api/ipfs/unpin.ts` and its regression tests in `api/ipfs/unpin.test.ts`. + ### Content Security Policy A strict CSP is enforced both as a `` tag and via HTTP response headers on the hosted deployment. See the [README](./README.md#content-security-policy-csp) for configuration details. diff --git a/api/_lib/pinOwnership.ts b/api/_lib/pinOwnership.ts new file mode 100644 index 00000000..9bec1662 --- /dev/null +++ b/api/_lib/pinOwnership.ts @@ -0,0 +1,122 @@ +/** + * Tracks which wallet address pinned a given IPFS CID, so the unpin endpoint + * can verify ownership before deleting content from Pinata on someone's + * behalf. Recorded at upload time by upload-json.ts / upload-file.ts. + * + * Uses Vercel KV for durability across serverless instances, matching the + * pattern in rateLimit.ts. Falls back to per-instance memory when KV isn't + * configured — note that this fallback fails *closed*: a CID pinned by one + * instance won't be found by a different instance's in-memory map, so + * getPinOwner() returns null and the caller denies the unpin. Configure + * Vercel KV in production so legitimate owners aren't denied. + */ + +interface PinOwnerRecord { + ownerAddress: string; + pinnedAt: number; +} + +const memoryRegistry = new Map(); + +function pinKey(cid: string): string { + return `pinowner:${cid}`; +} + +/** Records the wallet address that pinned `cid`. Called after a successful Pinata pin. */ +export async function recordPinOwner( + cid: string, + ownerAddress: string, +): Promise { + const kvUrl = process.env.VERCEL_KV_REST_API_URL; + const kvToken = process.env.VERCEL_KV_REST_API_TOKEN; + const record: PinOwnerRecord = { ownerAddress, pinnedAt: Date.now() }; + + if (kvUrl && kvToken) { + try { + await kvSet(kvUrl, kvToken, pinKey(cid), JSON.stringify(record)); + return; + } catch (err) { + console.error( + "Failed to record pin owner in KV, falling back to memory:", + err, + ); + } + } + + memoryRegistry.set(cid, record); +} + +/** + * Returns the wallet address that pinned `cid`, or null if the CID is not + * indexed. Callers MUST treat null as "ownership cannot be verified" and + * deny the request — never allow-by-default for an unknown CID. + */ +export async function getPinOwner(cid: string): Promise { + const kvUrl = process.env.VERCEL_KV_REST_API_URL; + const kvToken = process.env.VERCEL_KV_REST_API_TOKEN; + + if (kvUrl && kvToken) { + try { + const data = await kvGet(kvUrl, kvToken, pinKey(cid)); + if (!data) return null; + const record = JSON.parse(data) as PinOwnerRecord; + return record.ownerAddress; + } catch (err) { + console.error("Failed to read pin owner from KV:", err); + return null; + } + } + + return memoryRegistry.get(cid)?.ownerAddress ?? null; +} + +/** Removes ownership tracking for `cid` after it has been successfully unpinned. */ +export async function clearPinOwner(cid: string): Promise { + const kvUrl = process.env.VERCEL_KV_REST_API_URL; + const kvToken = process.env.VERCEL_KV_REST_API_TOKEN; + + if (kvUrl && kvToken) { + try { + await kvDel(kvUrl, kvToken, pinKey(cid)); + return; + } catch (err) { + console.error("Failed to clear pin owner from KV:", err); + } + } + + memoryRegistry.delete(cid); +} + +// Vercel KV REST API helpers (mirrors rateLimit.ts) +async function kvGet( + url: string, + token: string, + key: string, +): Promise { + const response = await fetch(`${url}/get/${encodeURIComponent(key)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) return null; + const data = (await response.json()) as { result: string | null }; + return data.result; +} + +async function kvSet( + url: string, + token: string, + key: string, + value: string, +): Promise { + await fetch(`${url}/set/${encodeURIComponent(key)}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ value }), + }); +} + +async function kvDel(url: string, token: string, key: string): Promise { + await fetch(`${url}/del/${encodeURIComponent(key)}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); +} diff --git a/api/_lib/schemaValidation.ts b/api/_lib/schemaValidation.ts index 911b610f..40a32e26 100644 --- a/api/_lib/schemaValidation.ts +++ b/api/_lib/schemaValidation.ts @@ -4,15 +4,24 @@ */ export interface TokenMetadata { - name: string - description: string - image: string // Must be ipfs://CID format + name: string; + description: string; + image: string; // Must be ipfs://CID format } -const MAX_METADATA_JSON_SIZE = 8 * 1024 // 8 KiB strict limit -const MAX_NAME_LENGTH = 128 -const MAX_DESCRIPTION_LENGTH = 2000 -const IPFS_URI_PATTERN = /^ipfs:\/\/[a-zA-Z0-9]+$/ +const MAX_METADATA_JSON_SIZE = 8 * 1024; // 8 KiB strict limit +const MAX_NAME_LENGTH = 128; +const MAX_DESCRIPTION_LENGTH = 2000; +const IPFS_URI_PATTERN = /^ipfs:\/\/[a-zA-Z0-9]+$/; + +// CIDv0: Qm + 44 base58 chars (total 46); CIDv1: b + base32 (lowercase) +const CID_V0_PATTERN = /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/; +const CID_V1_PATTERN = /^b[a-z2-7]{58,}$/; + +/** Validates a bare IPFS CID (no `ipfs://` prefix), CIDv0 or CIDv1. */ +export function isValidCid(cid: string): boolean { + return CID_V0_PATTERN.test(cid) || CID_V1_PATTERN.test(cid); +} /** * Validate a TokenMetadata object and its JSON serialization. @@ -29,49 +38,53 @@ export function validateTokenMetadata( return { valid: false, error: `Metadata exceeds maximum size of ${MAX_METADATA_JSON_SIZE} bytes.`, - } + }; } // Type check - if (typeof metadata !== 'object' || metadata === null) { - return { valid: false, error: 'Metadata must be a JSON object.' } + if (typeof metadata !== "object" || metadata === null) { + return { valid: false, error: "Metadata must be a JSON object." }; } - const obj = metadata as Record + const obj = metadata as Record; // Required fields - if (typeof obj.name !== 'string' || obj.name.length === 0 || obj.name.length > MAX_NAME_LENGTH) { + if ( + typeof obj.name !== "string" || + obj.name.length === 0 || + obj.name.length > MAX_NAME_LENGTH + ) { return { valid: false, error: `name must be a non-empty string, max ${MAX_NAME_LENGTH} characters.`, - } + }; } if ( - typeof obj.description !== 'string' || + typeof obj.description !== "string" || obj.description.length === 0 || obj.description.length > MAX_DESCRIPTION_LENGTH ) { return { valid: false, error: `description must be a non-empty string, max ${MAX_DESCRIPTION_LENGTH} characters.`, - } + }; } - if (typeof obj.image !== 'string' || !IPFS_URI_PATTERN.test(obj.image)) { + if (typeof obj.image !== "string" || !IPFS_URI_PATTERN.test(obj.image)) { return { valid: false, - error: 'image must be in ipfs://CID format (e.g., ipfs://QmXxxx).', - } + error: "image must be in ipfs://CID format (e.g., ipfs://QmXxxx).", + }; } // No extra fields allowed - const allowedKeys = new Set(['name', 'description', 'image']) + const allowedKeys = new Set(["name", "description", "image"]); for (const key of Object.keys(obj)) { if (!allowedKeys.has(key)) { - return { valid: false, error: `Unexpected field: ${key}.` } + return { valid: false, error: `Unexpected field: ${key}.` }; } } - return { valid: true } + return { valid: true }; } diff --git a/api/ipfs/unpin.test.ts b/api/ipfs/unpin.test.ts new file mode 100644 index 00000000..612d9c49 --- /dev/null +++ b/api/ipfs/unpin.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import handler from "./unpin"; +import { issueToken } from "../_lib/jwt"; +import { recordPinOwner } from "../_lib/pinOwnership"; + +function fakeReqRes( + body: unknown, + token: string | undefined, + ip = "127.0.0.1", +) { + const headers: Record = { "x-forwarded-for": ip }; + if (token) headers.authorization = `Bearer ${token}`; + + const req = { + method: "POST", + headers, + socket: { remoteAddress: ip }, + body, + } as unknown as VercelRequest; + + const json = vi.fn(); + const status = vi.fn(() => ({ json })); + const res = { status } as unknown as VercelResponse; + + return { req, res, status, json }; +} + +/** A syntactically valid CIDv0, unique per test via `seed`. */ +function makeCid(seed: string): string { + return `Qm${seed.padEnd(44, "a")}`; +} + +describe("POST /api/ipfs/unpin", () => { + beforeEach(() => { + process.env.JWT_SECRET = "test-jwt-secret"; + process.env.PINATA_API_KEY = "test-key"; + process.env.PINATA_API_SECRET = "test-secret"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + } as Response), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.JWT_SECRET; + delete process.env.PINATA_API_KEY; + delete process.env.PINATA_API_SECRET; + }); + + it("rejects non-POST methods", async () => { + const { req, res, status } = fakeReqRes({}, undefined, "203.0.113.20"); + req.method = "GET"; + + await handler(req, res); + + expect(status).toHaveBeenCalledWith(405); + }); + + it("rejects requests without a valid JWT", async () => { + const cid = makeCid("noauth"); + const { req, res, status } = fakeReqRes({ cid }, undefined, "203.0.113.21"); + + await handler(req, res); + + expect(status).toHaveBeenCalledWith(401); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects a malformed cid before checking ownership", async () => { + const token = issueToken( + "GREQUESTERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + const { req, res, status } = fakeReqRes( + { cid: "not-a-real-cid" }, + token, + "203.0.113.22", + ); + + await handler(req, res); + + expect(status).toHaveBeenCalledWith(400); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("denies unpinning a CID with no ownership record on file (deny by default)", async () => { + const cid = makeCid("unknown1"); + const token = issueToken( + "GREQUESTERBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + ); + const { req, res, status, json } = fakeReqRes( + { cid }, + token, + "203.0.113.23", + ); + + await handler(req, res); + + expect(status).toHaveBeenCalledWith(403); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.stringContaining("denied by default"), + }), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("denies a wallet that does not own the CID (IDOR check)", async () => { + const cid = makeCid("ownedbyA"); + await recordPinOwner( + cid, + "GOWNERWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + + const attackerToken = issueToken( + "GATTACKERWALLETBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + ); + const { req, res, status, json } = fakeReqRes( + { cid }, + attackerToken, + "203.0.113.24", + ); + + await handler(req, res); + + expect(status).toHaveBeenCalledWith(403); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.stringContaining("not authorized"), + }), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("allows the legitimate owner to unpin their own CID", async () => { + const cid = makeCid("ownedbyB"); + const ownerAddress = + "GOWNERWALLETCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; + await recordPinOwner(cid, ownerAddress); + + const ownerToken = issueToken(ownerAddress); + const { req, res, status, json } = fakeReqRes( + { cid }, + ownerToken, + "203.0.113.25", + ); + + await handler(req, res); + + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith({ cid, unpinned: true }); + + const [url, options] = vi.mocked(fetch).mock.calls[0]; + expect(url).toBe(`https://api.pinata.cloud/pinning/unpin/${cid}`); + expect((options as RequestInit).method).toBe("DELETE"); + const headers = (options as RequestInit).headers as Record; + expect(headers.pinata_api_key).toBe("test-key"); + }); +}); diff --git a/api/ipfs/unpin.ts b/api/ipfs/unpin.ts new file mode 100644 index 00000000..47645a64 --- /dev/null +++ b/api/ipfs/unpin.ts @@ -0,0 +1,129 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { isRateLimited } from "../_lib/rateLimit"; +import { PINATA_API_URL, pinataHeaders } from "../_lib/pinata"; +import { isValidCid } from "../_lib/schemaValidation"; +import { verifyToken } from "../_lib/jwt"; +import { getPinOwner, clearPinOwner } from "../_lib/pinOwnership"; + +interface UnpinBody { + cid: string; +} + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + // Authenticate: require a valid JWT from the challenge → signature flow + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res.status(401).json({ + error: + "Authorization required. Request a challenge and sign with your wallet.", + }); + return; + } + + let walletAddress: string; + try { + const token = authHeader.slice(7); // Remove "Bearer " + const payload = verifyToken(token); + walletAddress = payload.address; + } catch (err) { + res.status(401).json({ + error: err instanceof Error ? err.message : "Invalid or expired token.", + }); + return; + } + + // Check rate limits (per wallet address, durable across instances) + if (await isRateLimited(walletAddress)) { + res + .status(429) + .json({ error: "Too many requests. Please try again later." }); + return; + } + + const body = req.body as UnpinBody | undefined; + const rawCid = typeof body?.cid === "string" ? body.cid : null; + if (!rawCid || !isValidCid(rawCid)) { + auditUnpin(walletAddress, rawCid ?? "(missing)", "denied_invalid_cid"); + res + .status(400) + .json({ error: "Request body must include a valid { cid: string }." }); + return; + } + const cid = rawCid; + + // Ownership check: a CID may only be unpinned by the wallet that pinned + // it. An unindexed/unknown CID is denied by default — we never assume + // the requester owns content we have no record of. + const ownerAddress = await getPinOwner(cid); + + if (ownerAddress === null) { + auditUnpin(walletAddress, cid, "denied_unknown_cid"); + res.status(403).json({ + error: + "This CID has no known owner on record. Unpin is denied by default for unindexed content.", + }); + return; + } + + if (ownerAddress !== walletAddress) { + auditUnpin(walletAddress, cid, "denied_not_owner"); + res + .status(403) + .json({ error: "You are not authorized to unpin this content." }); + return; + } + + let headers: Record; + try { + headers = pinataHeaders(); + } catch (err) { + res + .status(500) + .json({ + error: err instanceof Error ? err.message : "Server misconfiguration.", + }); + return; + } + + try { + const pinataRes = await fetch(`${PINATA_API_URL}/pinning/unpin/${cid}`, { + method: "DELETE", + headers, + }); + + if (!pinataRes.ok) { + auditUnpin(walletAddress, cid, "pinata_error"); + res + .status(502) + .json({ error: `Pinata unpin failed (HTTP ${pinataRes.status}).` }); + return; + } + + await clearPinOwner(cid); + auditUnpin(walletAddress, cid, "allowed"); + res.status(200).json({ cid, unpinned: true }); + } catch { + auditUnpin(walletAddress, cid, "error"); + res + .status(500) + .json({ error: "Unexpected error while unpinning from IPFS." }); + } +} + +/** Structured audit log for every unpin attempt, allowed or rejected. */ +function auditUnpin(address: string, cid: string, outcome: string): void { + console.log( + JSON.stringify({ + event: "ipfs_unpin", + address, + cid, + outcome, + at: new Date().toISOString(), + }), + ); +} diff --git a/api/ipfs/upload-file.ts b/api/ipfs/upload-file.ts index 6a201afc..cbcc6a23 100644 --- a/api/ipfs/upload-file.ts +++ b/api/ipfs/upload-file.ts @@ -1,17 +1,18 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node' -import Busboy from 'busboy' -import { isRateLimited } from '../_lib/rateLimit' -import { PINATA_API_URL, pinataHeaders } from '../_lib/pinata' -import { validateFileMagicBytes } from '../_lib/fileValidation' -import { verifyToken } from '../_lib/jwt' +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import Busboy from "busboy"; +import { isRateLimited } from "../_lib/rateLimit"; +import { PINATA_API_URL, pinataHeaders } from "../_lib/pinata"; +import { validateFileMagicBytes } from "../_lib/fileValidation"; +import { verifyToken } from "../_lib/jwt"; +import { recordPinOwner } from "../_lib/pinOwnership"; // Kept just under Vercel's 4.5MB serverless function request-body ceiling. -const MAX_FILE_SIZE = 4 * 1024 * 1024 +const MAX_FILE_SIZE = 4 * 1024 * 1024; interface ParsedFile { - buffer: Buffer - filename: string - mimeType: string + buffer: Buffer; + filename: string; + mimeType: string; } function parseMultipart(req: VercelRequest): Promise { @@ -19,122 +20,148 @@ function parseMultipart(req: VercelRequest): Promise { const bb = Busboy({ headers: req.headers as Record, limits: { fileSize: MAX_FILE_SIZE, files: 1 }, - }) - - let found: ParsedFile | null = null - let fileTooLarge = false - - bb.on('file', (_name, stream, info) => { - const chunks: Buffer[] = [] - stream.on('data', (chunk: Buffer) => chunks.push(chunk)) - stream.on('limit', () => { - fileTooLarge = true - }) - stream.on('end', () => { + }); + + let found: ParsedFile | null = null; + let fileTooLarge = false; + + bb.on("file", (_name, stream, info) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("limit", () => { + fileTooLarge = true; + }); + stream.on("end", () => { if (!fileTooLarge) { - found = { buffer: Buffer.concat(chunks), filename: info.filename, mimeType: info.mimeType } + found = { + buffer: Buffer.concat(chunks), + filename: info.filename, + mimeType: info.mimeType, + }; } - }) - }) + }); + }); - bb.on('error', reject) - bb.on('close', () => { + bb.on("error", reject); + bb.on("close", () => { if (fileTooLarge) { - reject(new Error('FILE_TOO_LARGE')) - return + reject(new Error("FILE_TOO_LARGE")); + return; } if (!found) { - reject(new Error('NO_FILE')) - return + reject(new Error("NO_FILE")); + return; } - resolve(found) - }) + resolve(found); + }); - req.pipe(bb) - }) + req.pipe(bb); + }); } export default async function handler(req: VercelRequest, res: VercelResponse) { - if (req.method !== 'POST') { - res.status(405).json({ error: 'Method not allowed' }) - return + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; } // Authenticate: require a valid JWT from the challenge → signature flow - const authHeader = req.headers.authorization - if (!authHeader || !authHeader.startsWith('Bearer ')) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { res.status(401).json({ - error: 'Authorization required. Request a challenge and sign with your wallet.', - }) - return + error: + "Authorization required. Request a challenge and sign with your wallet.", + }); + return; } - let walletAddress: string + let walletAddress: string; try { - const token = authHeader.slice(7) // Remove "Bearer " - const payload = verifyToken(token) - walletAddress = payload.address + const token = authHeader.slice(7); // Remove "Bearer " + const payload = verifyToken(token); + walletAddress = payload.address; } catch (err) { res.status(401).json({ - error: err instanceof Error ? err.message : 'Invalid or expired token.', - }) - return + error: err instanceof Error ? err.message : "Invalid or expired token.", + }); + return; } // Check rate limits (per wallet address, durable across instances) if (await isRateLimited(walletAddress)) { - res.status(429).json({ error: 'Too many upload requests. Please try again later.' }) - return + res + .status(429) + .json({ error: "Too many upload requests. Please try again later." }); + return; } - let file: ParsedFile + let file: ParsedFile; try { - file = await parseMultipart(req) + file = await parseMultipart(req); } catch (err) { - if (err instanceof Error && err.message === 'FILE_TOO_LARGE') { - res.status(413).json({ error: 'File exceeds the 4MB limit.' }) - return + if (err instanceof Error && err.message === "FILE_TOO_LARGE") { + res.status(413).json({ error: "File exceeds the 4MB limit." }); + return; } - res.status(400).json({ error: 'No valid file uploaded.' }) - return + res.status(400).json({ error: "No valid file uploaded." }); + return; } // Validate file content against magic bytes, not client-supplied MIME type - const magicValidation = validateFileMagicBytes(file.buffer, file.mimeType) + const magicValidation = validateFileMagicBytes(file.buffer, file.mimeType); if (!magicValidation.valid) { - res.status(400).json({ error: magicValidation.error }) - return + res.status(400).json({ error: magicValidation.error }); + return; } - const verifiedMimeType = magicValidation.mimeType + const verifiedMimeType = magicValidation.mimeType; - let headers: Record + let headers: Record; try { - headers = pinataHeaders() + headers = pinataHeaders(); } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : 'Server misconfiguration.' }) - return + res + .status(500) + .json({ + error: err instanceof Error ? err.message : "Server misconfiguration.", + }); + return; } try { - const formData = new FormData() - formData.append('file', new Blob([file.buffer], { type: verifiedMimeType }), file.filename) - formData.append('pinataMetadata', JSON.stringify({ name: file.filename })) - formData.append('pinataOptions', JSON.stringify({ cidVersion: 1 })) + const formData = new FormData(); + formData.append( + "file", + new Blob([file.buffer], { type: verifiedMimeType }), + file.filename, + ); + formData.append("pinataMetadata", JSON.stringify({ name: file.filename })); + formData.append("pinataOptions", JSON.stringify({ cidVersion: 1 })); const pinataRes = await fetch(`${PINATA_API_URL}/pinning/pinFileToIPFS`, { - method: 'POST', + method: "POST", headers, body: formData, - }) + }); if (!pinataRes.ok) { - res.status(502).json({ error: `Pinata upload failed (HTTP ${pinataRes.status}).` }) - return + res + .status(502) + .json({ error: `Pinata upload failed (HTTP ${pinataRes.status}).` }); + return; } - const data = (await pinataRes.json()) as { IpfsHash: string } - res.status(200).json({ cid: data.IpfsHash }) + const data = (await pinataRes.json()) as { IpfsHash: string }; + + try { + await recordPinOwner(data.IpfsHash, walletAddress); + } catch (err) { + console.error("Failed to record pin owner:", err); + } + + res.status(200).json({ cid: data.IpfsHash }); } catch { - res.status(500).json({ error: 'Unexpected error while uploading to IPFS.' }) + res + .status(500) + .json({ error: "Unexpected error while uploading to IPFS." }); } } diff --git a/api/ipfs/upload-json.ts b/api/ipfs/upload-json.ts index ca9f2ada..249f45ea 100644 --- a/api/ipfs/upload-json.ts +++ b/api/ipfs/upload-json.ts @@ -1,89 +1,117 @@ -import type { VercelRequest, VercelResponse } from '@vercel/node' -import { isRateLimited } from '../_lib/rateLimit' -import { PINATA_API_URL, pinataHeaders } from '../_lib/pinata' -import { validateTokenMetadata } from '../_lib/schemaValidation' -import { verifyToken } from '../_lib/jwt' +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { isRateLimited } from "../_lib/rateLimit"; +import { PINATA_API_URL, pinataHeaders } from "../_lib/pinata"; +import { validateTokenMetadata } from "../_lib/schemaValidation"; +import { verifyToken } from "../_lib/jwt"; +import { recordPinOwner } from "../_lib/pinOwnership"; interface UploadJsonBody { - metadata: unknown - name: string + metadata: unknown; + name: string; } export default async function handler(req: VercelRequest, res: VercelResponse) { - if (req.method !== 'POST') { - res.status(405).json({ error: 'Method not allowed' }) - return + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; } // Authenticate: require a valid JWT from the challenge → signature flow - const authHeader = req.headers.authorization - if (!authHeader || !authHeader.startsWith('Bearer ')) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { res.status(401).json({ - error: 'Authorization required. Request a challenge and sign with your wallet.', - }) - return + error: + "Authorization required. Request a challenge and sign with your wallet.", + }); + return; } - let walletAddress: string + let walletAddress: string; try { - const token = authHeader.slice(7) // Remove "Bearer " - const payload = verifyToken(token) - walletAddress = payload.address + const token = authHeader.slice(7); // Remove "Bearer " + const payload = verifyToken(token); + walletAddress = payload.address; } catch (err) { res.status(401).json({ - error: err instanceof Error ? err.message : 'Invalid or expired token.', - }) - return + error: err instanceof Error ? err.message : "Invalid or expired token.", + }); + return; } // Check rate limits (per wallet address, durable across instances) if (await isRateLimited(walletAddress)) { - res.status(429).json({ error: 'Too many upload requests. Please try again later.' }) - return + res + .status(429) + .json({ error: "Too many upload requests. Please try again later." }); + return; } - const body = req.body as UploadJsonBody | undefined - if (!body || typeof body.name !== 'string' || typeof body.metadata !== 'object' || body.metadata === null) { - res.status(400).json({ error: 'Request body must include { metadata: object, name: string }.' }) - return + const body = req.body as UploadJsonBody | undefined; + if ( + !body || + typeof body.name !== "string" || + typeof body.metadata !== "object" || + body.metadata === null + ) { + res + .status(400) + .json({ + error: "Request body must include { metadata: object, name: string }.", + }); + return; } // Validate metadata against schema (name, description, image fields) // and enforce strict 8 KiB size limit - const jsonString = JSON.stringify(body.metadata) - const schemaValidation = validateTokenMetadata(body.metadata, jsonString) + const jsonString = JSON.stringify(body.metadata); + const schemaValidation = validateTokenMetadata(body.metadata, jsonString); if (!schemaValidation.valid) { - res.status(400).json({ error: schemaValidation.error }) - return + res.status(400).json({ error: schemaValidation.error }); + return; } - let headers: Record + let headers: Record; try { - headers = pinataHeaders({ 'Content-Type': 'application/json' }) + headers = pinataHeaders({ "Content-Type": "application/json" }); } catch (err) { - res.status(500).json({ error: err instanceof Error ? err.message : 'Server misconfiguration.' }) - return + res + .status(500) + .json({ + error: err instanceof Error ? err.message : "Server misconfiguration.", + }); + return; } try { const pinataRes = await fetch(`${PINATA_API_URL}/pinning/pinJSONToIPFS`, { - method: 'POST', + method: "POST", headers, body: JSON.stringify({ pinataContent: body.metadata, pinataMetadata: { name: body.name }, pinataOptions: { cidVersion: 1 }, }), - }) + }); if (!pinataRes.ok) { - res.status(502).json({ error: `Pinata upload failed (HTTP ${pinataRes.status}).` }) - return + res + .status(502) + .json({ error: `Pinata upload failed (HTTP ${pinataRes.status}).` }); + return; } - const data = (await pinataRes.json()) as { IpfsHash: string } - res.status(200).json({ cid: data.IpfsHash }) + const data = (await pinataRes.json()) as { IpfsHash: string }; + + try { + await recordPinOwner(data.IpfsHash, walletAddress); + } catch (err) { + console.error("Failed to record pin owner:", err); + } + + res.status(200).json({ cid: data.IpfsHash }); } catch { - res.status(500).json({ error: 'Unexpected error while uploading metadata to IPFS.' }) + res + .status(500) + .json({ error: "Unexpected error while uploading metadata to IPFS." }); } } From afc59c721d18736a249d70ade9f9e66704a050e7 Mon Sep 17 00:00:00 2001 From: Ejirosoft Date: Tue, 25 Aug 2026 09:27:33 +0000 Subject: [PATCH 2/2] fix(frontend): align react-dom with react at 19.2.8 PR #1049 bumped react to 19.2.8 but left react-dom pinned to 19.2.7, which React now hard-errors on at runtime ("Incompatible React versions"), breaking the entire frontend test suite and blocking the pre-push hook. --- frontend/package-lock.json | 12 ++++++------ frontend/package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 70e27bc4..a82085cb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,7 +14,7 @@ "i18next": "^26.3.6", "qrcode.react": "^4.2.0", "react": "^19.2.8", - "react-dom": "^19.2.7", + "react-dom": "^19.2.8", "react-hook-form": "^7.83.0", "react-i18next": "^17.0.11", "react-router-dom": "^7.18.1", @@ -10268,15 +10268,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-hook-form": { @@ -11792,7 +11792,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index 3740c8f0..b17e6ec0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,7 @@ "i18next": "^26.3.6", "qrcode.react": "^4.2.0", "react": "^19.2.8", - "react-dom": "^19.2.7", + "react-dom": "^19.2.8", "react-hook-form": "^7.83.0", "react-i18next": "^17.0.11", "react-router-dom": "^7.18.1",