Skip to content
Closed
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
31 changes: 18 additions & 13 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] <brief description>`.

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.
Expand All @@ -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

Expand All @@ -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 `<meta>` tag and via HTTP response headers on the hosted deployment. See the [README](./README.md#content-security-policy-csp) for configuration details.
122 changes: 122 additions & 0 deletions api/_lib/pinOwnership.ts
Original file line number Diff line number Diff line change
@@ -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<string, PinOwnerRecord>();

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<void> {
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<string | null> {
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<void> {
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<string | null> {
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<void> {
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<void> {
await fetch(`${url}/del/${encodeURIComponent(key)}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
}
55 changes: 34 additions & 21 deletions api/_lib/schemaValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, unknown>
const obj = metadata as Record<string, unknown>;

// 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 };
}
Loading
Loading