VibeAudit · Claude Fable 5.1 reads a whole repo and grades it for launch. Free quick scan, $19 deep audit. Scan your own repo →
DEEP AUDIT · 2026-09-02
Nutlope/roomGPT
70–89 fix first
<70 not ready
roomGPT is a Next.js 13 App Router app: users upload a room photo via Bytescale, a single POST /generate route hands the URL and a prompt to a Replicate ControlNet model, polls until done, and returns the image. The core flow works on the happy path, but the API route is unauthenticated, rate limiting is silently disabled when Upstash env vars are absent, request input is unvalidated, and almost every failure path (Replicate error, rate limit hit, generation failure) either crashes the client or hangs the spinner forever. The most important fix is protecting /generate from unlimited Replicate spend (auth or mandatory, correctly-keyed rate limiting plus input validation), followed by making the route and client handle failures without hanging.
27 files reviewed · claude-fable-5-1 · deep audit
Findings (12)
The route has no auth and every request creates a paid Replicate prediction (line 40). Rate limiting only runs `if (ratelimit)` (line 17), which is undefined whenever UPSTASH_REDIS_REST_URL/TOKEN are missing (utils/redis.ts line 3-9). The README and .example.env describe those vars as optional and the Vercel deploy button only prompts for REPLICATE_API_KEY, so the default production deploy has zero throttling. Even with Redis configured, the limit is 5/day per IP, which is trivially bypassed with rotating IPs or proxies.
A single script can call /generate in a loop and run up an arbitrary Replicate bill against your account, or exhaust your quota so real users get failures. For a paid product this is a direct money leak; for a free demo it is a denial-of-wallet vector.
Do not deploy with rate limiting optional. Either (a) require Redis and fail closed: `if (!ratelimit) return NextResponse.json({error:'Rate limiter not configured'},{status:503})`, or (b) put the route behind a session (NextAuth/Clerk) and key the limit on user id, and add a global daily cap as a circuit breaker (e.g. a second Ratelimit with identifier 'global' at N/day) so a leak is bounded.
In app/generate/route.ts, make rate limiting mandatory and add a global spend cap. Currently the Upstash Ratelimit is only created when Redis env vars exist and the check is skipped otherwise, which means production can run with no throttling on a paid Replicate endpoint. Change it so that if `redis` is undefined the handler returns a 503 JSON error (`{ error: 'Rate limiting is not configured' }`) instead of proceeding. Add a second Ratelimit instance (e.g. `Ratelimit.fixedWindow(500, '1440 m')`) keyed on the constant string 'global' and check it before the per-IP limit, returning 429 if exceeded, so total daily Replicate spend is bounded. Update .example.env and README to say the Upstash vars are required, and add them to the Vercel deploy button `env=` list in README.md.`ratelimit.limit(ipIdentifier ?? "")` uses the `x-real-ip` header (line 19). On any host that does not set it (local dev, many non-Vercel platforms, some proxies) every request shares the identifier "" and the whole site is limited to 5 generations per day globally. Where the header is passed through from the client rather than set by the edge, an attacker can spoof it to get a fresh bucket per request.
Either the entire app locks out all users after 5 requests in 24h (if header missing), or attackers bypass the limit entirely by sending a random x-real-ip (if header is not overwritten by the platform).
Resolve the client IP defensively and never fall back to a shared constant: `const ip = headersList.get('x-real-ip') ?? headersList.get('x-forwarded-for')?.split(',')[0]?.trim(); if (!ip) return NextResponse.json({error:'Could not identify client'},{status:400});`. On Vercel, x-real-ip and x-forwarded-for are set by the platform and are trustworthy; document that assumption or gate on process.env.VERCEL.
In app/generate/route.ts, replace `const ipIdentifier = headersList.get("x-real-ip"); const result = await ratelimit.limit(ipIdentifier ?? "");` with logic that reads `x-real-ip`, then falls back to the first entry of `x-forwarded-for` (split on comma, trimmed), and if neither is present returns a 400 JSON response instead of rate limiting on an empty string. Reason: an empty identifier puts every user in one shared 5/day bucket, and blindly trusting a single header allows bypass when the platform does not overwrite it. Add a comment noting the headers are only trustworthy behind Vercel/a trusted proxy.`const { imageUrl, theme, room } = await request.json();` is destructured and used without checks. `theme.toLowerCase()`/`room.toLowerCase()` (line 54) throw a TypeError if either is missing or non-string, producing an unhandled 500. Any string is accepted, so a caller can put arbitrary text into the ControlNet prompt, and `imageUrl` can be any URL (not just your Bytescale account), turning your Replicate account into a free general-purpose image generation API. The client only offers a fixed set of themes/rooms (utils/dropdownTypes.ts), but the server never enforces it.
Users can generate arbitrary (including NSFW or infringing) images billed to your Replicate account and associated with your brand; malformed bodies crash the route with an opaque 500.
Validate against the allowlists and origin: `import { themes, rooms } from '../../utils/dropdownTypes'; const body = await request.json().catch(()=>null); if (!body || !themes.includes(body.theme) || !rooms.includes(body.room) || typeof body.imageUrl !== 'string' || !/^https:\/\/upcdn\.io\//.test(body.imageUrl)) return NextResponse.json({error:'Invalid input'},{status:400});`. Optionally also check the URL contains your Bytescale account id.
In app/generate/route.ts, add server-side validation of the request body before calling Replicate. Import `themes` and `rooms` from utils/dropdownTypes.ts. Parse the body with `await request.json().catch(() => null)`; if it is null, or `theme` is not in `themes`, or `room` is not in `rooms`, or `imageUrl` is not a string starting with `https://upcdn.io/`, return `NextResponse.json({ error: 'Invalid input' }, { status: 400 })`. Reason: currently arbitrary strings reach the ControlNet prompt (prompt injection that bills our Replicate account) and any URL is accepted; also a missing theme causes `theme.toLowerCase()` to throw an unhandled 500. Use the validated values when building the Replicate request.`let endpointUrl = jsonStartResponse.urls.get;` assumes the POST to Replicate succeeded. If Replicate returns 401 (bad/missing REPLICATE_API_KEY, which becomes the literal header `Token undefined`), 402 (out of credit), 422 (bad input/image URL), or 429, the body has `detail`/`title` but no `urls`, so the route throws a TypeError and Next returns a generic 500 HTML page. `startResponse.ok` and `startResponse.status` are never inspected.
The most common production failures (expired key, no billing, Replicate rate limit) surface as an unexplained 500 with nothing in the response to tell you or the user what happened; the client then hangs (see F6).
Check the response and return a JSON error: `if (!startResponse.ok || !jsonStartResponse?.urls?.get) { console.error('Replicate start failed', startResponse.status, jsonStartResponse); return NextResponse.json({error:'Image generation is temporarily unavailable'},{status:502}); }`. Wrap the whole handler in try/catch that returns JSON 500.
In app/generate/route.ts, after `let jsonStartResponse = await startResponse.json();` add a guard: if `!startResponse.ok` or `!jsonStartResponse?.urls?.get`, log `startResponse.status` and the body with console.error and return `NextResponse.json({ error: 'Image generation is temporarily unavailable' }, { status: 502 })`. Also wrap the entire POST handler body in a try/catch that logs the error and returns `NextResponse.json({ error: 'Unexpected error' }, { status: 500 })`. Reason: currently `jsonStartResponse.urls.get` throws a TypeError whenever Replicate returns 401/402/422/429, producing an opaque HTML 500 that the client cannot parse.`while (!restoredImage)` only exits on `succeeded` or `failed` (lines 81-84). Replicate predictions can also end as `canceled`, and the polling `fetch` itself has no timeout and no `.ok` check (a non-JSON error body makes `finalResponse.json()` throw). There is no `export const maxDuration` on the route, so on Vercel Hobby the function is killed at 10s; ControlNet with a cold boot routinely takes 15-60s.
Requests hang until the platform kills them (10s on Hobby, 60s+ on Pro), burning function time while the Replicate prediction still runs and bills you. Users see a spinner that never resolves, and the route silently fails on the default Vercel plan.
Bound the loop and the function: `export const maxDuration = 60;` at top of the route; track `const deadline = Date.now() + 55_000;` and `break` when past it or when status is `failed`/`canceled`; check `finalResponse.ok`; pass `signal: AbortSignal.timeout(10_000)` to each fetch. Longer term, use Replicate webhooks or have the client poll a lightweight status route so the function does not block for the whole generation.
In app/generate/route.ts, make the Replicate polling loop bounded. Add `export const maxDuration = 60;` at the top of the file. Inside POST, compute `const deadline = Date.now() + 55_000` before the while loop and change the loop condition so it also exits when `Date.now() > deadline`. Treat `status === 'canceled'` like `failed` and break. Check `finalResponse.ok` before calling `.json()` and break with an error if not ok. Pass `signal: AbortSignal.timeout(10_000)` to both Replicate fetch calls. If the loop exits without an image, return a JSON error with status 504. Reason: today the loop can spin forever on 'canceled' or on a non-JSON error response, and without maxDuration the Vercel Hobby 10s limit kills the function before ControlNet finishes.
On failure the route returns `NextResponse.json("Failed to restore image")` with the default 200 status. The client (app/dream/page.tsx line 92-95) treats any 200 as success and does `setRestoredImage(newPhoto[1])`, which on the string yields the character "a". That is then passed to `<Image src="a">`, which next/image rejects at runtime (invalid src / not in `images.domains`), crashing the dream page. The client also assumes `output` is an array with a second element without checking.
Every Replicate failure (NSFW filter, bad image, model error) crashes the user's page instead of showing the error state that already exists in the UI.
Return a proper error status from the server: `if (!restoredImage) return NextResponse.json({error:'Failed to generate image'},{status:502}); return NextResponse.json({image: Array.isArray(restoredImage) ? restoredImage[1] : restoredImage});`. On the client, check `typeof data.image === 'string'` before setting state.
In app/generate/route.ts, replace the final `return NextResponse.json(restoredImage ? restoredImage : "Failed to restore image")` with: if `restoredImage` is falsy, return `NextResponse.json({ error: 'Failed to generate image' }, { status: 502 })`; otherwise return `NextResponse.json({ image: Array.isArray(restoredImage) ? restoredImage[1] : restoredImage })`. Then in app/dream/page.tsx update `generatePhoto` to read `data.image` on success and `data.error` on failure, and only call `setRestoredImage` when `typeof data.image === 'string'`. Reason: the current 200-with-string response makes the client do `"Failed to restore image"[1]` → 'a', which is then passed to next/image and crashes the page.`let newPhoto = await res.json();` runs before the status check. The rate-limit branch in the route returns a plain-text `new Response("Too many uploads...")` (route.ts line 24), and any thrown error in the route returns Next's HTML 500 page. `res.json()` rejects on both, `generatePhoto` has no try/catch, so `setLoading(false)` (line 97-99) never runs and `setError` is never called. If the server ever does return JSON on error, `setError(newPhoto)` may set an object, which React refuses to render as a child (line 242) and crashes.
Rate-limited users, and users hitting any server error, see an infinite loading indicator with no message and no way to retry except reloading.
Wrap in try/catch/finally and read text first: `try { const res = await fetch(...); const text = await res.text(); let data; try { data = JSON.parse(text) } catch { data = { error: text } } if (!res.ok) { setError(typeof data === 'string' ? data : data.error ?? 'Something went wrong'); return; } setRestoredImage(data.image); } catch (e) { setError('Network error, please try again'); } finally { setLoading(false); }`. Also make the 429 in the route return JSON for consistency.
In app/dream/page.tsx, rewrite `generatePhoto` so it cannot hang: wrap the fetch in try/catch/finally; call `setLoading(false)` in `finally`; read the body with `await res.text()` and attempt `JSON.parse`, falling back to `{ error: text }`; if `!res.ok`, call `setError` with a string (use `data.error` if present, otherwise the raw text or a generic message) and return; on success set the restored image from `data.image`. In app/generate/route.ts change the 429 rate-limit response to `NextResponse.json({ error: 'Too many uploads in 1 day. Please try again in 24 hours.' }, { status: 429, headers: {...} })`. Reason: today `res.json()` throws on the plain-text 429 and on HTML 500 pages, so the loading spinner never clears and no error is shown.Quick wins
- · Change the 429 response in app/generate/route.ts to JSON so the client can parse it (part of F7).
- · Add `export const maxDuration = 60` to app/generate/route.ts so Vercel does not kill the function at 10s.
- · Delete unused dependencies `request-ip`, `@types/request-ip`, and `react-countup` from package.json.
- · Fix the leftover `aria-label="TaxPal on Twitter"` / `"TaxPal on GitHub"` in components/Footer.tsx.
- · Handle filenames without an extension in utils/appendNewToName.ts (indexOf('.') === -1 currently produces '-newfilename').
- · Remove the `console.log("polling for result...")` in the polling loop or gate it behind a debug flag; it spams production logs once per second per request.
- · Add NEXT_PUBLIC_UPLOAD_API_KEY and the Upstash vars to the Vercel deploy button `env=` list in README.md.
What's already good
- · Secrets are handled correctly: REPLICATE_API_KEY and Upstash credentials are server-only and never reach the client bundle; the only NEXT_PUBLIC var is the Bytescale public key, which is designed to be public.
- · The upload widget restricts MIME types to JPEG/PNG and a single file, and images are served via Bytescale/Replicate CDNs rather than your own server.
- · Rate limiting infrastructure (Upstash + @upstash/ratelimit) is already wired in and just needs to be made mandatory and keyed correctly.
- · The UI already has loading and error states designed in; the fixes are about routing failures into them, not building them.
- · Small, readable surface area: one API route and one client page, which makes hardening cheap.
Do this first
- Protect /generate from unbounded spend: make rate limiting fail-closed, fix the IP identifier, add a global daily cap, and validate theme/room/imageUrl against allowlists (F1, F2, F3).
- Make failures observable and non-fatal: check Replicate's start response, bound the polling loop with maxDuration and a deadline, return JSON errors with proper status codes, and rewrite the client's generatePhoto with try/catch/finally (F4, F5, F6, F7).
- Fix deployment config: require NEXT_PUBLIC_UPLOAD_API_KEY, update the README deploy button env list, set metadataBase (F9, F12).
- Persist generated images to your own storage so results do not expire, and only consume a rate-limit token after validation (F10, F8).
- Upgrade Next.js and Upstash libraries, then add real auth before layering payments on top (F11).
Fixed things? Re-audit.
Run a new scan on the updated repo. Use a 5-pack key or pay per audit.