Skip to content

Commit dd0efda

Browse files
siracusa5claude
andcommitted
fix(marketplace): address search API security and UX review findings
- Add runtime allowlist validation for `scope` and `type` params (H1) - Stop forwarding Supabase error.message to client; log server-side (H2) - Cap query string at 200 chars before tokenization (M1) - Parse and validate `rating` param as float 1–5 at handler boundary (M2) - Fix profile `hasMore`: fetch limit+1 rows and slice, replacing the unreliable `length === limit` heuristic (L4) - Fix `filterByRating` undefined check: use explicit `=== undefined` instead of falsy `!` (components with ratings summing to 0 no longer misclassified as unrated) - Extract `jsonResponse` helper to eliminate duplicate X-Response-Time header computation across profile FTS + fallback paths - Extract `applyRatingFilter` helper to remove duplicated post-filter logic in FTS and ILIKE paths - Remove placeholder Rating/Token Cost/Platform filter sections from FilterPanel — non-functional UI ships nothing to users until the backing API support is wired in Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a0ced74 commit dd0efda

2 files changed

Lines changed: 116 additions & 214 deletions

File tree

apps/marketplace/app/api/search/route.ts

Lines changed: 116 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
22
import { supabase } from "@/lib/supabase";
33
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
44

5-
type SearchScope = "component" | "profile";
6-
type ComponentType = "skill" | "agent" | "hook" | "script" | "knowledge" | "rules";
5+
const VALID_SCOPES = new Set(["component", "profile"]);
6+
const VALID_COMPONENT_TYPES = new Set([
7+
"skill", "agent", "hook", "script", "knowledge", "rules", "plugin",
8+
]);
79

