Put images behind the session and make routes private by default
The /minio-images rewrite proxied straight onto the MinIO bucket with no authentication, and the bucket carries an anonymous read policy. Verified from the public internet: GET /minio-images?list-type=2 returned a full ListBucketResult naming all 33 objects, and any key then fetched 200. Every menu scan, drink photo and bar photo was enumerable and downloadable by anyone. Replaced with a route handler that requires a session and streams via the existing getImage(). The URL shape is unchanged, so stored imageUrls, uploadImage/getImageUrl and every <img> keep working. Nothing uses next/image and it must stay that way here: the optimizer fetches server-side without the session cookie. Route protection was an allowlist that had to be updated by hand for each new page, and had already been missed for /bar, /bartender and /recommend, which rendered to logged-out visitors. /recipes was in the middleware matcher but not the authorized() list, so it fell through too. Inverted both to a denylist so new pages are private by default. /api stays out of the matcher because authorized() answers with an HTML redirect and API clients need the JSON 401 those routes already return. Also fixes a cross-user write: POST /api/recipes took sourceDrinkId from the client with no ownership check, and the drink detail page loaded its recipes relation unfiltered, so one user could attach an arbitrary recipe to another user's drink where it rendered permanently and the owner could not delete it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,10 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
images: {
|
// No `images.remotePatterns` and no rewrite to MinIO: images are served by the
|
||||||
remotePatterns: [
|
// session-gated route at src/app/minio-images/[...key]/route.ts. The rewrite this
|
||||||
{
|
// replaced was an unauthenticated proxy onto the bucket. Nothing uses next/image,
|
||||||
protocol: "http",
|
// and it must stay that way - the optimizer fetches without the session cookie.
|
||||||
hostname: "localhost",
|
|
||||||
port: "9000",
|
|
||||||
pathname: "/drink-images/**",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "*.amazonaws.com",
|
|
||||||
pathname: "/**",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
async rewrites() {
|
|
||||||
// Proxy image requests to MinIO so URLs work from any device
|
|
||||||
const minioHost = process.env.MINIO_ENDPOINT || "localhost";
|
|
||||||
const minioPort = process.env.MINIO_PORT || "9000";
|
|
||||||
const minioBucket = process.env.MINIO_BUCKET || "drink-images";
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/minio-images/:path*",
|
|
||||||
destination: `http://${minioHost}:${minioPort}/${minioBucket}/:path*`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -69,13 +46,11 @@ const nextConfig = {
|
|||||||
// only ever receive as a URL at sign-in.
|
// only ever receive as a URL at sign-in.
|
||||||
[
|
[
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"http://localhost:9000",
|
|
||||||
"https://*.amazonaws.com",
|
|
||||||
"https://lh3.googleusercontent.com",
|
"https://lh3.googleusercontent.com",
|
||||||
"https://avatars.githubusercontent.com",
|
"https://avatars.githubusercontent.com",
|
||||||
].join(" "),
|
].join(" "),
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
"connect-src 'self' http://localhost:9000",
|
"connect-src 'self'",
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export default async function DrinkDetailPage({
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
},
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
|
// Defence in depth: the POST route now verifies drink ownership, but this
|
||||||
|
// relation would otherwise render any user's recipe on this drink's page.
|
||||||
|
where: { userId: session.user.id },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sourceDrinkId comes from the client and is a foreign key onto Drink. Without
|
||||||
|
// this check a user could attach a recipe to someone else's drink, where it
|
||||||
|
// would render on their drink page and they could not delete it.
|
||||||
|
if (parsed.data.sourceDrinkId) {
|
||||||
|
const ownsDrink = await prisma.drink.findFirst({
|
||||||
|
where: { id: parsed.data.sourceDrinkId, userId: session.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!ownsDrink) {
|
||||||
|
return NextResponse.json({ error: "Drink not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const recipe = await prisma.recipe.create({
|
const recipe = await prisma.recipe.create({
|
||||||
data: {
|
data: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
|||||||
56
src/app/minio-images/[...key]/route.ts
Normal file
56
src/app/minio-images/[...key]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { getImage } from "@/lib/s3"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves images out of object storage behind the session.
|
||||||
|
*
|
||||||
|
* This replaces a Next.js rewrite that proxied straight onto the MinIO bucket with
|
||||||
|
* no authentication. Combined with the bucket's anonymous read policy that made
|
||||||
|
* every image in the system enumerable and downloadable from the public internet -
|
||||||
|
* `GET /minio-images?list-type=2` returned a full ListBucketResult.
|
||||||
|
*
|
||||||
|
* The URL shape is deliberately unchanged, so everything that already stores or
|
||||||
|
* renders `/minio-images/...` keeps working: uploadImage/getImageUrl in lib/s3.ts,
|
||||||
|
* imageUrlSchema in lib/validators.ts, and every <img> in the app.
|
||||||
|
*
|
||||||
|
* Note the app renders images with plain <img>, not next/image. That matters: the
|
||||||
|
* image optimizer fetches server-side without the session cookie, so switching to
|
||||||
|
* next/image would 401 every image here.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: { key: string[] } }
|
||||||
|
) {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return new Response(null, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = params.key.join("/")
|
||||||
|
if (key.includes("..")) {
|
||||||
|
return new Response(null, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const object = await getImage(key)
|
||||||
|
if (!object.Body) return new Response(null, { status: 404 })
|
||||||
|
|
||||||
|
return new Response(object.Body as unknown as ReadableStream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": object.ContentType ?? "application/octet-stream",
|
||||||
|
...(object.ContentLength
|
||||||
|
? { "Content-Length": String(object.ContentLength) }
|
||||||
|
: {}),
|
||||||
|
// Keys embed a random UUID, so an object never changes under a given key.
|
||||||
|
// `private` keeps it out of any shared cache now that it is session-gated.
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Missing key, or storage unavailable - do not distinguish, to avoid
|
||||||
|
// confirming whether a given key exists.
|
||||||
|
return new Response(null, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ import { PrismaAdapter } from "@auth/prisma-adapter"
|
|||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
import { rateLimit } from "@/lib/rate-limit"
|
import { rateLimit } from "@/lib/rate-limit"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only routes reachable without a session. Matched exactly or as a path
|
||||||
|
* prefix, so `/share` also covers `/share/<slug>`. Everything else is private -
|
||||||
|
* see the `authorized` callback and the denylist matcher in src/middleware.ts.
|
||||||
|
*/
|
||||||
|
const PUBLIC_ROUTES = ["/login", "/register", "/share"]
|
||||||
|
|
||||||
const providers = [
|
const providers = [
|
||||||
Google({
|
Google({
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
@@ -66,19 +73,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
},
|
},
|
||||||
authorized({ auth, request: { nextUrl } }) {
|
authorized({ auth, request: { nextUrl } }) {
|
||||||
const isLoggedIn = !!auth?.user
|
const isLoggedIn = !!auth?.user
|
||||||
const isOnApp = nextUrl.pathname.startsWith("/dashboard") ||
|
const { pathname } = nextUrl
|
||||||
nextUrl.pathname.startsWith("/scan") ||
|
|
||||||
nextUrl.pathname.startsWith("/drinks") ||
|
|
||||||
nextUrl.pathname.startsWith("/rate") ||
|
|
||||||
nextUrl.pathname.startsWith("/settings") ||
|
|
||||||
nextUrl.pathname.startsWith("/wishlist")
|
|
||||||
|
|
||||||
if (isOnApp) {
|
// Everything is private unless named here. Paired with the denylist matcher
|
||||||
if (isLoggedIn) return true
|
// in middleware.ts, so a new page is protected by default rather than public
|
||||||
return false
|
// until somebody remembers to add it.
|
||||||
}
|
const isPublic = PUBLIC_ROUTES.some(
|
||||||
|
(p) => pathname === p || pathname.startsWith(`${p}/`)
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoggedIn && (nextUrl.pathname === "/login" || nextUrl.pathname === "/register")) {
|
if (!isPublic && !isLoggedIn) return false
|
||||||
|
|
||||||
|
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
|
||||||
return Response.redirect(new URL("/dashboard", nextUrl))
|
return Response.redirect(new URL("/dashboard", nextUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
export { auth as middleware } from "@/lib/auth"
|
export { auth as middleware } from "@/lib/auth"
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
// A denylist, not an allowlist. The previous per-route allowlist meant every new
|
||||||
|
// page was public until someone remembered to add it here - /bar, /bartender and
|
||||||
|
// /recommend all shipped unprotected that way. Everything is matched now except
|
||||||
|
// static assets, and `authorized` in lib/auth.ts names the few public routes.
|
||||||
|
//
|
||||||
|
// /api is deliberately excluded: `authorized` answers with an HTML redirect to
|
||||||
|
// /login, whereas API clients need a JSON 401 - which every route already returns
|
||||||
|
// from its own auth() check.
|
||||||
matcher: [
|
matcher: [
|
||||||
"/dashboard/:path*",
|
"/((?!api|_next/static|_next/image|favicon.ico|manifest.json).*)",
|
||||||
"/scan/:path*",
|
|
||||||
"/drinks/:path*",
|
|
||||||
"/rate/:path*",
|
|
||||||
"/settings/:path*",
|
|
||||||
"/wishlist/:path*",
|
|
||||||
"/recipes/:path*",
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user