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
5 changes: 5 additions & 0 deletions app/app/api/a2a/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { buildA2AResponse, buildA2AError } from "@/lib/aip/a2a";
import type { A2ARequest } from "@/lib/aip/a2a";
import { enforceIpLimit } from "@/lib/rateLimit";

export async function POST(request: NextRequest) {
// Throttle: this JSON-RPC endpoint can create Job rows; cap unauthenticated
// abuse (spam job creation / resource exhaustion).
const limited = await enforceIpLimit(request, "a2a");
if (limited) return limited;
try {
const body: A2ARequest = await request.json();

Expand Down
7 changes: 6 additions & 1 deletion app/app/api/agents/stake/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { enforceIpLimit } from "@/lib/rateLimit";
import { requireAuth } from "@/lib/require-auth";
import { requireAuth, requireWalletMatch } from "@/lib/require-auth";

/**
* POST /api/agents/stake
Expand Down Expand Up @@ -31,6 +31,11 @@ export async function POST(req: NextRequest) {
);
}

// IDOR guard: only the wallet owner may stake under their address.
const __guard = requireWalletMatch(__auth, walletAddress);
if (!__guard.ok)
return NextResponse.json({ error: __guard.reason }, { status: __guard.status });

if (typeof amount !== "number" || amount < 10) {
return NextResponse.json(
{ error: "Minimum stake is 10 USDC" },
Expand Down
12 changes: 11 additions & 1 deletion app/app/api/agents/unstake/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { enforceIpLimit } from "@/lib/rateLimit";
import { requireAuth, requireWalletMatch } from "@/lib/require-auth";

/**
* POST /api/agents/unstake
Expand All @@ -19,7 +20,11 @@ export async function POST(req: NextRequest) {
const limited = await enforceIpLimit(req, "unstake");
if (limited) return limited;

const body = await req.json();
const __raw = await req.text();
const __auth = await requireAuth(req, { rawBody: __raw });
if (!__auth.ok)
return NextResponse.json({ error: __auth.reason }, { status: __auth.status });
const body = __raw ? JSON.parse(__raw) : {};
const { walletAddress } = body as { walletAddress?: string };

if (!walletAddress || typeof walletAddress !== "string") {
Expand All @@ -29,6 +34,11 @@ export async function POST(req: NextRequest) {
);
}

// IDOR guard: unstake/slash/bonus only affects the owner's own stake.
const __guard = requireWalletMatch(__auth, walletAddress);
if (!__guard.ok)
return NextResponse.json({ error: __guard.reason }, { status: __guard.status });

// Check active stake
const stake = await prisma.agentStake.findUnique({
where: { walletAddress },
Expand Down
5 changes: 5 additions & 0 deletions app/app/api/delivery/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { enforceIpLimit } from "@/lib/rateLimit";

/**
* POST /api/delivery/upload
Expand All @@ -15,6 +16,10 @@ import crypto from "crypto";
* Requires: BLOB_READ_WRITE_TOKEN env var.
*/
export async function POST(req: NextRequest) {
// Throttle: each call writes to Vercel Blob (storage + cost). Cap abuse.
const limited = await enforceIpLimit(req, "delivery_upload");
if (limited) return limited;

const token = process.env.BLOB_READ_WRITE_TOKEN;
if (!token) {
return NextResponse.json(
Expand Down
6 changes: 6 additions & 0 deletions app/app/api/generate/image/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { enforceIpLimit } from "@/lib/rateLimit";

/**
* POST /api/generate/image
Expand Down Expand Up @@ -33,6 +34,11 @@ const SIZE_MAP: Record<string, { width: number; height: number }> = {
};

export async function POST(req: NextRequest) {
// Throttle: each call hits the paid fal.ai API + Vercel Blob. Without a
// limit an unauthenticated caller can run up the bill / exhaust quota.
const limited = await enforceIpLimit(req, "generate_image");
if (limited) return limited;

if (!FAL_KEY) {
return NextResponse.json(
{
Expand Down
45 changes: 40 additions & 5 deletions app/app/api/profile/[wallet]/avatar/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth, requireWalletMatch } from "@/lib/require-auth";
import { enforceIpLimit } from "@/lib/rateLimit";

const AGENT_ALPHA_WALLET = process.env.AGENT_ALPHA_WALLET || "";
const AGENT_OMEGA_WALLET = process.env.AGENT_OMEGA_WALLET || "";
const MAX_SIZE_BYTES = 500 * 1024; // 500KB

// Raster image types only. SVG (data:image/svg+xml) is intentionally excluded:
// SVG is an active document that can carry <script>/onload handlers, so storing
// an attacker-supplied SVG as an avatar is a stored-XSS vector if it is ever
// rendered outside an <img> sandbox.
const ALLOWED_IMAGE_PREFIXES = [
"data:image/png;",
"data:image/jpeg;",
"data:image/jpg;",
"data:image/webp;",
"data:image/gif;",
];

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ wallet: string }> }
) {
try {
const { wallet } = await params;

// Throttle: this writes a 500KB blob to the DB; cap abuse per IP.
const limited = await enforceIpLimit(request, "profile_avatar");
if (limited) return limited;

// Read the raw body once so the auth check can hash it, then parse.
const raw = await request.text();
const auth = await requireAuth(request, { rawBody: raw });
if (!auth.ok) {
return NextResponse.json({ error: auth.reason }, { status: auth.status });
}
// IDOR guard: the signer must control the wallet whose avatar is being set.
const guard = requireWalletMatch(auth, wallet);
if (!guard.ok) {
return NextResponse.json({ error: guard.reason }, { status: guard.status });
}

// Block agent wallets from uploading avatars
if (
wallet === AGENT_ALPHA_WALLET ||
Expand All @@ -23,8 +53,13 @@ export async function POST(
);
}

const body = await request.json();
const { imageData } = body as { imageData?: string };
let body: { imageData?: string };
try {
body = JSON.parse(raw);
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { imageData } = body;

if (!imageData || typeof imageData !== "string") {
return NextResponse.json(
Expand All @@ -33,10 +68,10 @@ export async function POST(
);
}

// Validate it's a real image data URL
if (!imageData.startsWith("data:image/")) {
// Validate it's a real RASTER image data URL (no SVG — stored-XSS vector).
if (!ALLOWED_IMAGE_PREFIXES.some((p) => imageData.startsWith(p))) {
return NextResponse.json(
{ error: "imageData must be a valid image data URL (data:image/...)" },
{ error: "imageData must be a base64 data URL of type png, jpeg, webp, or gif" },
{ status: 400 }
);
}
Expand Down
11 changes: 10 additions & 1 deletion app/app/api/profile/[wallet]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth, requireWalletMatch } from "@/lib/require-auth";

export async function GET(
_request: NextRequest,
Expand Down Expand Up @@ -35,7 +36,15 @@ export async function PATCH(
) {
try {
const { wallet } = await params;
const body = await request.json();
const raw = await request.text();
const auth = await requireAuth(request, { rawBody: raw });
if (!auth.ok)
return NextResponse.json({ error: auth.reason }, { status: auth.status });
// IDOR guard: you may only edit the profile of the wallet you control.
const guard = requireWalletMatch(auth, wallet);
if (!guard.ok)
return NextResponse.json({ error: guard.reason }, { status: guard.status });
const body = raw ? JSON.parse(raw) : {};
const { displayName, bio, role } = body as {
displayName?: string;
bio?: string;
Expand Down
12 changes: 11 additions & 1 deletion app/app/api/profile/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireAuth, requireWalletMatch } from "@/lib/require-auth";

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const raw = await request.text();
const auth = await requireAuth(request, { rawBody: raw });
if (!auth.ok)
return NextResponse.json({ error: auth.reason }, { status: auth.status });
const body = raw ? JSON.parse(raw) : {};
const { walletAddress, displayName, bio, role } = body;

if (!walletAddress || !displayName) {
Expand All @@ -13,6 +18,11 @@ export async function POST(request: NextRequest) {
);
}

// IDOR guard: a profile may only be created for the wallet you control.
const guard = requireWalletMatch(auth, walletAddress);
if (!guard.ok)
return NextResponse.json({ error: guard.reason }, { status: guard.status });

// Check if profile already exists
const existing = await prisma.profile.findUnique({
where: { walletAddress },
Expand Down
33 changes: 27 additions & 6 deletions app/lib/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,34 @@ export function rateLimited429(result: RateLimitResult): Response {
);
}

/** Best-effort client IP from proxy headers. */
/**
* Trusted client IP from proxy headers.
*
* SECURITY: a client can send an arbitrary `X-Forwarded-For` header, so the
* LEFTMOST XFF entry is attacker-controlled and must NOT be used for rate
* limiting (an attacker rotates it to a fresh fake IP per request and the
* per-IP limit never trips). On Vercel, `x-real-ip` and `x-vercel-forwarded-for`
* are set by the platform to the true client IP and overwrite anything the
* client sent, so we trust those first, then the RIGHTMOST XFF entry (appended
* by the closest trusted proxy) — never the spoofable leftmost value.
*/
export function ipFromRequest(req: Request): string {
return (
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
req.headers.get("x-real-ip") ||
"unknown"
);
const realIp = req.headers.get("x-real-ip");
if (realIp) return realIp.trim();

const vercelFwd = req.headers.get("x-vercel-forwarded-for");
if (vercelFwd) {
const parts = vercelFwd.split(",").map((s) => s.trim()).filter(Boolean);
if (parts.length) return parts[parts.length - 1];
}

const xff = req.headers.get("x-forwarded-for");
if (xff) {
const parts = xff.split(",").map((s) => s.trim()).filter(Boolean);
if (parts.length) return parts[parts.length - 1];
}

return "unknown";
}

/**
Expand Down
63 changes: 61 additions & 2 deletions app/lib/require-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,68 @@
import crypto from "node:crypto";
import { verifyWalletSignature } from "./wallet-auth";

/** Whether mutating-endpoint auth is enforced. Off by default (non-breaking). */
/**
* Whether mutating-endpoint auth is enforced.
*
* SECURITY: this is a phased-rollout flag. While it is unset every mutating
* route is effectively unauthenticated. It MUST be flipped to "true" in
* production once the frontend + SDK sign requests (see docs/SECURITY_AUDIT.md,
* "auth enforcement"). When `NODE_ENV==="production"` and the flag is not
* explicitly "true", we emit a loud one-time warning so the gap can't ship
* silently.
*/
let warnedAuthOff = false;
export function authEnforced(): boolean {
return process.env.AUTH_ENFORCED === "true";
const on = process.env.AUTH_ENFORCED === "true";
if (!on && process.env.NODE_ENV === "production" && !warnedAuthOff) {
warnedAuthOff = true;
console.error(
"[security] AUTH_ENFORCED is not 'true' in production: mutating endpoints " +
"accept unauthenticated requests. Enable wallet signing then set AUTH_ENFORCED=true.",
);
}
return on;
}

/**
* Bind an authenticated request to the wallet it claims to act on.
*
* `requireAuth` proves the caller controls the wallet in `x-wallet`, but it
* does NOT check that this equals the wallet the request mutates (the `wallet`
* field in the body / path). Without this binding a caller can sign as their
* own wallet and act on someone else's — a systemic IDOR. Every mutating route
* that takes a wallet from the body/path must call this with the prior
* `requireAuth` result and the wallet it is about to act on.
*
* const auth = await requireAuth(req, { rawBody: raw });
* if (!auth.ok) return Response.json({ error: auth.reason }, { status: auth.status });
* const guard = requireWalletMatch(auth, body.wallet);
* if (!guard.ok) return Response.json({ error: guard.reason }, { status: guard.status });
*
* Semantics:
* - mode "disabled" (flag off): no-op pass, so the binding is correct the
* instant AUTH_ENFORCED is flipped on without breaking the pre-signing UI.
* - mode "api_key": trusted automation, allowed to act on any wallet.
* - mode "signature": REQUIRE proven wallet === acting wallet.
*/
export function requireWalletMatch(
auth: AuthResult,
actingWallet: string | null | undefined,
): { ok: true } | { ok: false; status: number; reason: string } {
if (!auth.ok) return { ok: false, status: auth.status, reason: auth.reason };
if (auth.mode === "disabled" || auth.mode === "api_key") return { ok: true };
// signature mode
if (!actingWallet) {
return { ok: false, status: 400, reason: "missing acting wallet" };
}
if (!auth.wallet || !timingSafeEqual(auth.wallet, actingWallet)) {
return {
ok: false,
status: 403,
reason: "signer does not control the target wallet",
};
}
return { ok: true };
}

/** SHA-256 hex of a (possibly empty) request body. */
Expand Down
Loading
Loading