810
export async function GET(request: NextRequest) {
9-
// Start performance monitoring
1011
const startTime = performance.now();
1112

1213
const { searchParams } = request.nextUrl;
1314
const query = searchParams.get("q") ?? "";
14-
const scope = (searchParams.get("scope") as SearchScope) ?? "component";
15-
16-
// Pagination parameters
17-
const page = parseInt(searchParams.get("page") ?? "1", 10);
18-
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20", 10), 100); // Max 100 per page
19-
20-
// Filter parameters for components
21-
const componentType = searchParams.get("type") as ComponentType | null;
22-
const category = searchParams.get("category");
23-
const minRating = searchParams.get("rating");
2415

2516
if (!query) {
2617
return NextResponse.json(
@@ -29,13 +20,59 @@ export async function GET(request: NextRequest) {
2920
);
3021
}
3122

23+
if (query.length > 200) {
24+
return NextResponse.json(
25+
{ error: "Query too long (max 200 characters)" },
26+
{ status: 400 },
27+
);
28+
}
29+
30+
const scopeParam = searchParams.get("scope") ?? "component";
31+
if (!VALID_SCOPES.has(scopeParam)) {
32+
return NextResponse.json(
33+
{ error: "Invalid scope. Must be 'component' or 'profile'" },
34+
{ status: 400 },
35+
);
36+
}
37+
const scope = scopeParam as "component" | "profile";
38+
39+
// Pagination parameters
40+
const page = parseInt(searchParams.get("page") ?? "1", 10);
41+
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20", 10), 100);
42+
3243
if (page < 1) {
3344
return NextResponse.json(
3445
{ error: "Page must be >= 1" },
3546
{ status: 400 },
3647
);
3748
}
3849

50+
// Filter parameters — validated before use
51+
const componentTypeParam = searchParams.get("type");
52+
if (componentTypeParam !== null && !VALID_COMPONENT_TYPES.has(componentTypeParam)) {
53+
return NextResponse.json(
54+
{ error: `Invalid type filter. Must be one of: ${[...VALID_COMPONENT_TYPES].join(", ")}` },
55+
{ status: 400 },
56+
);
57+
}
58+
const componentType = componentTypeParam as string | null;
59+
60+
const category = searchParams.get("category");
61+
62+
// Parse and validate rating at the boundary
63+
let minRating: number | null = null;
64+
const ratingParam = searchParams.get("rating");
65+
if (ratingParam !== null) {
66+
const parsed = parseFloat(ratingParam);
67+
if (isNaN(parsed) || parsed < 1 || parsed > 5) {
68+
return NextResponse.json(
69+
{ error: "Rating must be a number between 1 and 5" },
70+
{ status: 400 },
71+
);
72+
}
73+
minRating = parsed;
74+
}
75+
3976
// 60 searches per minute per IP
4077
const ip = getClientIp(request);
4178
const { allowed, retryAfter } = checkRateLimit(`search:${ip}`, {
@@ -72,7 +109,7 @@ export async function GET(request: NextRequest) {
72109
.select("*")
73110
.textSearch("fts", tsquery)
74111
.order("name", { ascending: true })
75-
.range(offset, offset + limit - 1);
112+
.range(offset, offset + limit); // fetch limit+1 to detect hasMore
76113

77114
if (error) {
78115
// Fallback to ilike if FTS fails — use parameterized filters
@@ -81,99 +118,62 @@ export async function GET(request: NextRequest) {
81118
.select("*")
82119
.or(`name.ilike.%${query.replace(/[,().%_\\]/g, "")}%,description.ilike.%${query.replace(/[,().%_\\]/g, "")}%`)
83120
.order("name", { ascending: true })
84-
.range(offset, offset + limit - 1);
121+
.range(offset, offset + limit); // fetch limit+1 to detect hasMore
85122

86123
if (fallbackError) {
87-
return NextResponse.json(
88-
{ error: `Search failed: ${fallbackError.message}` },
89-
{ status: 500 },
90-
);
124+
console.error("[search] profile fallback failed", fallbackError);
125+
return NextResponse.json({ error: "Search failed" }, { status: 500 });
91126
}
92127

93-
const endTime = performance.now();
94-
const responseTime = ((endTime - startTime) / 1000).toFixed(3);
95-
96-
return NextResponse.json(
97-
{
98-
type: "profile",
99-
results: fallback ?? [],
100-
pagination: {
101-
page,
102-
limit,
103-
hasMore: (fallback?.length ?? 0) === limit,
104-
},
105-
},
106-
{
107-
headers: {
108-
"X-Response-Time": `${responseTime}s`,
109-
},
110-
}
111-
);
128+
const results = fallback ?? [];
129+
return jsonResponse(startTime, {
130+
type: "profile",
131+
results: results.slice(0, limit),
132+
pagination: { page, limit, hasMore: results.length > limit },
133+
});
112134
}
113135

114-
const endTime = performance.now();
115-
const responseTime = ((endTime - startTime) / 1000).toFixed(3);
116-
117-
return NextResponse.json(
118-
{
119-
type: "profile",
120-
results: data ?? [],
121-
pagination: {
122-
page,
123-
limit,
124-
hasMore: (data?.length ?? 0) === limit,
125-
},
126-
},
127-
{
128-
headers: {
129-
"X-Response-Time": `${responseTime}s`,
130-
},
131-
}
132-
);
136+
const results = data ?? [];
137+
return jsonResponse(startTime, {
138+
type: "profile",
139+
results: results.slice(0, limit),
140+
pagination: { page, limit, hasMore: results.length > limit },
141+
});
133142
}
134143

135144
// Default: search components using full-text search with filters
136145
try {
137146
const results = await searchComponents(tsquery, query, componentType, category, minRating, page, limit);
138-
139-
const endTime = performance.now();
140-
const responseTime = ((endTime - startTime) / 1000).toFixed(3);
141-
142-
return NextResponse.json(
143-
{
144-
type: "component",
145-
results: results.data,
146-
pagination: {
147-
page,
148-
limit,
149-
hasMore: results.hasMore,
150-
},
151-
},
152-
{
153-
headers: {
154-
"X-Response-Time": `${responseTime}s`,
155-
},
156-
}
157-
);
158-
} catch (error: any) {
159-
return NextResponse.json(
160-
{ error: `Search failed: ${error.message}` },
161-
{ status: 500 },
162-
);
147+
return jsonResponse(startTime, {
148+
type: "component",
149+
results: results.data,
150+
pagination: { page, limit, hasMore: results.hasMore },
151+
});
152+
} catch (err) {
153+
console.error("[search] component search failed", err);
154+
return NextResponse.json({ error: "Search failed" }, { status: 500 });
163155
}
164156
}
165157

158+
function jsonResponse(startTime: number, body: object) {
159+
const responseTime = ((performance.now() - startTime) / 1000).toFixed(3);
160+
return NextResponse.json(body, {
161+
headers: { "X-Response-Time": `${responseTime}s` },
162+
});
163+
}
164+
166165
async function searchComponents(
167166
tsquery: string,
168167
rawQuery: string,
169-
componentType: ComponentType | null,
168+
componentType: string | null,
170169
category: string | null,
171-
minRating: string | null,
170+
minRating: number | null,
172171
page: number,
173172
limit: number,
174173
): Promise<{ data: any[]; hasMore: boolean }> {
175174
const offset = (page - 1) * limit;
176-
// Step 1: Get component IDs that match the category filter (if specified)
175+
176+
// Step 1: Resolve category filter to component IDs (if specified)
177177
let categoryComponentIds: string[] | null = null;
178178
if (category) {
179179
const { data: categoryData, error: categoryError } = await supabase
@@ -184,31 +184,27 @@ async function searchComponents(
184184
if (categoryError) throw categoryError;
185185
categoryComponentIds = categoryData?.map((cc: any) => cc.component_id) ?? [];
186186

187-
// If category filter is specified but no components match, return empty
188187
if (categoryComponentIds.length === 0) {
189188
return { data: [], hasMore: false };
190189
}
191190
}
192191

193-
// Step 2: Build the main component search query with FTS
192+
// Step 2: Build and execute the FTS query
194193
let componentQuery = supabase
195194
.from("components")
196195
.select("*")
197196
.textSearch("fts", tsquery);
198197

199-
// Apply component type filter
200198
if (componentType) {
201199
componentQuery = componentQuery.eq("type", componentType);
202200
}
203201

204-
// Apply category filter (if we have matching component IDs)
205202
if (categoryComponentIds) {
206203
componentQuery = componentQuery.in("id", categoryComponentIds);
207204
}
208205

209-
// Execute the query - fetch extra to determine if there are more pages
210-
// When rating filter is present, we need more data for post-filtering
211-
const fetchLimit = minRating ? Math.max(limit * 3, 100) : limit + 1;
206+
// When a rating filter is active, over-fetch to compensate for post-filter attrition
207+
const fetchLimit = minRating !== null ? Math.max(limit * 3, 100) : limit + 1;
212208

213209
const { data: components, error } = await componentQuery
214210
.order("install_count", { ascending: false })
@@ -235,82 +231,54 @@ async function searchComponents(
235231

236232
if (fallbackError) throw fallbackError;
237233

238-
// Apply rating filter if specified
239-
if (minRating && fallback) {
240-
const minRatingNum = parseFloat(minRating);
241-
const filteredResults = await filterByRating(fallback, minRatingNum);
242-
const hasMore = filteredResults.length > limit;
243-
return {
244-
data: filteredResults.slice(0, limit),
245-
hasMore,
246-
};
247-
}
248-
249-
const resultData = fallback ?? [];
250-
const hasMore = resultData.length > limit;
251-
return {
252-
data: resultData.slice(0, limit),
253-
hasMore,
254-
};
234+
return applyRatingFilter(fallback ?? [], minRating, limit);
255235
}
256236

257-
// Step 3: Apply rating filter if specified
258-
if (minRating && components) {
259-
const minRatingNum = parseFloat(minRating);
260-
const filteredResults = await filterByRating(components, minRatingNum);
261-
const hasMore = filteredResults.length > limit;
262-
return {
263-
data: filteredResults.slice(0, limit),
264-
hasMore,
265-
};
237+
return applyRatingFilter(components ?? [], minRating, limit);
238+
}
239+
240+
async function applyRatingFilter(
241+
components: any[],
242+
minRating: number | null,
243+
limit: number,
244+
): Promise<{ data: any[]; hasMore: boolean }> {
245+
if (minRating === null) {
246+
const hasMore = components.length > limit;
247+
return { data: components.slice(0, limit), hasMore };
266248
}
267249

268-
const resultData = components ?? [];
269-
const hasMore = resultData.length > limit;
270-
return {
271-
data: resultData.slice(0, limit),
272-
hasMore,
273-
};
250+
const filtered = await filterByRating(components, minRating);
251+
const hasMore = filtered.length > limit;
252+
return { data: filtered.slice(0, limit), hasMore };
274253
}
275254

276-
// Helper function to filter components by minimum average rating
277-
async function filterByRating(components: any[], minRating: number) {
255+
async function filterByRating(components: any[], minRating: number): Promise<any[]> {
278256
if (components.length === 0) return [];
279257

280258
const componentIds = components.map(c => c.id);
281259

282-
// Get average ratings for all components
283260
const { data: ratings } = await supabase
284261
.from("ratings")
285262
.select("component_id, rating")
286263
.in("component_id", componentIds);
287264

288265
if (!ratings || ratings.length === 0) {
289-
// No ratings = only return if minRating is 0
290-
return minRating === 0 ? components : [];
266+
return [];
291267
}
292268

293-
// Calculate average rating per component
294-
const ratingMap = new Map<string, number>();
295-
const countMap = new Map<string, number>();
269+
// Compute average rating per component
270+
const ratingSum = new Map<string, number>();
271+
const ratingCount = new Map<string, number>();
296272

297-
ratings.forEach(r => {
298-
const current = ratingMap.get(r.component_id) || 0;
299-
const count = countMap.get(r.component_id) || 0;
300-
ratingMap.set(r.component_id, current + r.rating);
301-
countMap.set(r.component_id, count + 1);
302-
});
273+
for (const r of ratings) {
274+
ratingSum.set(r.component_id, (ratingSum.get(r.component_id) ?? 0) + r.rating);
275+
ratingCount.set(r.component_id, (ratingCount.get(r.component_id) ?? 0) + 1);
276+
}
303277

304-
// Calculate averages and filter
305278
return components.filter(component => {
306-
const totalRating = ratingMap.get(component.id);
307-
const count = countMap.get(component.id);
308-
309-
if (!totalRating || !count) {
310-
return minRating === 0; // No ratings = only include if minRating is 0
311-
}
312-
313-
const avgRating = totalRating / count;
314-
return avgRating >= minRating;
279+
const sum = ratingSum.get(component.id);
280+
const count = ratingCount.get(component.id);
281+
if (sum === undefined || count === undefined) return false;
282+
return sum / count >= minRating;
315283
});
316284
}

0 commit comments

Comments
 (0)