Harden form auth and submission handling
This commit is contained in:
+86
@@ -0,0 +1,86 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import type { Session } from "next-auth";
|
||||
import { db } from "@/db";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
import { getGuildMemberRoles } from "@/lib/discord";
|
||||
|
||||
export type SessionWithDiscord = Session & {
|
||||
user?: Session["user"] & {
|
||||
id?: string;
|
||||
discordId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FormAccessConfig = {
|
||||
id: string;
|
||||
allowedRoles?: string[] | null;
|
||||
deniedRoles?: string[] | null;
|
||||
};
|
||||
|
||||
export function getAdminDiscordIds() {
|
||||
return (process.env.ADMIN_DISCORD_IDS ?? "")
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getSessionDiscordId(session: Session | null) {
|
||||
return (session as SessionWithDiscord | null)?.user?.discordId ?? null;
|
||||
}
|
||||
|
||||
export async function getCurrentDiscordId() {
|
||||
const session = await getServerSession(authOptions);
|
||||
return getSessionDiscordId(session);
|
||||
}
|
||||
|
||||
export async function requireDiscordId() {
|
||||
const discordId = await getCurrentDiscordId();
|
||||
if (!discordId) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
return discordId;
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
const discordId = await requireDiscordId();
|
||||
if (!getAdminDiscordIds().includes(discordId)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
return discordId;
|
||||
}
|
||||
|
||||
export async function canAccessForm(form: FormAccessConfig, discordId: string | null) {
|
||||
const allowedRoles = form.allowedRoles ?? [];
|
||||
const deniedRoles = form.deniedRoles ?? [];
|
||||
|
||||
if (allowedRoles.length === 0 && deniedRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!discordId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userRoles = await getGuildMemberRoles(discordId);
|
||||
const isAllowed =
|
||||
allowedRoles.length === 0 || userRoles.some((role) => allowedRoles.includes(role));
|
||||
const isDenied = deniedRoles.some((role) => userRoles.includes(role));
|
||||
|
||||
return isAllowed && !isDenied;
|
||||
}
|
||||
|
||||
export async function requireFormAccess(formId: string, discordId: string) {
|
||||
const form = await db.query.forms.findFirst({
|
||||
where: (forms, { eq }) => eq(forms.id, formId),
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
throw new Error("Form not found");
|
||||
}
|
||||
|
||||
if (!(await canAccessForm(form, discordId))) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return form;
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
export interface DiscordRole {
|
||||
id: string;
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface DiscordMemberProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
globalName: string | null;
|
||||
avatarUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
accentColor: number | null;
|
||||
roles: DiscordRole[];
|
||||
}
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const rolesCache: { entry: CacheEntry<DiscordRole[]> | null } = { entry: null };
|
||||
const profileCache = new Map<string, CacheEntry<DiscordMemberProfile>>();
|
||||
const CACHE_TTL = 60_000;
|
||||
|
||||
function getCached<T>(entry: CacheEntry<T> | null | undefined): T | null {
|
||||
if (entry && Date.now() < entry.expiresAt) return entry.data;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getGuildRolesInternal(): Promise<DiscordRole[]> {
|
||||
const cached = getCached(rolesCache.entry);
|
||||
if (cached) return cached;
|
||||
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
if (!token || !guildId) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
|
||||
const roles = (await res.json()) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: number;
|
||||
position: number;
|
||||
}>;
|
||||
const result = roles
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
color: role.color,
|
||||
position: role.position,
|
||||
}))
|
||||
.sort((a, b) => b.position - a.position);
|
||||
|
||||
rolesCache.entry = { data: result, expiresAt: Date.now() + CACHE_TTL };
|
||||
return result;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGuildMemberRoles(discordId: string): Promise<string[]> {
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
if (!token || !guildId || !discordId) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
|
||||
const member = (await res.json()) as { roles?: string[] };
|
||||
return member.roles ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDiscordMemberProfile(discordId: string): Promise<DiscordMemberProfile | null> {
|
||||
const cached = getCached(profileCache.get(discordId));
|
||||
if (cached) return cached;
|
||||
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
if (!token || !guildId || !discordId) return null;
|
||||
|
||||
try {
|
||||
const [memberRes, allRoles] = await Promise.all([
|
||||
fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}),
|
||||
getGuildRolesInternal(),
|
||||
]);
|
||||
|
||||
if (!memberRes.ok) return null;
|
||||
const member = (await memberRes.json()) as {
|
||||
roles?: string[];
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
global_name?: string | null;
|
||||
avatar?: string | null;
|
||||
banner?: string | null;
|
||||
accent_color?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
const roles = (member.roles ?? [])
|
||||
.map((id) => allRoles.find((role) => role.id === id))
|
||||
.filter((role): role is DiscordRole => Boolean(role))
|
||||
.sort((a, b) => b.position - a.position);
|
||||
|
||||
const { user } = member;
|
||||
const avatarUrl = user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith("a_") ? "gif" : "png"}?size=128`
|
||||
: null;
|
||||
const bannerUrl = user.banner
|
||||
? `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${user.banner.startsWith("a_") ? "gif" : "png"}?size=512`
|
||||
: null;
|
||||
|
||||
const result = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.global_name ?? null,
|
||||
avatarUrl,
|
||||
bannerUrl,
|
||||
accentColor: user.accent_color ?? null,
|
||||
roles,
|
||||
};
|
||||
|
||||
profileCache.set(discordId, { data: result, expiresAt: Date.now() + CACHE_TTL });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch member profile", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Bun exposes RedisClient at runtime.
|
||||
import { RedisClient } from "bun";
|
||||
|
||||
const globalForRedis = global as unknown as { redis: RedisClient };
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const ALLOWED_TAGS = new Set([
|
||||
"a",
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"i",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"s",
|
||||
"strong",
|
||||
"strike",
|
||||
"u",
|
||||
"ul",
|
||||
]);
|
||||
|
||||
const BLOCKED_TAGS = /<(script|style|iframe|object|embed|svg|math|template)[\s\S]*?<\/\1>/gi;
|
||||
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;
|
||||
const ATTR_RE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g;
|
||||
|
||||
export function sanitizeUrl(url: string) {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed, "https://erika.sudloh.com");
|
||||
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "mailto:") {
|
||||
return trimmed;
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string) {
|
||||
return html.replace(BLOCKED_TAGS, "").replace(TAG_RE, (tag, rawName: string, rawAttrs: string) => {
|
||||
const name = rawName.toLowerCase();
|
||||
if (!ALLOWED_TAGS.has(name)) return "";
|
||||
|
||||
if (tag.startsWith("</")) {
|
||||
return `</${name}>`;
|
||||
}
|
||||
|
||||
if (name !== "a") {
|
||||
return `<${name}>`;
|
||||
}
|
||||
|
||||
let href = "";
|
||||
for (const match of rawAttrs.matchAll(ATTR_RE)) {
|
||||
if (match[1].toLowerCase() === "href") {
|
||||
href = sanitizeUrl(match[2].replace(/^["']|["']$/g, ""));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return href
|
||||
? `<a href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer">`
|
||||
: "<a>";
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user