Security hardening: 12 improvements across the stack

1. env.ts: APP_KEY placeholder detection with validation
2. schema.prisma: password column widened to varchar(255) for argon2id
3. auth.ts: trustHost restricted to development only
4. next.config.ts: added CSP, HSTS, X-Frame-Options, and other security headers
5. api.ts: CORS restricted to APP_URL instead of wildcard
6. register-form.tsx: migrated from REST API fetch to server action (useActionState)
7. twofactor.ts + 2fa page: TOTP recovery codes (8 one-time codes, generated and displayed)
8. register.ts: password min length 8 + complexity requirements (upper, lower, digit)
9. register.ts + help-tickets.ts + radio-shouts.ts: Zod schema validation
10. rate-limit.ts: improved periodic cleanup with aggressive eviction at 10k buckets
11. guard.ts + admin actions: rate-limited admin actions (30 req/min per staff)
12. help-tickets.ts + radio-shouts.ts: content moderation via moderateOrThrow
This commit is contained in:
openhands committed 2026-07-04 18:52:00 +02:00
1 parent a1950e5b65
commit 5628e7d6b7
19 files changed
+370 -220

No files matched your search

+18 -80
View File
@@ -1,9 +1,9 @@
"use client";
import { useState, FormEvent, ChangeEvent } from "react";
import { useActionState, ChangeEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { register } from "@/actions/register";
import { AvatarCarousel } from "@/components/auth/avatar-carousel";
interface RegisterFormProps {
@@ -22,71 +22,8 @@ export function RegisterForm({
error,
}: RegisterFormProps) {
const t = useTranslations("pages.register");
const router = useRouter();
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
const [selectedFigure, setSelectedFigure] = useState(initialFigures[0] || "");
const [formData, setFormData] = useState({
username: "",
mail: "",
password: "",
password_confirmation: "",
terms: false,
beta_code: "",
referral_code: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({ ...prev, [name]: type === "checkbox" ? checked : value }));
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) newErrors.username = t("usernameError");
if (!formData.mail.trim()) newErrors.mail = t("emailError");
if (!formData.password) newErrors.password = t("passwordError");
if (formData.password !== formData.password_confirmation) {
newErrors.password_confirmation = t("confirmPasswordError");
}
if (!formData.terms) newErrors.terms = t("termsError");
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
setIsSubmitting(false);
return;
}
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
look: selectedFigure,
}),
});
const data = await res.json();
if (!res.ok) {
setErrors({ form: data.message || "Registration failed" });
setIsSubmitting(false);
return;
}
router.push("/login?registered=1");
} catch {
setErrors({ form: "An error occurred. Please try again." });
setIsSubmitting(false);
}
};
const [serverError, formAction, isPending] = useActionState(register, null);
return (
<div className="mx-auto w-full max-w-[800px]">
@@ -111,7 +48,7 @@ export function RegisterForm({
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-6" style={{
<form action={formAction} className="p-4 flex flex-col gap-6" style={{
backgroundColor: "color-mix(in srgb, var(--color-background) 50%, var(--color-surface))",
borderRadius: "0 0 8px 8px",
}}>
@@ -120,6 +57,14 @@ export function RegisterForm({
{error}
</div>
)}
{serverError && (
<div className="p-3 rounded-md text-sm" style={{ backgroundColor: "#ef4444", color: "white" }}>
{serverError}
</div>
)}
{/* Avatar hidden input */}
<input type="hidden" name="look" defaultValue={initialFigures[0] || ""} />
{/* Username & Email */}
<div className="w-full flex flex-col md:flex-row gap-4">
@@ -141,9 +86,7 @@ export function RegisterForm({
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.username && <p className="text-xs text-red-500">{errors.username}</p>}
</fieldset>
</div>
<div className="w-full md:w-1/2">
@@ -164,9 +107,7 @@ export function RegisterForm({
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.mail && <p className="text-xs text-red-500">{errors.mail}</p>}
</fieldset>
</div>
</div>
@@ -191,9 +132,7 @@ export function RegisterForm({
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.password && <p className="text-xs text-red-500">{errors.password}</p>}
</fieldset>
</div>
<div className="w-full md:w-1/2">
@@ -214,9 +153,7 @@ export function RegisterForm({
borderColor: "color-mix(in srgb, var(--color-text-muted) 25%, transparent)",
}}
required
onChange={handleChange}
/>
{errors.password_confirmation && <p className="text-xs text-red-500">{errors.password_confirmation}</p>}
</fieldset>
</div>
</div>
@@ -232,7 +169,10 @@ export function RegisterForm({
<AvatarCarousel
figures={initialFigures}
imager={imager}
onSelect={setSelectedFigure}
onSelect={(figure) => {
const input = document.querySelector<HTMLInputElement>('input[name="look"]');
if (input) input.value = figure;
}}
/>
</div>
@@ -248,7 +188,6 @@ export function RegisterForm({
className="w-4 h-4 border-gray-300 rounded focus:ring-0"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)" }}
required
onChange={handleChange}
/>
<label htmlFor="terms" className="font-semibold" style={{ color: "var(--color-text)" }}>
<a href="/help-center/rules" target="_blank" rel="noopener noreferrer" className="hover:underline" style={{ color: "var(--color-text)" }}>
@@ -256,7 +195,6 @@ export function RegisterForm({
</a>
</label>
</fieldset>
{errors.terms && <p className="text-xs text-red-500 italic">{errors.terms}</p>}
</div>
{/* Captcha */}
@@ -270,14 +208,14 @@ export function RegisterForm({
{/* Submit */}
<button
type="submit"
disabled={isSubmitting}
disabled={isPending}
className="w-full rounded p-2 text-white font-semibold transition-all duration-200 text-base"
style={{
backgroundColor: "#27a44d",
border: "2px solid #34c661",
}}
>
{isSubmitting ? t("creatingAccount") : t("createAccount")}
{isPending ? t("creatingAccount") : t("createAccount")}
</button>
{/* Social Login - optional */}