anti-brigading system - detection engine, phantom voting, ALTCHA adaptive difficulty, honeypot fields, admin security dashboard, auto-learning
This commit is contained in:
@@ -34,6 +34,10 @@ model Board {
|
||||
rssFeedCount Int @default(50)
|
||||
staleDays Int @default(0)
|
||||
position Int @default(0)
|
||||
sensitivityLevel String @default("normal")
|
||||
velocityThreshold Int?
|
||||
quarantined Boolean @default(false)
|
||||
requireVoteVerification Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -43,6 +47,8 @@ model Board {
|
||||
statusConfig BoardStatus[]
|
||||
templates BoardTemplate[]
|
||||
changelogEntries ChangelogEntry[]
|
||||
baseline BoardBaseline?
|
||||
brigadePatterns BrigadePattern[]
|
||||
}
|
||||
|
||||
model BoardStatus {
|
||||
@@ -69,6 +75,12 @@ model User {
|
||||
displayName String?
|
||||
avatarPath String?
|
||||
darkMode String @default("system")
|
||||
firstActionType String?
|
||||
firstActionAt DateTime?
|
||||
actionDiversityScore Float @default(0)
|
||||
voteTimingStdDev Float?
|
||||
boardInteractionCount Int @default(0)
|
||||
flagCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -128,6 +140,8 @@ model Post {
|
||||
isEditLocked Boolean @default(false)
|
||||
isThreadLocked Boolean @default(false)
|
||||
isVotingLocked Boolean @default(false)
|
||||
frozenAt DateTime?
|
||||
votesVisibleAfter DateTime?
|
||||
onBehalfOf String?
|
||||
boardId String
|
||||
authorId String
|
||||
@@ -148,6 +162,7 @@ model Post {
|
||||
tags PostTag[]
|
||||
attachments Attachment[]
|
||||
editHistory EditHistory[]
|
||||
voteSnapshots PostVoteSnapshot[]
|
||||
|
||||
@@index([boardId, status])
|
||||
}
|
||||
@@ -206,6 +221,9 @@ model Vote {
|
||||
id String @id @default(cuid())
|
||||
weight Int @default(1)
|
||||
importance String?
|
||||
phantom Boolean @default(false)
|
||||
voterIp String?
|
||||
referrer String?
|
||||
postId String
|
||||
voterId String
|
||||
budgetPeriod String
|
||||
@@ -493,3 +511,60 @@ model PluginData {
|
||||
@@unique([pluginId, key])
|
||||
@@index([pluginId])
|
||||
}
|
||||
|
||||
model AnomalyEvent {
|
||||
id String @id @default(cuid())
|
||||
type String
|
||||
severity String
|
||||
targetType String
|
||||
targetId String
|
||||
boardId String?
|
||||
metadata Json @default("{}")
|
||||
status String @default("pending")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([targetType, targetId])
|
||||
@@index([boardId, createdAt])
|
||||
}
|
||||
|
||||
model BoardBaseline {
|
||||
id String @id @default(cuid())
|
||||
boardId String @unique
|
||||
avgVotesPerHour Float @default(0)
|
||||
avgPostsPerDay Float @default(0)
|
||||
avgReactionsPerHour Float @default(0)
|
||||
peakHourOfDay Int @default(12)
|
||||
peakDayOfWeek Int @default(2)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model PostVoteSnapshot {
|
||||
id String @id @default(cuid())
|
||||
postId String
|
||||
voteCount Int
|
||||
snapshotAt DateTime @default(now())
|
||||
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([postId, snapshotAt])
|
||||
}
|
||||
|
||||
model BrigadePattern {
|
||||
id String @id @default(cuid())
|
||||
boardId String?
|
||||
features Json
|
||||
matchCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model AdminWebhookConfig {
|
||||
id String @id @default(cuid())
|
||||
url String
|
||||
events String[]
|
||||
active Boolean @default(true)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,26 @@ import { cleanExpiredChallenges } from "../routes/passkey.js";
|
||||
import { cleanupExpiredTokens } from "../lib/token-blocklist.js";
|
||||
import { getPluginCronJobs } from "../plugins/loader.js";
|
||||
import { cleanupViews } from "../lib/view-tracker.js";
|
||||
import {
|
||||
takeVoteSnapshots,
|
||||
scanVoteVelocity,
|
||||
scanPostVelocity,
|
||||
scanIdentityClusters,
|
||||
scanInflectionPoints,
|
||||
scanCohortArrivals,
|
||||
scanReferrerConcentration,
|
||||
scanPostSimilarity,
|
||||
recalculateBoardBaselines,
|
||||
scanVoterOverlap,
|
||||
scanOutboundLinks,
|
||||
scanCommentVoteRatio,
|
||||
scanVoteDistribution,
|
||||
compareSeasonalBaseline,
|
||||
pruneOldSnapshots,
|
||||
pruneOldAnomalyEvents,
|
||||
scanOffHoursActivity,
|
||||
buildVoterGraph,
|
||||
} from "../services/detection-engine.js";
|
||||
|
||||
export function startCronJobs() {
|
||||
// prune old activity events - daily at 3am
|
||||
@@ -108,6 +128,53 @@ export function startCronJobs() {
|
||||
// clean expired view-tracker entries - every 5 minutes
|
||||
cron.schedule("*/5 * * * *", () => { cleanupViews(); });
|
||||
|
||||
// --- anti-brigading detection jobs ---
|
||||
|
||||
// every 5 min: snapshots + velocity scans
|
||||
cron.schedule("*/5 * * * *", async () => {
|
||||
await takeVoteSnapshots().catch(() => {});
|
||||
await scanVoteVelocity().catch(() => {});
|
||||
await scanPostVelocity().catch(() => {});
|
||||
});
|
||||
|
||||
// every 10 min: identity clustering
|
||||
cron.schedule("*/10 * * * *", async () => {
|
||||
await scanIdentityClusters().catch(() => {});
|
||||
});
|
||||
|
||||
// every 15 min: inflection point detection
|
||||
cron.schedule("*/15 * * * *", async () => {
|
||||
await scanInflectionPoints().catch(() => {});
|
||||
});
|
||||
|
||||
// every 30 min: cohort arrivals, referrer analysis, post similarity
|
||||
cron.schedule("*/30 * * * *", async () => {
|
||||
await scanCohortArrivals().catch(() => {});
|
||||
await scanReferrerConcentration().catch(() => {});
|
||||
await scanPostSimilarity().catch(() => {});
|
||||
});
|
||||
|
||||
// hourly: baselines, overlap, links, comment ratio, off-hours, voter graph
|
||||
cron.schedule("0 * * * *", async () => {
|
||||
await recalculateBoardBaselines().catch(() => {});
|
||||
await scanVoterOverlap().catch(() => {});
|
||||
await scanOutboundLinks().catch(() => {});
|
||||
await scanCommentVoteRatio().catch(() => {});
|
||||
await scanOffHoursActivity().catch(() => {});
|
||||
const boards = await prisma.board.findMany({ select: { id: true } }).catch(() => [] as { id: string }[]);
|
||||
for (const b of boards) {
|
||||
await buildVoterGraph(b.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// daily at 3am: distribution, seasonal, pruning
|
||||
cron.schedule("0 3 * * *", async () => {
|
||||
await scanVoteDistribution().catch(() => {});
|
||||
await compareSeasonalBaseline().catch(() => {});
|
||||
await pruneOldSnapshots().catch(() => {});
|
||||
await pruneOldAnomalyEvents().catch(() => {});
|
||||
});
|
||||
|
||||
// register plugin-provided cron jobs (min interval: every minute, reject sub-minute)
|
||||
for (const job of getPluginCronJobs()) {
|
||||
if (!cron.validate(job.schedule)) {
|
||||
|
||||
509
packages/api/src/routes/admin/security.ts
Normal file
509
packages/api/src/routes/admin/security.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { buildVoterGraph } from "../../services/detection-engine.js";
|
||||
|
||||
const ANOMALY_TYPE_LABELS: Record<string, string> = {
|
||||
vote_velocity: "Vote velocity spike",
|
||||
post_velocity: "Post creation spike",
|
||||
identity_cluster: "Identity cluster",
|
||||
cohort_arrival: "Coordinated arrival",
|
||||
voter_overlap: "Voter overlap",
|
||||
vote_distribution_outlier: "Vote distribution outlier",
|
||||
inflection_point: "Vote inflection point",
|
||||
referrer_concentration: "Referrer concentration",
|
||||
post_similarity: "Similar post titles",
|
||||
outbound_link_cluster: "Outbound link cluster",
|
||||
reaction_velocity: "Reaction velocity spike",
|
||||
comment_vote_ratio: "Low comment-to-vote ratio",
|
||||
off_hours_activity: "Off-hours activity",
|
||||
voter_network_cluster: "Voter network cluster",
|
||||
seasonal_deviation: "Seasonal deviation",
|
||||
post_substantially_edited: "Post substantially edited after votes",
|
||||
audit_cleanup: "Cleanup action",
|
||||
};
|
||||
|
||||
const alertsQuery = z.object({
|
||||
status: z.enum(["pending", "confirmed", "dismissed"]).optional(),
|
||||
severity: z.string().max(20).optional(),
|
||||
type: z.string().max(50).optional(),
|
||||
boardId: z.string().max(50).optional(),
|
||||
page: z.coerce.number().int().min(1).max(500).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
});
|
||||
|
||||
const cleanupBody = z.object({
|
||||
anomalyEventId: z.string().min(1),
|
||||
actions: z.array(z.enum(["remove_phantom_votes", "remove_flagged_votes", "recalculate_counts"])).min(1),
|
||||
});
|
||||
|
||||
const boardSecurityBody = z.object({
|
||||
sensitivityLevel: z.enum(["low", "normal", "high", "paranoid"]).optional(),
|
||||
velocityThreshold: z.number().int().min(0).nullable().optional(),
|
||||
quarantined: z.boolean().optional(),
|
||||
requireVoteVerification: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const webhookBody = z.object({
|
||||
url: z.string().url().max(500),
|
||||
events: z.array(z.string().max(50)).min(1),
|
||||
active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const webhookUpdateBody = z.object({
|
||||
url: z.string().url().max(500).optional(),
|
||||
events: z.array(z.string().max(50)).min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export default async function adminSecurityRoutes(app: FastifyInstance) {
|
||||
|
||||
// 1. List anomaly events
|
||||
app.get<{ Querystring: Record<string, string> }>(
|
||||
"/admin/security/alerts",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const q = alertsQuery.safeParse(req.query);
|
||||
if (!q.success) {
|
||||
reply.status(400).send({ error: "Invalid query parameters" });
|
||||
return;
|
||||
}
|
||||
const { status, severity, type, boardId, page, limit } = q.data;
|
||||
|
||||
const where: Prisma.AnomalyEventWhereInput = {};
|
||||
if (status) where.status = status;
|
||||
if (severity) where.severity = severity;
|
||||
if (type) where.type = type;
|
||||
if (boardId) where.boardId = boardId;
|
||||
|
||||
const [events, total] = await Promise.all([
|
||||
prisma.anomalyEvent.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: Math.min((page - 1) * limit, 50000),
|
||||
take: limit,
|
||||
}),
|
||||
prisma.anomalyEvent.count({ where }),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
alerts: events.map((e) => ({
|
||||
...e,
|
||||
label: ANOMALY_TYPE_LABELS[e.type] || e.type,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pages: Math.ceil(total / limit),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 2. Get single anomaly event
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/admin/security/alerts/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||
if (!event) {
|
||||
reply.status(404).send({ error: "Alert not found" });
|
||||
return;
|
||||
}
|
||||
reply.send({
|
||||
...event,
|
||||
label: ANOMALY_TYPE_LABELS[event.type] || event.type,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 3. Confirm anomaly
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/security/alerts/:id/confirm",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||
if (!event) {
|
||||
reply.status(404).send({ error: "Alert not found" });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.anomalyEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { status: "confirmed" },
|
||||
});
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// 4. Dismiss anomaly
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/security/alerts/:id/dismiss",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||
if (!event) {
|
||||
reply.status(404).send({ error: "Alert not found" });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.anomalyEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { status: "dismissed" },
|
||||
});
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// 5. One-click cleanup
|
||||
app.post<{ Body: z.infer<typeof cleanupBody> }>(
|
||||
"/admin/security/cleanup",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = cleanupBody.parse(req.body);
|
||||
|
||||
const event = await prisma.anomalyEvent.findUnique({ where: { id: body.anomalyEventId } });
|
||||
if (!event) {
|
||||
reply.status(404).send({ error: "Anomaly event not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
const results: Record<string, unknown> = {};
|
||||
|
||||
// figure out which posts are affected
|
||||
const flaggedPosts: string[] = (meta.flaggedPosts as string[]) || [];
|
||||
const postA = meta.postA as string | undefined;
|
||||
const postB = meta.postB as string | undefined;
|
||||
let targetPostId = event.targetType === "post" ? event.targetId : null;
|
||||
const affectedPostIds = [
|
||||
...flaggedPosts,
|
||||
...(postA ? [postA] : []),
|
||||
...(postB ? [postB] : []),
|
||||
...(targetPostId ? [targetPostId] : []),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const action of body.actions) {
|
||||
if (action === "remove_phantom_votes") {
|
||||
if (affectedPostIds.length === 0) {
|
||||
results.remove_phantom_votes = { deleted: 0 };
|
||||
continue;
|
||||
}
|
||||
const deleted = await prisma.vote.deleteMany({
|
||||
where: { postId: { in: affectedPostIds }, phantom: true },
|
||||
});
|
||||
results.remove_phantom_votes = { deleted: deleted.count };
|
||||
}
|
||||
|
||||
if (action === "remove_flagged_votes") {
|
||||
const identities = (meta.identities as string[]) || [];
|
||||
if (identities.length === 0) {
|
||||
results.remove_flagged_votes = { deleted: 0 };
|
||||
continue;
|
||||
}
|
||||
const deleted = await prisma.vote.deleteMany({
|
||||
where: { voterId: { in: identities } },
|
||||
});
|
||||
results.remove_flagged_votes = { deleted: deleted.count };
|
||||
}
|
||||
|
||||
if (action === "recalculate_counts") {
|
||||
const postIds = affectedPostIds.length > 0
|
||||
? affectedPostIds
|
||||
: (await prisma.post.findMany({ where: { boardId: event.boardId ?? undefined }, select: { id: true }, take: 200 })).map((p) => p.id);
|
||||
|
||||
let recalculated = 0;
|
||||
for (const pid of postIds) {
|
||||
const sum = await prisma.vote.aggregate({
|
||||
where: { postId: pid, phantom: false },
|
||||
_sum: { weight: true },
|
||||
});
|
||||
await prisma.post.update({
|
||||
where: { id: pid },
|
||||
data: { voteCount: sum._sum.weight ?? 0 },
|
||||
});
|
||||
recalculated++;
|
||||
}
|
||||
results.recalculate_counts = { recalculated };
|
||||
}
|
||||
}
|
||||
|
||||
// create audit log entry
|
||||
await prisma.anomalyEvent.create({
|
||||
data: {
|
||||
type: "audit_cleanup",
|
||||
severity: "info",
|
||||
targetType: event.targetType,
|
||||
targetId: event.targetId,
|
||||
boardId: event.boardId,
|
||||
status: "confirmed",
|
||||
metadata: {
|
||||
sourceEventId: event.id,
|
||||
adminId: req.adminId,
|
||||
actions: body.actions,
|
||||
results,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, anomalyEventId: event.id, actions: body.actions }, "security cleanup");
|
||||
reply.send({ ok: true, results });
|
||||
}
|
||||
);
|
||||
|
||||
// 6. Audit log
|
||||
app.get(
|
||||
"/admin/security/audit-log",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const entries = await prisma.anomalyEvent.findMany({
|
||||
where: { type: "audit_cleanup" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
reply.send({
|
||||
entries: entries.map((e) => {
|
||||
const meta = (e.metadata ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: e.id,
|
||||
adminId: meta.adminId || null,
|
||||
action: (meta.actions as string[])?.join(", ") || "unknown",
|
||||
metadata: meta,
|
||||
undone: meta.undone === true,
|
||||
createdAt: e.createdAt,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 7. Undo cleanup
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/admin/security/audit-log/:id/undo",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const entry = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry || entry.type !== "audit_cleanup") {
|
||||
reply.status(404).send({ error: "Audit entry not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
|
||||
if (meta.undone) {
|
||||
reply.status(409).send({ error: "Already undone" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.anomalyEvent.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
metadata: { ...meta, undone: true, undoneBy: req.adminId, undoneAt: new Date().toISOString() } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, auditEntryId: entry.id }, "cleanup marked as undone");
|
||||
reply.send({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
// 8. Freeze/unfreeze post
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/posts/:id/freeze",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: { frozenAt: post.frozenAt ? null : new Date() },
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, postId: post.id, frozen: !!updated.frozenAt }, "post freeze toggled");
|
||||
reply.send({ id: updated.id, frozenAt: updated.frozenAt });
|
||||
}
|
||||
);
|
||||
|
||||
// 9. Board security settings
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof boardSecurityBody> }>(
|
||||
"/admin/boards/:id/security",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = boardSecurityBody.parse(req.body);
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.sensitivityLevel !== undefined) data.sensitivityLevel = body.sensitivityLevel;
|
||||
if (body.velocityThreshold !== undefined) data.velocityThreshold = body.velocityThreshold;
|
||||
if (body.quarantined !== undefined) data.quarantined = body.quarantined;
|
||||
if (body.requireVoteVerification !== undefined) data.requireVoteVerification = body.requireVoteVerification;
|
||||
|
||||
const updated = await prisma.board.update({
|
||||
where: { id: board.id },
|
||||
data,
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, boardId: board.id }, "board security settings updated");
|
||||
reply.send({
|
||||
id: updated.id,
|
||||
sensitivityLevel: updated.sensitivityLevel,
|
||||
velocityThreshold: updated.velocityThreshold,
|
||||
quarantined: updated.quarantined,
|
||||
requireVoteVerification: updated.requireVoteVerification,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// 10. Admin notification webhook config - CRUD
|
||||
app.get(
|
||||
"/admin/security/webhooks",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const webhooks = await prisma.adminWebhookConfig.findMany();
|
||||
reply.send({ webhooks });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof webhookBody> }>(
|
||||
"/admin/security/webhooks",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = webhookBody.parse(req.body);
|
||||
const wh = await prisma.adminWebhookConfig.create({
|
||||
data: { url: body.url, events: body.events, active: body.active },
|
||||
});
|
||||
reply.status(201).send(wh);
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof webhookUpdateBody> }>(
|
||||
"/admin/security/webhooks/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const wh = await prisma.adminWebhookConfig.findUnique({ where: { id: req.params.id } });
|
||||
if (!wh) {
|
||||
reply.status(404).send({ error: "Webhook not found" });
|
||||
return;
|
||||
}
|
||||
const body = webhookUpdateBody.parse(req.body);
|
||||
const updated = await prisma.adminWebhookConfig.update({
|
||||
where: { id: wh.id },
|
||||
data: {
|
||||
...(body.url !== undefined && { url: body.url }),
|
||||
...(body.events !== undefined && { events: body.events }),
|
||||
...(body.active !== undefined && { active: body.active }),
|
||||
},
|
||||
});
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/security/webhooks/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const wh = await prisma.adminWebhookConfig.findUnique({ where: { id: req.params.id } });
|
||||
if (!wh) {
|
||||
reply.status(404).send({ error: "Webhook not found" });
|
||||
return;
|
||||
}
|
||||
await prisma.adminWebhookConfig.delete({ where: { id: wh.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// 11. Mark as brigaded
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/admin/security/alerts/:id/mark-brigaded",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
|
||||
if (!event) {
|
||||
reply.status(404).send({ error: "Alert not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = (event.metadata ?? {}) as Record<string, unknown>;
|
||||
|
||||
// extract pattern features from the anomaly
|
||||
const features: Record<string, unknown> = {
|
||||
anomalyType: event.type,
|
||||
severity: event.severity,
|
||||
};
|
||||
if (meta.identities) features.identityCount = (meta.identities as string[]).length;
|
||||
if (meta.referrer) features.referrer = meta.referrer;
|
||||
if (meta.ratio) features.velocityRatio = meta.ratio;
|
||||
if (meta.coefficient) features.overlapCoefficient = meta.coefficient;
|
||||
if (meta.phrase) features.phrase = meta.phrase;
|
||||
if (meta.url) features.url = meta.url;
|
||||
|
||||
const pattern = await prisma.brigadePattern.create({
|
||||
data: {
|
||||
boardId: event.boardId,
|
||||
features: features as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
// also confirm the anomaly
|
||||
await prisma.anomalyEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { status: "confirmed" },
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, patternId: pattern.id, anomalyEventId: event.id }, "brigade pattern recorded");
|
||||
reply.status(201).send(pattern);
|
||||
}
|
||||
);
|
||||
|
||||
// 12. Network graph data
|
||||
app.get<{ Params: { boardId: string } }>(
|
||||
"/admin/security/graph/:boardId",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// check for cached graph data from recent anomaly events
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const cached = await prisma.anomalyEvent.findFirst({
|
||||
where: {
|
||||
type: "voter_network_cluster",
|
||||
boardId: board.id,
|
||||
createdAt: { gt: oneDayAgo },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
reply.send({ source: "cached", data: cached.metadata, createdAt: cached.createdAt });
|
||||
return;
|
||||
}
|
||||
|
||||
// build fresh graph
|
||||
await buildVoterGraph(board.id);
|
||||
|
||||
const fresh = await prisma.anomalyEvent.findFirst({
|
||||
where: {
|
||||
type: "voter_network_cluster",
|
||||
boardId: board.id,
|
||||
createdAt: { gt: new Date(Date.now() - 60 * 1000) },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (fresh) {
|
||||
reply.send({ source: "fresh", data: fresh.metadata, createdAt: fresh.createdAt });
|
||||
} else {
|
||||
reply.send({ source: "fresh", data: { clusterSize: 0, identities: [], totalClusters: 0 }, createdAt: new Date() });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ const createCommentSchema = z.object({
|
||||
altcha: z.string().optional(),
|
||||
replyToId: z.string().max(30).optional(),
|
||||
attachmentIds: z.array(z.string()).max(10).optional(),
|
||||
website: z.string().optional(),
|
||||
_ts: z.number().optional(),
|
||||
});
|
||||
|
||||
const updateCommentSchema = z.object({
|
||||
@@ -127,16 +129,32 @@ export default async function commentRoutes(app: FastifyInstance) {
|
||||
reply.status(403).send({ error: "Thread is locked" });
|
||||
return;
|
||||
}
|
||||
if (post.frozenAt) {
|
||||
reply.status(403).send({ error: "Post is frozen" });
|
||||
return;
|
||||
}
|
||||
|
||||
// honeypot check - bots fill this hidden field
|
||||
if ((req.body as any)?.website) {
|
||||
reply.status(201).send({ id: "ok", body: (req.body as any).body || "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = createCommentSchema.parse(req.body);
|
||||
|
||||
// time-to-submit check - flag users who submit too fast
|
||||
if (body._ts && Date.now() - body._ts < 5000) {
|
||||
await prisma.user.update({ where: { id: req.user!.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
|
||||
}
|
||||
|
||||
// admins skip ALTCHA, regular users must provide it
|
||||
if (!req.adminId) {
|
||||
if (!body.altcha) {
|
||||
reply.status(400).send({ error: "Challenge response required" });
|
||||
return;
|
||||
}
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||
const valid = await verifyChallenge(body.altcha, sid);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
|
||||
@@ -79,7 +79,8 @@ export default async function identityRoutes(app: FastifyInstance) {
|
||||
reply.status(400).send({ error: "Verification required for display name changes" });
|
||||
return;
|
||||
}
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||
const valid = await verifyChallenge(body.altcha, sid);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
@@ -224,7 +225,8 @@ export default async function identityRoutes(app: FastifyInstance) {
|
||||
reply.status(400).send({ error: "Verification required to delete account" });
|
||||
return;
|
||||
}
|
||||
const challengeValid = await verifyChallenge(altcha);
|
||||
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||
const challengeValid = await verifyChallenge(altcha, sid);
|
||||
if (!challengeValid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PostType, Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { createHash } from "node:crypto";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import prisma from "../lib/prisma.js";
|
||||
@@ -62,6 +63,9 @@ const createPostSchema = z.object({
|
||||
templateId: z.string().optional(),
|
||||
attachmentIds: z.array(z.string()).max(10).optional(),
|
||||
altcha: z.string(),
|
||||
website: z.string().optional(),
|
||||
_ts: z.number().optional(),
|
||||
_pasted: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.templateId) {
|
||||
// template posts use flexible description keys
|
||||
@@ -226,27 +230,31 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
: null;
|
||||
|
||||
reply.send({
|
||||
posts: posts.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
statusReason: p.statusReason,
|
||||
category: p.category,
|
||||
voteCount: p.voteCount,
|
||||
viewCount: p.viewCount,
|
||||
isPinned: p.isPinned,
|
||||
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
|
||||
commentCount: p._count.comments,
|
||||
author: cleanAuthor(p.author),
|
||||
tags: p.tags.map((pt) => pt.tag),
|
||||
onBehalfOf: p.onBehalfOf,
|
||||
voted: userVotes.has(p.id),
|
||||
voteWeight: userVotes.get(p.id) ?? 0,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
})),
|
||||
posts: posts.map((p) => {
|
||||
const isVoteHidden = p.votesVisibleAfter && new Date(p.votesVisibleAfter) > new Date();
|
||||
return {
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
statusReason: p.statusReason,
|
||||
category: p.category,
|
||||
voteCount: isVoteHidden ? null : p.voteCount,
|
||||
votingInProgress: isVoteHidden ? true : undefined,
|
||||
viewCount: p.viewCount,
|
||||
isPinned: p.isPinned,
|
||||
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
|
||||
commentCount: p._count.comments,
|
||||
author: cleanAuthor(p.author),
|
||||
tags: p.tags.map((pt) => pt.tag),
|
||||
onBehalfOf: p.onBehalfOf,
|
||||
voted: userVotes.has(p.id),
|
||||
voteWeight: userVotes.get(p.id) ?? 0,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
page: q.page,
|
||||
pages: Math.ceil(total / q.limit),
|
||||
@@ -257,7 +265,7 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id",
|
||||
{ preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
@@ -327,6 +335,9 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const isVoteHidden = post.votesVisibleAfter && new Date(post.votesVisibleAfter) > new Date();
|
||||
const showRealCount = req.user?.id === post.authorId || req.adminId;
|
||||
|
||||
reply.send({
|
||||
id: post.id,
|
||||
type: post.type,
|
||||
@@ -335,7 +346,8 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
status: post.status,
|
||||
statusReason: post.statusReason,
|
||||
category: post.category,
|
||||
voteCount: post.voteCount,
|
||||
voteCount: (isVoteHidden && !showRealCount) ? null : post.voteCount,
|
||||
votingInProgress: (isVoteHidden && !showRealCount) ? true : undefined,
|
||||
viewCount: post.viewCount,
|
||||
isPinned: post.isPinned,
|
||||
isEditLocked: post.isEditLocked,
|
||||
@@ -472,14 +484,34 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// honeypot check - bots fill this hidden field
|
||||
if ((req.body as any)?.website) {
|
||||
reply.status(201).send({ id: "ok", title: (req.body as any).title || "" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = createPostSchema.parse(req.body);
|
||||
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||
const valid = await verifyChallenge(body.altcha, sessionId);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
}
|
||||
|
||||
// time-to-submit check - flag users who submit too fast
|
||||
if (body._ts && Date.now() - body._ts < 5000) {
|
||||
await prisma.user.update({ where: { id: req.user!.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
|
||||
}
|
||||
|
||||
// clipboard paste detection - flag new identities who paste content
|
||||
if (body._pasted && req.user) {
|
||||
const pUser = await prisma.user.findUnique({ where: { id: req.user.id }, select: { createdAt: true } });
|
||||
if (pUser && Date.now() - pUser.createdAt.getTime() < 10 * 60 * 1000) {
|
||||
await prisma.user.update({ where: { id: req.user.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const normalizedTitle = cleanTitle.trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
|
||||
@@ -501,6 +533,22 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// cross-user content hash deduplication
|
||||
const crossUserPosts = await prisma.post.findMany({
|
||||
where: { boardId: board.id, createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) } },
|
||||
select: { title: true, description: true, authorId: true },
|
||||
take: 50,
|
||||
});
|
||||
const myHash = createHash("sha256").update(body.title.trim().toLowerCase() + JSON.stringify(body.description)).digest("hex");
|
||||
for (const rp of crossUserPosts) {
|
||||
if (rp.authorId === req.user!.id) continue;
|
||||
const rpHash = createHash("sha256").update((rp.title as string).trim().toLowerCase() + JSON.stringify(rp.description)).digest("hex");
|
||||
if (rpHash === myHash) {
|
||||
reply.status(409).send({ error: "This has already been submitted" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.templateId) {
|
||||
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: body.templateId } });
|
||||
if (!tmpl || tmpl.boardId !== board.id) {
|
||||
@@ -518,6 +566,7 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
templateId: body.templateId,
|
||||
boardId: board.id,
|
||||
authorId: req.user!.id,
|
||||
votesVisibleAfter: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -603,7 +652,8 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
reply.status(400).send({ error: "Challenge response required" });
|
||||
return;
|
||||
}
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||
const valid = await verifyChallenge(body.altcha, sid);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
@@ -665,6 +715,48 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
// check edit distance after save - flag substantial edits on voted posts
|
||||
if ((titleChanged || descChanged) && post.voteCount > 5) {
|
||||
const oldContent = JSON.stringify({ title: post.title, description: post.description });
|
||||
const newContent = JSON.stringify({ title: updated.title, description: updated.description });
|
||||
const maxLen = Math.max(oldContent.length, newContent.length);
|
||||
if (maxLen > 0) {
|
||||
let diffs = 0;
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
if (oldContent[i] !== newContent[i]) diffs++;
|
||||
}
|
||||
const changeRatio = diffs / maxLen;
|
||||
|
||||
if (changeRatio > 0.7) {
|
||||
const { createAnomalyEvent } = await import("../services/detection-engine.js");
|
||||
createAnomalyEvent({
|
||||
type: "post_substantially_edited",
|
||||
severity: "medium",
|
||||
targetType: "post",
|
||||
targetId: post.id,
|
||||
boardId: post.boardId,
|
||||
metadata: { changeRatio, voteCount: post.voteCount, editedBy: req.user!.id },
|
||||
}).catch(() => {});
|
||||
|
||||
const voters = await prisma.vote.findMany({
|
||||
where: { postId: post.id },
|
||||
select: { voterId: true },
|
||||
});
|
||||
if (voters.length > 0) {
|
||||
await prisma.notification.createMany({
|
||||
data: voters.slice(0, 100).map((v) => ({
|
||||
type: "post_edited",
|
||||
title: "Post you voted on was edited",
|
||||
body: `"${post.title}" was substantially modified after receiving votes`,
|
||||
postId: post.id,
|
||||
userId: v.voterId,
|
||||
})),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reply.send({
|
||||
id: updated.id, type: updated.type, title: updated.title,
|
||||
description: updated.description, status: updated.status,
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { generateChallenge } from "../services/altcha.js";
|
||||
import { generateChallenge, getAdaptiveDifficulty } from "../services/altcha.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const challengeQuery = z.object({
|
||||
difficulty: z.enum(["normal", "light"]).default("normal"),
|
||||
boardId: z.string().max(30).optional(),
|
||||
postId: z.string().max(30).optional(),
|
||||
});
|
||||
|
||||
export default async function privacyRoutes(app: FastifyInstance) {
|
||||
app.get("/altcha/challenge", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const { difficulty } = challengeQuery.parse(req.query);
|
||||
const challenge = await generateChallenge(difficulty);
|
||||
app.get("/altcha/challenge", { preHandler: [app.optionalUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const { difficulty, boardId, postId } = challengeQuery.parse(req.query);
|
||||
const userId = req.user?.id;
|
||||
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||
|
||||
const adaptiveMax = await getAdaptiveDifficulty({ postId, boardId, userId });
|
||||
// use the higher of adaptive or difficulty-based default
|
||||
const difficultyMax = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
|
||||
const maxNumber = Math.max(adaptiveMax, difficultyMax);
|
||||
|
||||
const challenge = await generateChallenge({ maxNumber, sessionId });
|
||||
reply.send(challenge);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { checkReactionVelocity } from "../services/detection-engine.js";
|
||||
|
||||
const reactionBody = z.object({
|
||||
emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
|
||||
@@ -61,6 +62,7 @@ export default async function reactionRoutes(app: FastifyInstance) {
|
||||
userId: req.user!.id,
|
||||
},
|
||||
});
|
||||
checkReactionVelocity(comment.id).catch(() => {});
|
||||
reply.send({ toggled: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ export default async function recoveryRoutes(app: FastifyInstance) {
|
||||
async (req, reply) => {
|
||||
const body = recoverBody.parse(req.body);
|
||||
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||
const valid = await verifyChallenge(body.altcha, sessionId);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { createHash } from "node:crypto";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { verifyChallenge } from "../services/altcha.js";
|
||||
import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js";
|
||||
import { recordAction, recordVoteTimingUpdate } from "../services/identity-signals.js";
|
||||
|
||||
const voteBody = z.object({
|
||||
altcha: z.string(),
|
||||
@@ -44,7 +46,8 @@ export default async function voteRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const body = voteBody.parse(req.body);
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
|
||||
const valid = await verifyChallenge(body.altcha, sessionId);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
@@ -52,6 +55,14 @@ export default async function voteRoutes(app: FastifyInstance) {
|
||||
|
||||
const period = getCurrentPeriod(board.voteBudgetReset);
|
||||
|
||||
// phantom vote detection - hash IP, check rate
|
||||
const ipHash = createHash("sha256").update(req.ip || "unknown").digest("hex").slice(0, 16);
|
||||
const ipVoteCount = await prisma.vote.count({
|
||||
where: { postId: post.id, voterIp: ipHash, createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) }, phantom: false },
|
||||
});
|
||||
const isPhantom = ipVoteCount >= 3 || !!post.frozenAt;
|
||||
const refHeader = ((req.headers.referer || req.headers.referrer || "") as string).slice(0, 200) || null;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.vote.findUnique({
|
||||
@@ -75,14 +86,20 @@ export default async function voteRoutes(app: FastifyInstance) {
|
||||
postId: post.id,
|
||||
voterId: req.user!.id,
|
||||
budgetPeriod: period,
|
||||
phantom: isPhantom,
|
||||
voterIp: ipHash,
|
||||
referrer: refHeader,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.post.update({
|
||||
where: { id: post.id },
|
||||
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
|
||||
});
|
||||
// phantom votes don't affect the visible count
|
||||
if (!isPhantom) {
|
||||
await tx.post.update({
|
||||
where: { id: post.id },
|
||||
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
|
||||
});
|
||||
}
|
||||
}, { isolationLevel: "Serializable" });
|
||||
} catch (err: any) {
|
||||
if (err.message === "ALREADY_VOTED") {
|
||||
@@ -109,7 +126,11 @@ export default async function voteRoutes(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
const newCount = post.voteCount + 1;
|
||||
// record identity signals
|
||||
recordAction(req.user!.id, "vote", { boardId: post.boardId }).catch(() => {});
|
||||
recordVoteTimingUpdate(req.user!.id).catch(() => {});
|
||||
|
||||
const newCount = isPhantom ? post.voteCount : post.voteCount + 1;
|
||||
const milestones = [10, 50, 100, 250, 500];
|
||||
if (milestones.includes(newCount)) {
|
||||
await prisma.activityEvent.create({
|
||||
|
||||
@@ -53,6 +53,7 @@ import recoveryRoutes from "./routes/recovery.js";
|
||||
import settingsRoutes from "./routes/admin/settings.js";
|
||||
import adminTeamRoutes from "./routes/admin/team.js";
|
||||
import adminPluginRoutes from "./routes/admin/plugins.js";
|
||||
import adminSecurityRoutes from "./routes/admin/security.js";
|
||||
import pluginApiRoutes from "./routes/plugins-api.js";
|
||||
|
||||
const DEFAULT_OG_DESC = "Self-hosted feedback board. Anonymous by default, no email required. Vote on what matters, comment with markdown, track status changes.";
|
||||
@@ -234,6 +235,7 @@ export async function createServer() {
|
||||
await api.register(settingsRoutes);
|
||||
await api.register(adminTeamRoutes);
|
||||
await api.register(adminPluginRoutes);
|
||||
await api.register(adminSecurityRoutes);
|
||||
await api.register(pluginApiRoutes);
|
||||
}, { prefix: "/api/v1" });
|
||||
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
import { createChallenge, verifySolution } from "altcha-lib";
|
||||
import { createHash } from "node:crypto";
|
||||
import { config } from "../config.js";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { getIdentityRiskLevel } from "./identity-signals.js";
|
||||
|
||||
// replay protection: track consumed challenge hashes (fingerprint -> timestamp)
|
||||
const usedChallenges = new Map<string, number>();
|
||||
interface ChallengeEntry {
|
||||
issuedAt: number;
|
||||
sessionHash: string;
|
||||
}
|
||||
|
||||
// replay protection: track consumed challenge hashes
|
||||
const usedChallenges = new Map<string, ChallengeEntry>();
|
||||
// track issued challenges for solve-time validation
|
||||
const issuedChallenges = new Map<string, ChallengeEntry>();
|
||||
const EXPIRY_MS = 300000;
|
||||
const MIN_SOLVE_MS = 50;
|
||||
|
||||
// clean up expired entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - EXPIRY_MS;
|
||||
for (const [fp, ts] of usedChallenges) {
|
||||
if (ts < cutoff) usedChallenges.delete(fp);
|
||||
for (const [fp, entry] of usedChallenges) {
|
||||
if (entry.issuedAt < cutoff) usedChallenges.delete(fp);
|
||||
}
|
||||
for (const [salt, entry] of issuedChallenges) {
|
||||
if (entry.issuedAt < cutoff) issuedChallenges.delete(salt);
|
||||
}
|
||||
}, EXPIRY_MS);
|
||||
|
||||
@@ -18,17 +31,83 @@ function challengeFingerprint(payload: string): string {
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
export async function generateChallenge(difficulty: "normal" | "light" = "normal") {
|
||||
const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
|
||||
function hashSession(cookieValue: string): string {
|
||||
return createHash("sha256").update(cookieValue).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
export async function getAdaptiveDifficulty(context: {
|
||||
postId?: string;
|
||||
boardId?: string;
|
||||
userId?: string;
|
||||
}): Promise<number> {
|
||||
const base = config.ALTCHA_MAX_NUMBER;
|
||||
let multiplier = 1;
|
||||
|
||||
if (context.boardId) {
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { id: context.boardId },
|
||||
select: { sensitivityLevel: true, requireVoteVerification: true },
|
||||
});
|
||||
if (board?.sensitivityLevel === "high") multiplier *= 3;
|
||||
|
||||
// when vote verification is enabled, new identities get harder challenges
|
||||
if (board?.requireVoteVerification && context.userId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.userId },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
if (user && Date.now() - user.createdAt.getTime() < 60 * 60 * 1000) {
|
||||
multiplier *= 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context.postId) {
|
||||
const recentAnomaly = await prisma.anomalyEvent.findFirst({
|
||||
where: {
|
||||
targetId: context.postId,
|
||||
type: "vote_velocity",
|
||||
status: "pending",
|
||||
createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) },
|
||||
},
|
||||
});
|
||||
if (recentAnomaly) multiplier *= 5;
|
||||
}
|
||||
|
||||
if (context.userId) {
|
||||
const risk = await getIdentityRiskLevel(context.userId);
|
||||
if (risk === "high") multiplier *= 3;
|
||||
else if (risk === "medium") multiplier *= 1.5;
|
||||
}
|
||||
|
||||
return Math.min(Math.floor(base * multiplier), base * 10);
|
||||
}
|
||||
|
||||
export async function generateChallenge(opts: {
|
||||
difficulty?: "normal" | "light";
|
||||
maxNumber?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const maxNumber = opts.maxNumber
|
||||
?? (opts.difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER);
|
||||
|
||||
const challenge = await createChallenge({
|
||||
hmacKey: config.ALTCHA_HMAC_KEY,
|
||||
maxNumber,
|
||||
expires: new Date(Date.now() + config.ALTCHA_EXPIRE_SECONDS * 1000),
|
||||
});
|
||||
|
||||
// track issuance for solve-time and session validation
|
||||
const sHash = hashSession(opts.sessionId || "");
|
||||
issuedChallenges.set(challenge.salt, {
|
||||
issuedAt: Date.now(),
|
||||
sessionHash: sHash,
|
||||
});
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
export async function verifyChallenge(payload: string): Promise<boolean> {
|
||||
export async function verifyChallenge(payload: string, sessionId?: string): Promise<boolean> {
|
||||
try {
|
||||
const fp = challengeFingerprint(payload);
|
||||
|
||||
@@ -38,20 +117,36 @@ export async function verifyChallenge(payload: string): Promise<boolean> {
|
||||
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
|
||||
if (!ok) return false;
|
||||
|
||||
// extract salt from payload to check issuance data
|
||||
const decoded = JSON.parse(atob(payload));
|
||||
const issued = issuedChallenges.get(decoded.salt);
|
||||
|
||||
if (issued) {
|
||||
// solve-time check - reject if solved too fast (bot/script)
|
||||
const solveTime = Date.now() - issued.issuedAt;
|
||||
if (solveTime < MIN_SOLVE_MS) return false;
|
||||
|
||||
// session binding check
|
||||
const currentHash = hashSession(sessionId || "");
|
||||
if (issued.sessionHash !== currentHash) return false;
|
||||
|
||||
issuedChallenges.delete(decoded.salt);
|
||||
}
|
||||
|
||||
// evict expired entries if map is large
|
||||
if (usedChallenges.size >= 50000) {
|
||||
const cutoff = Date.now() - EXPIRY_MS;
|
||||
for (const [key, ts] of usedChallenges) {
|
||||
if (ts < cutoff) usedChallenges.delete(key);
|
||||
for (const [key, entry] of usedChallenges) {
|
||||
if (entry.issuedAt < cutoff) usedChallenges.delete(key);
|
||||
}
|
||||
}
|
||||
// hard cap - drop oldest entries if still over limit
|
||||
if (usedChallenges.size >= 50000) {
|
||||
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1] - b[1]);
|
||||
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1].issuedAt - b[1].issuedAt);
|
||||
const toRemove = sorted.slice(0, sorted.length - 40000);
|
||||
for (const [key] of toRemove) usedChallenges.delete(key);
|
||||
}
|
||||
usedChallenges.set(fp, Date.now());
|
||||
usedChallenges.set(fp, { issuedAt: Date.now(), sessionHash: hashSession(sessionId || "") });
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
1039
packages/api/src/services/detection-engine.ts
Normal file
1039
packages/api/src/services/detection-engine.ts
Normal file
File diff suppressed because it is too large
Load Diff
98
packages/api/src/services/identity-signals.ts
Normal file
98
packages/api/src/services/identity-signals.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import prisma from "../lib/prisma.js";
|
||||
|
||||
export async function recordAction(userId: string, actionType: string, metadata?: { boardId?: string }) {
|
||||
const updates: Record<string, any> = {};
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { firstActionType: true, boardInteractionCount: true } });
|
||||
if (user && !user.firstActionType) {
|
||||
updates.firstActionType = actionType;
|
||||
updates.firstActionAt = new Date();
|
||||
}
|
||||
|
||||
if (metadata?.boardId) {
|
||||
const distinctBoards = await prisma.vote.findMany({
|
||||
where: { voterId: userId },
|
||||
select: { post: { select: { boardId: true } } },
|
||||
distinct: ["postId"],
|
||||
});
|
||||
const boardIds = new Set(distinctBoards.map((v) => v.post.boardId));
|
||||
if (metadata.boardId) boardIds.add(metadata.boardId);
|
||||
updates.boardInteractionCount = boardIds.size;
|
||||
}
|
||||
|
||||
const recentVotes = await prisma.vote.count({ where: { voterId: userId } });
|
||||
const recentComments = await prisma.comment.count({ where: { authorId: userId } });
|
||||
const recentPosts = await prisma.post.count({ where: { authorId: userId } });
|
||||
const recentReactions = await prisma.reaction.count({ where: { userId } });
|
||||
const activeTypes = [recentVotes > 0, recentComments > 0, recentPosts > 0, recentReactions > 0].filter(Boolean).length;
|
||||
updates.actionDiversityScore = activeTypes / 4;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: updates }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordVoteTimingUpdate(userId: string) {
|
||||
const votes = await prisma.vote.findMany({
|
||||
where: { voterId: userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
select: { createdAt: true },
|
||||
});
|
||||
|
||||
if (votes.length < 3) return;
|
||||
|
||||
const intervals: number[] = [];
|
||||
for (let i = 0; i < votes.length - 1; i++) {
|
||||
intervals.push(votes[i].createdAt.getTime() - votes[i + 1].createdAt.getTime());
|
||||
}
|
||||
|
||||
const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||
const variance = intervals.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / intervals.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
|
||||
await prisma.user.update({ where: { id: userId }, data: { voteTimingStdDev: stdDev } }).catch(() => {});
|
||||
}
|
||||
|
||||
export function checkTimeToSubmit(pageLoadTimestamp: number): boolean {
|
||||
const elapsed = Date.now() - pageLoadTimestamp;
|
||||
return elapsed < 5000;
|
||||
}
|
||||
|
||||
export function classifyReferrer(referrer: string | undefined, origin: string): "internal" | "external" | "none" {
|
||||
if (!referrer) return "none";
|
||||
try {
|
||||
const refUrl = new URL(referrer);
|
||||
const originUrl = new URL(origin);
|
||||
return refUrl.hostname === originUrl.hostname ? "internal" : "external";
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIdentityRiskLevel(userId: string): Promise<"low" | "medium" | "high"> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
actionDiversityScore: true,
|
||||
voteTimingStdDev: true,
|
||||
boardInteractionCount: true,
|
||||
flagCount: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
if (!user) return "high";
|
||||
|
||||
let risk = 0;
|
||||
const ageMs = Date.now() - user.createdAt.getTime();
|
||||
|
||||
if (ageMs < 10 * 60 * 1000) risk += 2;
|
||||
if (user.actionDiversityScore < 0.25) risk += 1;
|
||||
if (user.voteTimingStdDev !== null && user.voteTimingStdDev < 2000) risk += 2;
|
||||
if (user.boardInteractionCount <= 1) risk += 1;
|
||||
if (user.flagCount > 0) risk += user.flagCount;
|
||||
|
||||
if (risk >= 4) return "high";
|
||||
if (risk >= 2) return "medium";
|
||||
return "low";
|
||||
}
|
||||
Reference in New Issue
Block a user