Skip to content
Open
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
134 changes: 134 additions & 0 deletions apps/scan/src/app/api/v1/resources/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { NextResponse, type NextRequest } from 'next/server';
import { z } from 'zod';

import { probeX402Endpoint } from '@/lib/discovery/probe';
import { registerResource } from '@/lib/resources';

const refreshBodySchema = z.object({
url: z.string().url('A valid URL is required'),
});

const corsHeaders: Record<string, string> = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};

function jsonResponse(data: unknown, status = 200): NextResponse {
return NextResponse.json(
JSON.parse(
JSON.stringify(data, (_k, v: unknown) =>
typeof v === 'bigint' ? Number(v) : v
)
),
{ status, headers: corsHeaders }
);
}

export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}

/**
* POST /api/v1/resources/refresh
*
* Re-probe an existing resource and update its registration data.
* This allows resource providers to trigger a refresh after updating
* their offering, ensuring x402scan displays the latest information.
*
* Body: { "url": "https://example.com/api/resource" }
*/
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return jsonResponse(
{
success: false,
error: {
type: 'invalid_json',
message: 'Request body must be valid JSON',
},
},
400
);
}

const parsed = refreshBodySchema.safeParse(body);
if (!parsed.success) {
return jsonResponse(
{
success: false,
error: {
type: 'validation_error',
message: 'Invalid request body',
details: parsed.error.issues,
},
},
400
);
}

const { url } = parsed.data;

try {
const probeResult = await probeX402Endpoint(
url.replaceAll('{', '').replaceAll('}', '')
);

if (!probeResult.success) {
return jsonResponse(
{
success: false,
error: {
type: 'no_402',
message:
'URL did not return a 402 Payment Required response. Ensure the resource is still x402-protected.',
details: probeResult.error,
},
},
422
);
}

const result = await registerResource(url, probeResult.advisory);

if (!result.success) {
return jsonResponse(
{
success: false,
error: {
type: 'parse_error',
message: 'Failed to parse x402 response during refresh',
parseErrors:
result.error.type === 'parseResponse'
? result.error.parseErrors
: [JSON.stringify(result.error)],
},
},
422
);
}

return jsonResponse({
success: true,
message: 'Resource refreshed successfully',
resource: result.resource,
accepts: result.accepts,
registrationDetails: result.registrationDetails,
});
} catch (error) {
console.error('Resource refresh failed:', error);
return jsonResponse(
{
success: false,
error: {
type: 'internal_error',
message: 'An unexpected error occurred during refresh',
},
},
500
);
}
}
120 changes: 120 additions & 0 deletions apps/scan/src/app/api/v1/resources/register-origin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { NextResponse, type NextRequest } from 'next/server';
import { z } from 'zod';

import { fetchDiscoveryDocument } from '@/services/discovery';
import { registerResourcesFromDiscovery } from '@/lib/discovery/register-origin';

const registerOriginBodySchema = z.object({
origin: z.string().url('A valid origin URL is required'),
});

const corsHeaders: Record<string, string> = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};

function jsonResponse(data: unknown, status = 200): NextResponse {
return NextResponse.json(
JSON.parse(
JSON.stringify(data, (_k, v: unknown) =>
typeof v === 'bigint' ? Number(v) : v
)
),
{ status, headers: corsHeaders }
);
}

export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}

/**
* POST /api/v1/resources/register-origin
*
* Discover and register all x402-protected resources from an origin.
* Uses DNS TXT records (_x402.{hostname}) or /.well-known/x402 for discovery.
*
* Body: { "origin": "https://example.com" }
*/
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return jsonResponse(
{
success: false,
error: {
type: 'invalid_json',
message: 'Request body must be valid JSON',
},
},
400
);
}

const parsed = registerOriginBodySchema.safeParse(body);
if (!parsed.success) {
return jsonResponse(
{
success: false,
error: {
type: 'validation_error',
message: 'Invalid request body',
details: parsed.error.issues,
},
},
400
);
}

const { origin } = parsed.data;

try {
const discoveryResult = await fetchDiscoveryDocument(origin);

if (!discoveryResult.success) {
return jsonResponse(
{
success: false,
error: {
type: 'no_discovery',
message:
discoveryResult.error ?? 'No discovery document found at origin',
},
},
404
);
}

const result = await registerResourcesFromDiscovery(
discoveryResult.resources,
discoveryResult.source
);

return jsonResponse({
success: true,
registered: result.registered,
failed: result.failed,
skipped: result.skipped,
deprecated: result.deprecated,
total: result.total,
source: result.source,
failedDetails:
result.failedDetails.length > 0 ? result.failedDetails : undefined,
});
} catch (error) {
console.error('Origin registration failed:', error);
return jsonResponse(
{
success: false,
error: {
type: 'internal_error',
message: 'An unexpected error occurred during origin registration',
},
},
500
);
}
}
133 changes: 133 additions & 0 deletions apps/scan/src/app/api/v1/resources/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { NextResponse, type NextRequest } from 'next/server';
import { z } from 'zod';

import { probeX402Endpoint } from '@/lib/discovery/probe';
import { registerResource } from '@/lib/resources';

const registerBodySchema = z.object({
url: z.string().url('A valid URL is required'),
});

const corsHeaders: Record<string, string> = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};

function jsonResponse(data: unknown, status = 200): NextResponse {
return NextResponse.json(
JSON.parse(
JSON.stringify(data, (_k, v: unknown) =>
typeof v === 'bigint' ? Number(v) : v
)
),
{ status, headers: corsHeaders }
);
}

export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}

/**
* POST /api/v1/resources/register
*
* Register a single x402-protected resource by URL.
* The endpoint probes the URL for a 402 response, parses payment info,
* and registers it on x402scan.
*
* Body: { "url": "https://example.com/api/resource" }
*/
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await request.json();
} catch {
return jsonResponse(
{
success: false,
error: {
type: 'invalid_json',
message: 'Request body must be valid JSON',
},
},
400
);
}

const parsed = registerBodySchema.safeParse(body);
if (!parsed.success) {
return jsonResponse(
{
success: false,
error: {
type: 'validation_error',
message: 'Invalid request body',
details: parsed.error.issues,
},
},
400
);
}

const { url } = parsed.data;

try {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server-Side Request Forgery (SSRF) vulnerability in multiple public API endpoints allowing unauthenticated users to probe internal/private addresses including localhost, private IP ranges, and cloud metadata endpoints.

Fix on Vercel

const probeResult = await probeX402Endpoint(
url.replaceAll('{', '').replaceAll('}', '')
);

if (!probeResult.success) {
return jsonResponse(
{
success: false,
error: {
type: 'no_402',
message: 'URL did not return a 402 Payment Required response',
details: probeResult.error,
},
},
422
);
}

const result = await registerResource(url, probeResult.advisory);

if (!result.success) {
return jsonResponse(
{
success: false,
error: {
type: 'parse_error',
message: 'Failed to parse x402 response',
parseErrors:
result.error.type === 'parseResponse'
? result.error.parseErrors
: [JSON.stringify(result.error)],
},
data: result.data,
},
422
);
}

return jsonResponse({
success: true,
resource: result.resource,
accepts: result.accepts,
registrationDetails: result.registrationDetails,
});
} catch (error) {
console.error('Resource registration failed:', error);
return jsonResponse(
{
success: false,
error: {
type: 'internal_error',
message: 'An unexpected error occurred during registration',
},
},
500
);
}
}
Loading