From d2db21a5586e84f3cb832e906aa2190f0a55564a Mon Sep 17 00:00:00 2001 From: airslice Date: Thu, 23 Oct 2025 18:17:09 +0800 Subject: [PATCH] feat: add honeypot and rate limit --- .../extensions/sp_submitter/submitter/App.tsx | 216 ++++++++++-------- .../extensions/sp_submitter/submitter/app.css | 5 +- .../sp_submitter/submitter/hooks.ts | 8 + server/api/photographs.ts | 44 +++- server/src/types/index.ts | 1 + server/src/utils/rateLimiter.ts | 148 ++++++++++++ server/src/utils/validation.ts | 3 +- 7 files changed, 318 insertions(+), 107 deletions(-) create mode 100644 server/src/utils/rateLimiter.ts diff --git a/plugin/src/extensions/sp_submitter/submitter/App.tsx b/plugin/src/extensions/sp_submitter/submitter/App.tsx index 7374932..852880a 100644 --- a/plugin/src/extensions/sp_submitter/submitter/App.tsx +++ b/plugin/src/extensions/sp_submitter/submitter/App.tsx @@ -53,119 +53,133 @@ function App() { ) : ( -
-
- +
- - {formData.position && ( -

- ✓ Position selected:{" "} - {formData.position.coordinates[1].toFixed(6)},{" "} - {formData.position.coordinates[0].toFixed(6)} -

- )} + +
+ + {formData.position && ( +

+ ✓ Position selected:{" "} + {formData.position.coordinates[1].toFixed(6)},{" "} + {formData.position.coordinates[0].toFixed(6)} +

+ )} +
-
-
- - -
+
+ + +
-
- - {formData.photoUrl && ( -
- Uploaded photograph + + {formData.photoUrl && ( +
+ Uploaded photograph +
+ )} +
+ + + {!isUploading && formData.photoUrl && ( + ✓ Uploaded + )}
- )} -
- +
+ +
+ - {!isUploading && formData.photoUrl && ( - ✓ Uploaded - )}
-
-
- - -
+
+ + +
-
- - -
+
+ + +
- - + + )} diff --git a/plugin/src/extensions/sp_submitter/submitter/app.css b/plugin/src/extensions/sp_submitter/submitter/app.css index 510ca36..d8cdeaa 100644 --- a/plugin/src/extensions/sp_submitter/submitter/app.css +++ b/plugin/src/extensions/sp_submitter/submitter/app.css @@ -2,4 +2,7 @@ --background: transparent; } -/* Add your custom styles here */ +/* Honeypot */ +.hp { + display: none; +} diff --git a/plugin/src/extensions/sp_submitter/submitter/hooks.ts b/plugin/src/extensions/sp_submitter/submitter/hooks.ts index b5dc04c..3475e39 100644 --- a/plugin/src/extensions/sp_submitter/submitter/hooks.ts +++ b/plugin/src/extensions/sp_submitter/submitter/hooks.ts @@ -13,6 +13,7 @@ type FormData = { type: "Point"; coordinates: [number, number]; } | null; + website: string; }; export default () => { @@ -22,6 +23,7 @@ export default () => { author: "", photoUrl: "", position: null, + website: "", }); const [isSubmitting, setIsSubmitting] = useState(false); const [isUploading, setIsUploading] = useState(false); @@ -115,6 +117,11 @@ export default () => { return; } + // Anti-bot validation + if (formData.website.trim() !== "") { + return; + } + setIsSubmitting(true); try { @@ -133,6 +140,7 @@ export default () => { author: "", photoUrl: "", position: null, + website: "", }); setIsSubmitSuccess(true); } else { diff --git a/server/api/photographs.ts b/server/api/photographs.ts index defc3ca..2777159 100644 --- a/server/api/photographs.ts +++ b/server/api/photographs.ts @@ -4,20 +4,24 @@ import cors from "cors"; import { cmsService } from "../src/services/cms.js"; import { CreatePhotographRequest } from "../src/types"; import { authenticate, AuthenticatedRequest } from "../src/utils/auth.js"; +import { createPhotographRateLimiter } from "../src/utils/rateLimiter.js"; import { sendSuccess, sendError } from "../src/utils/response.js"; import { validateRequest, PhotographSchema } from "../src/utils/validation.js"; // CORS configuration const corsOptions = { - origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { + origin: ( + origin: string | undefined, + callback: (err: Error | null, allow?: boolean) => void + ) => { const corsOrigin = process.env.CORS_ORIGIN; if (!corsOrigin) { callback(null, true); return; } - - const allowedOrigins = corsOrigin.split(',').map(o => o.trim()); - + + const allowedOrigins = corsOrigin.split(",").map((o) => o.trim()); + if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { @@ -105,6 +109,32 @@ async function handleCreatePhotograph( res: VercelResponse, modelId: string ) { + // Check rate limit for photograph creation + const rateLimitResult = createPhotographRateLimiter.consume(req); + + // Add rate limit headers + res.setHeader("X-RateLimit-Limit", rateLimitResult.limit); + res.setHeader("X-RateLimit-Remaining", rateLimitResult.remaining); + res.setHeader( + "X-RateLimit-Reset", + new Date(rateLimitResult.resetTime).toISOString() + ); + + if (!rateLimitResult.success) { + return sendError( + res, + "RATE_LIMIT_EXCEEDED", + "Too many photograph submissions, please try again later.", + 429, + [ + { + field: "rate_limit", + message: `Please wait ${Math.ceil((rateLimitResult.resetTime - Date.now()) / 1000)} seconds before trying again.`, + }, + ] + ); + } + const validation = validateRequest(PhotographSchema, req.body); if (!validation.success) { @@ -117,6 +147,12 @@ async function handleCreatePhotograph( ); } + // Honeypot validation - silently reject if website field is filled + if (validation.data?.website && validation.data.website.trim() !== "") { + // Return success to avoid tipping off bots, but don't create the photograph + return sendSuccess(res, { id: "blocked", message: "Success" }, 201); + } + try { const photograph = await cmsService.createPhotograph( modelId, diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 17d864f..e66e70c 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -22,6 +22,7 @@ export type CreatePhotographRequest = { type: "Point"; coordinates: [number, number]; }; + website?: string; }; export type ApiResponse = { diff --git a/server/src/utils/rateLimiter.ts b/server/src/utils/rateLimiter.ts new file mode 100644 index 0000000..340ee29 --- /dev/null +++ b/server/src/utils/rateLimiter.ts @@ -0,0 +1,148 @@ +import { VercelRequest, VercelResponse } from "@vercel/node"; + +type RateLimitStore = Record< + string, + { + count: number; + resetTime: number; + } +>; + +// In-memory store for rate limiting +const store: RateLimitStore = {}; + +type RateLimitOptions = { + windowMs: number; // Time window in milliseconds + maxRequests: number; // Maximum number of requests per window + keyGenerator?: (req: VercelRequest) => string; // Function to generate unique key + skipSuccessfulRequests?: boolean; // Whether to skip counting successful requests + skipFailedRequests?: boolean; // Whether to skip counting failed requests +}; + +type RateLimitResult = { + success: boolean; + limit: number; + remaining: number; + resetTime: number; +}; + +export class RateLimiter { + private options: Required; + + constructor(options: RateLimitOptions) { + this.options = { + skipSuccessfulRequests: false, + skipFailedRequests: false, + keyGenerator: (req: VercelRequest) => this.getClientIp(req), + ...options, + }; + } + + private getClientIp(req: VercelRequest): string { + const forwarded = req.headers["x-forwarded-for"]; + const realIp = req.headers["x-real-ip"]; + + if (forwarded) { + return Array.isArray(forwarded) ? forwarded[0] : forwarded.split(",")[0]; + } + + if (realIp) { + return Array.isArray(realIp) ? realIp[0] : realIp; + } + + return req.socket?.remoteAddress || "unknown"; + } + + private cleanupExpiredEntries(): void { + const now = Date.now(); + Object.keys(store).forEach((key) => { + if (store[key].resetTime <= now) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete store[key]; + } + }); + } + + checkLimit(req: VercelRequest): RateLimitResult { + this.cleanupExpiredEntries(); + + const key = this.options.keyGenerator(req); + const now = Date.now(); + const resetTime = now + this.options.windowMs; + + if (!store[key]) { + store[key] = { + count: 0, + resetTime, + }; + } + + // Reset if window expired + if (store[key].resetTime <= now) { + store[key] = { + count: 0, + resetTime, + }; + } + + const current = store[key]; + const remaining = Math.max(0, this.options.maxRequests - current.count); + + return { + success: current.count < this.options.maxRequests, + limit: this.options.maxRequests, + remaining, + resetTime: current.resetTime, + }; + } + + consume(req: VercelRequest): RateLimitResult { + const result = this.checkLimit(req); + + if (result.success) { + const key = this.options.keyGenerator(req); + store[key].count++; + result.remaining--; + } + + return result; + } + + // Middleware function for easy integration + middleware() { + return (req: VercelRequest, res: VercelResponse, next: () => void) => { + const result = this.consume(req); + + // Add rate limit headers + res.setHeader("X-RateLimit-Limit", result.limit); + res.setHeader("X-RateLimit-Remaining", result.remaining); + res.setHeader( + "X-RateLimit-Reset", + new Date(result.resetTime).toISOString() + ); + + if (!result.success) { + return res.status(429).json({ + error: { + code: "RATE_LIMIT_EXCEEDED", + message: "Too many requests, please try again later.", + retryAfter: Math.ceil((result.resetTime - Date.now()) / 1000), + }, + }); + } + + next(); + }; + } +} + +// Predefined rate limiters for common use cases +export const createPhotographRateLimiter = new RateLimiter({ + windowMs: 15 * 60 * 1000, // 15 minutes + maxRequests: 30, // 30 requests per 15 minutes per IP +}); + +export const generalApiRateLimiter = new RateLimiter({ + windowMs: 60 * 1000, // 1 minute + maxRequests: 30, // 30 requests per minute per IP +}); diff --git a/server/src/utils/validation.ts b/server/src/utils/validation.ts index a7d1ca9..9345022 100644 --- a/server/src/utils/validation.ts +++ b/server/src/utils/validation.ts @@ -11,7 +11,8 @@ export const PhotographSchema = z.object({ z.number().min(-180).max(180), z.number().min(-90).max(90) ]) - }) + }), + website: z.string().optional() }); export function validateRequest(schema: z.ZodSchema, data: unknown): {