-
Notifications
You must be signed in to change notification settings - Fork 0
fix: harden security scan findings #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { ExternalLink } from "lucide-react"; | ||
| import { cookies } from "next/headers"; | ||
| import Link from "next/link"; | ||
| import { notFound } from "next/navigation"; | ||
|
|
||
| import { Button } from "@/app/components/ui/button"; | ||
| import { Card } from "@/app/components/ui/card"; | ||
| import { Stamp } from "@/app/components/ui/stamp"; | ||
| import { ADMIN_COOKIE_NAME, verifyAdminSession } from "@/lib/auth/admin-session"; | ||
| import { appEnvironment } from "@/lib/env"; | ||
|
|
||
| import { SentryClientTest } from "./client-test"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| export default async function SentryTestPage() { | ||
| const cookieStore = await cookies(); | ||
| const session = cookieStore.get(ADMIN_COOKIE_NAME)?.value; | ||
| if (appEnvironment === "production" || !(await verifyAdminSession(session))) { | ||
| notFound(); | ||
| } | ||
|
|
||
| return ( | ||
| <main className="min-h-screen bg-paper px-4 py-8 text-ink sm:px-8"> | ||
| <div className="mx-auto grid max-w-3xl gap-6"> | ||
| <div className="grid gap-4"> | ||
| <Stamp status="new">Preview only</Stamp> | ||
| <h1 className="ab-display-md">Sentry test counter</h1> | ||
| <p className="ab-body text-ink-2"> | ||
| Send one server event and one browser event before closing Phase 1. | ||
| </p> | ||
| </div> | ||
| <div className="grid gap-4 md:grid-cols-2"> | ||
| <Card className="grid gap-5"> | ||
| <div> | ||
| <p className="ab-eyebrow">Server event</p> | ||
| <h2 className="ab-h2 mt-2">Send a route test</h2> | ||
| </div> | ||
| <p className="ab-body-sm text-ink-2"> | ||
| The route captures a server exception and returns the event id. | ||
| </p> | ||
| <Button asChild> | ||
| <Link href="/admin/sentry-test/server"> | ||
| Open server route | ||
| <ExternalLink aria-hidden="true" /> | ||
| </Link> | ||
| </Button> | ||
| </Card> | ||
| <SentryClientTest /> | ||
| </div> | ||
| </div> | ||
| </main> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import * as Sentry from "@sentry/nextjs"; | ||
| import { cookies } from "next/headers"; | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| import { ADMIN_COOKIE_NAME, verifyAdminSession } from "@/lib/auth/admin-session"; | ||
| import { appEnvironment } from "@/lib/env"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| export async function GET() { | ||
| const cookieStore = await cookies(); | ||
| const session = cookieStore.get(ADMIN_COOKIE_NAME)?.value; | ||
| if ( | ||
| appEnvironment === "production" || | ||
| !(await verifyAdminSession(session, Date.now())) | ||
| ) { | ||
| return NextResponse.json({ message: "Not found." }, { status: 404 }); | ||
| } | ||
|
|
||
| const eventId = Sentry.captureException(new Error("Stoop server Sentry test event")); | ||
|
|
||
| return NextResponse.json( | ||
| { eventId, message: "Sentry server test event captured." }, | ||
| { | ||
| headers: { | ||
| "Cache-Control": "no-store" | ||
| } | ||
| } | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,7 @@ | ||
| import * as Sentry from "@sentry/nextjs"; | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| import { appEnvironment } from "@/lib/env"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| // Legacy public diagnostic URL. The functional Sentry test is now under /admin/sentry-test so an | ||
| // unauthenticated request can never create monitoring events in preview deployments. | ||
| export function GET() { | ||
| if (appEnvironment === "production") { | ||
| return NextResponse.json( | ||
| { message: "Sentry test events are disabled in production." }, | ||
| { status: 404 } | ||
| ); | ||
| } | ||
|
|
||
| const eventId = Sentry.captureException( | ||
| new Error("Stoop server Sentry test event") | ||
| ); | ||
|
|
||
| return NextResponse.json( | ||
| { eventId, message: "Sentry server test event captured." }, | ||
| { | ||
| headers: { | ||
| "Cache-Control": "no-store" | ||
| } | ||
| } | ||
| ); | ||
| return NextResponse.json({ message: "Not found." }, { status: 404 }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,50 +1,6 @@ | ||
| import { ExternalLink } from "lucide-react"; | ||
| import Link from "next/link"; | ||
| import { notFound } from "next/navigation"; | ||
|
|
||
| import { Button } from "@/app/components/ui/button"; | ||
| import { Card } from "@/app/components/ui/card"; | ||
| import { Stamp } from "@/app/components/ui/stamp"; | ||
| import { appEnvironment } from "@/lib/env"; | ||
|
|
||
| import { SentryClientTest } from "./client-test"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| export default function SentryTestPage() { | ||
| if (appEnvironment === "production") { | ||
| notFound(); | ||
| } | ||
|
|
||
| return ( | ||
| <main className="min-h-screen bg-paper px-4 py-8 text-ink sm:px-8"> | ||
| <div className="mx-auto grid max-w-3xl gap-6"> | ||
| <div className="grid gap-4"> | ||
| <Stamp status="new">Preview only</Stamp> | ||
| <h1 className="ab-display-md">Sentry test counter</h1> | ||
| <p className="ab-body text-ink-2"> | ||
| Send one server event and one browser event before closing Phase 1. | ||
| </p> | ||
| </div> | ||
| <div className="grid gap-4 md:grid-cols-2"> | ||
| <Card className="grid gap-5"> | ||
| <div> | ||
| <p className="ab-eyebrow">Server event</p> | ||
| <h2 className="ab-h2 mt-2">Send a route test</h2> | ||
| </div> | ||
| <p className="ab-body-sm text-ink-2"> | ||
| The route captures a server exception and returns the event id. | ||
| </p> | ||
| <Button asChild> | ||
| <Link href="/api/sentry-test"> | ||
| Open server route | ||
| <ExternalLink aria-hidden="true" /> | ||
| </Link> | ||
| </Button> | ||
| </Card> | ||
| <SentryClientTest /> | ||
| </div> | ||
| </div> | ||
| </main> | ||
| ); | ||
| // Keep the legacy public URL non-enumerable after moving the diagnostic behind founder access. | ||
| export default function LegacySentryTestPage() { | ||
| notFound(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,12 +7,12 @@ import { clientIp } from "@/lib/security/request-ip"; | |
| import { verifyTurnstile } from "@/lib/security/turnstile"; | ||
|
|
||
| // Phase 9.3: the soft abuse-control gate shared by the anon order + subscribe server actions. Both | ||
| // resolve the edge IP, reserve a per-(ip, store) + per-store KV window pair as one decision, then | ||
| // run the Turnstile challenge. The local hard cap goes first so a scripted flood can't force | ||
| // unlimited third-party siteverify calls with random tokens. The orchestration is identical; only | ||
| // the keys/limits and the caller-facing copy differ, so callers pass a window builder and map the | ||
| // reason to their own message. KV-null (plain `next dev` / tests) fails open — the same soft-control | ||
| // contract as the rest of the limiter. | ||
| // resolve the edge IP, verify the Turnstile challenge, then reserve a per-(ip, store) + per-store | ||
| // KV window pair as one decision. Failed challenges must not consume a store's finite public-write | ||
| // capacity; otherwise an attacker could deny service without solving Turnstile. The orchestration is | ||
| // identical; only the keys/limits and the caller-facing copy differ, so callers pass a window builder | ||
| // and map the reason to their own message. KV-null (plain `next dev` / tests) fails open — the same | ||
| // soft-control contract as the rest of the limiter. | ||
|
|
||
| export type AnonGuardResult = | ||
| | { ok: true } | ||
|
|
@@ -23,6 +23,10 @@ export async function guardAnonWrite( | |
| buildWindows: (ip: string, now: number) => RateLimitReservation[] | ||
| ): Promise<AnonGuardResult> { | ||
| const ip = await clientIp(); | ||
| if (!(await verifyTurnstile(turnstileToken, ip))) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| return { ok: false, reason: "turnstile" }; | ||
| } | ||
|
|
||
| const kv = getRateLimitKv(); | ||
| if (kv) { | ||
| const now = Date.now(); | ||
|
|
@@ -32,9 +36,5 @@ export async function guardAnonWrite( | |
| } | ||
| } | ||
|
|
||
| if (!(await verifyTurnstile(turnstileToken, ip))) { | ||
| return { ok: false, reason: "turnstile" }; | ||
| } | ||
|
|
||
| return { ok: true }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For any JPG/PNG under 4 MB whose source dimensions exceed 2048 px on an edge, this new check rejects before the existing
resize({ fit: "inside" })can downscale it. The upload path still only pre-validates MIME and byte size, and the processor was designed to clamp dimensions, so common phone photos that used to be accepted will now fail with the misleading “under 4 MB” error; keep a separate input-pixel bomb cap while still allowing ordinary oversized photos to be normalized.Useful? React with 👍 / 👎.