3042d289a0
Full list of the audit's confirmed findings and their fixes: - Stored XSS via unescaped JSON-LD on the public recipe page (app/r/[id]/page.tsx) — escape < before injecting. - CSP allowed unsafe-eval in production — now dev-only (Next prod never eval()s; only its HMR does). - avatarUrl accepted any URL with no ownership check — now takes an avatarKey issued by avatar-presign, validated server-side, same pattern as recipe/review photos. - No session revocation on password change/reset — both now revoke other sessions (revokeOtherSessions: true, revokeSessionsOnPasswordReset). - Rate-limit bypass via spoofable X-Forwarded-For — take the last (proxy-appended) hop instead of the first (client-supplied) one, matching the single-Traefik-hop topology. - Webhook signing secrets stored plaintext — now AES-256-GCM encrypted like every other secret in this app, with a legacy- plaintext fallback for pre-existing rows (bare hex has no ":", our ciphertext format always does). - Better Auth's own rate limiter defaulted to in-memory storage, ineffective across replicas — now backed by the same Redis as lib/rate-limit.ts (secondaryStorage), with storeSessionInDatabase explicit so session storage itself doesn't move as a side effect. - Presigned upload URLs didn't bind the declared file size to the actual upload, letting a client under-declare size (and quota charge) then PUT an arbitrarily large object — switched to S3 presigned POST with a signed content-length-range condition, enforced by the storage server itself. - generateMetadata() on the recipe page skipped the visibility filter the page body uses, leaking a private recipe's title via <title> to any signed-in user with the id. - Block/unblock had no rate limit, unlike follow/unfollow. - AI quota was charged even when a user's own BYOK key was used (their own credentials/billing) — added an isByok flag through the config-resolution chain and skip the charge when set. Also wired BYOK into generate/generate-from-idea/translate/import-url, which never looked it up at all before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { toast } from "sonner";
|
|
import { Camera, Loader2, X } from "lucide-react";
|
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { uploadToPresignedPost } from "@/lib/upload-client";
|
|
|
|
export function AvatarUploader({
|
|
name,
|
|
image,
|
|
hasCustomAvatar,
|
|
onChange,
|
|
}: {
|
|
name: string;
|
|
image: string | null;
|
|
hasCustomAvatar: boolean;
|
|
onChange: (image: string | null, hasCustomAvatar: boolean) => void;
|
|
}) {
|
|
const t = useTranslations("settingsForm");
|
|
const [uploading, setUploading] = useState(false);
|
|
const [preview, setPreview] = useState<string | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
async function handleFile(file: File) {
|
|
setUploading(true);
|
|
setPreview(URL.createObjectURL(file));
|
|
try {
|
|
const presignRes = await fetch("/api/v1/upload/avatar-presign", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ contentType: file.type, fileSize: file.size }),
|
|
});
|
|
if (!presignRes.ok) {
|
|
const err = await presignRes.json() as { error?: string };
|
|
toast.error(err.error ?? t("avatarUploadFailed"));
|
|
return;
|
|
}
|
|
const { url, fields, key } = await presignRes.json() as { url: string; fields: Record<string, string>; key: string };
|
|
const uploaded = await uploadToPresignedPost(url, fields, file);
|
|
if (!uploaded) {
|
|
toast.error(t("avatarUploadFailed"));
|
|
return;
|
|
}
|
|
|
|
// Send back the storage key we were issued, not a client-derived URL —
|
|
// the server validates the key belongs to us and builds the public URL
|
|
// itself, so a client can't set avatarUrl to an arbitrary/unowned target.
|
|
const saveRes = await fetch("/api/v1/users/me", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ avatarKey: key }),
|
|
});
|
|
if (!saveRes.ok) {
|
|
toast.error(t("avatarUploadFailed"));
|
|
return;
|
|
}
|
|
const saved = await saveRes.json() as { avatarUrl?: string };
|
|
onChange(saved.avatarUrl ?? null, true);
|
|
toast.success(t("avatarUploadSuccess"));
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
async function handleRemove() {
|
|
setUploading(true);
|
|
try {
|
|
const res = await fetch("/api/v1/users/me", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ avatarUrl: null }),
|
|
});
|
|
if (!res.ok) {
|
|
toast.error(t("avatarUploadFailed"));
|
|
return;
|
|
}
|
|
const saved = await res.json() as { avatarUrl?: string };
|
|
setPreview(null);
|
|
onChange(saved.avatarUrl ?? null, false);
|
|
toast.success(t("avatarRemoved"));
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative">
|
|
<Avatar size="lg" className="size-16">
|
|
<AvatarImage src={preview ?? image ?? undefined} alt={name} />
|
|
<AvatarFallback className="text-lg">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
{uploading && (
|
|
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
|
|
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/avif"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) void handleFile(file);
|
|
e.target.value = "";
|
|
}}
|
|
/>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => inputRef.current?.click()} disabled={uploading}>
|
|
<Camera className="h-4 w-4" />
|
|
{t("changePhoto")}
|
|
</Button>
|
|
{hasCustomAvatar && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { void handleRemove(); }}
|
|
disabled={uploading}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
{t("removePhoto")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|