Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,33 @@ This repository contains: <!-- IC: 198 -->
- We ask for a **90-day** coordinated disclosure window before public disclosure. <!-- IC: 211 -->
- We will credit reporters in the release notes unless you prefer to remain anonymous.

## Webhook Endpoint Abuse Defenses

The dashboard webhook endpoint (`POST /api/webhooks`) applies two guards
ahead of signature verification and logs every rejection in a structured
format:

- **Payload size limit** — bodies larger than `WEBHOOK_MAX_BODY_BYTES`
(default `262144` = 256 KB) are rejected with `413` before any HMAC work,
checked against both the declared `content-length` and the actual body
size.
- **Failed-verification rate limit** — a source (first `x-forwarded-for`
address, else `x-real-ip`) that produces more than
`WEBHOOK_INVALID_ATTEMPT_LIMIT` (default `10`) failed verifications within
`WEBHOOK_RATE_LIMIT_WINDOW_MS` (default `60000` ms) is rejected with `429`
until the sliding window expires. A successful verification resets the
source's window, so legitimate senders are unaffected. The limiter is
in-process; multi-instance deployments should back it with shared storage.

Every rejection emits one JSON log line via `console.warn`:

```json
{"event":"webhook_rejected","reason":"invalid_signature","source":"203.0.113.9","endpoint":"/api/webhooks","status":401,"ts":"2026-07-24T00:00:00.000Z"}
```

`reason` is one of `oversized_payload`, `invalid_signature`,
`expired_timestamp`, `rate_limited`, `malformed_header`. The log NEVER
contains the webhook secret, the signature header value, or the request
body — only the metadata above, which is safe to ship to alerting.

Thank you for helping keep GuildPass secure.
44 changes: 37 additions & 7 deletions apps/dashboard/app/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
} from "@guildpass/integration-client";
import type { ActivityChange } from "@guildpass/integration-client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useGuild } from "@/lib/guild/GuildProvider";`nimport { usePathname, useRouter, useSearchParams } from "next/navigation";`nimport type { ActivitySortOrder } from "@/lib/activity/query";
import { useGuild } from "@/lib/guild/GuildProvider";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { ActivitySortOrder } from "@/lib/activity/query";

const TYPE_ICON: Record<ActivityEventType, string> = {
"member.joined": "👤",
Expand Down Expand Up @@ -94,7 +96,17 @@ function readLimit(value: string | null): number {
return PAGE_SIZE_OPTIONS.includes(parsed as (typeof PAGE_SIZE_OPTIONS)[number]) ? parsed : 10;
}
export default function ActivityPage() {
const { guildId, guild } = useGuild();`n const router = useRouter();`n const pathname = usePathname();`n const searchParams = useSearchParams();`n const [type, setType] = useState<ActivityEventType | "">(() => (searchParams.get("type") as ActivityEventType | null) ?? "");`n const [source, setSource] = useState<ActivityEventSource | "">(() => (searchParams.get("source") as ActivityEventSource | null) ?? "");`n const [severity, setSeverity] = useState<ActivityEventSeverity | "">(() => (searchParams.get("severity") as ActivityEventSeverity | null) ?? "");`n const [actor, setActor] = useState(() => searchParams.get("actor") ?? "");`n const [from, setFrom] = useState(() => searchParams.get("from") ?? "");`n const [sort, setSort] = useState<ActivitySortOrder>(() => readSort(searchParams.get("sort")));`n const [limit, setLimit] = useState(() => readLimit(searchParams.get("limit")));
const { guildId, guild } = useGuild();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [type, setType] = useState<ActivityEventType | "">(() => (searchParams.get("type") as ActivityEventType | null) ?? "");
const [source, setSource] = useState<ActivityEventSource | "">(() => (searchParams.get("source") as ActivityEventSource | null) ?? "");
const [severity, setSeverity] = useState<ActivityEventSeverity | "">(() => (searchParams.get("severity") as ActivityEventSeverity | null) ?? "");
const [actor, setActor] = useState(() => searchParams.get("actor") ?? "");
const [from, setFrom] = useState(() => searchParams.get("from") ?? "");
const [sort, setSort] = useState<ActivitySortOrder>(() => readSort(searchParams.get("sort")));
const [limit, setLimit] = useState(() => readLimit(searchParams.get("limit")));
const { intervalMs } = getActivityRefreshConfig();
const updateActivityQuery = useCallback(
(updates: {
Expand All @@ -108,7 +120,12 @@ export default function ActivityPage() {
}) => {
const next = new URLSearchParams(searchParams.toString());
const setOrDelete = (key: string, value: string) => {
value.trim() ? next.set(key, value.trim()) : next.delete(key);
const trimmed = value.trim();
if (trimmed) {
next.set(key, trimmed);
} else {
next.delete(key);
}
};

if (updates.type !== undefined) setOrDelete("type", updates.type);
Expand All @@ -117,10 +134,18 @@ export default function ActivityPage() {
if (updates.actor !== undefined) setOrDelete("actor", updates.actor);
if (updates.from !== undefined) setOrDelete("from", updates.from);
if (updates.sort !== undefined) {
updates.sort === "newest" ? next.delete("sort") : next.set("sort", updates.sort);
if (updates.sort === "newest") {
next.delete("sort");
} else {
next.set("sort", updates.sort);
}
}
if (updates.limit !== undefined) {
updates.limit === 10 ? next.delete("limit") : next.set("limit", String(updates.limit));
if (updates.limit === 10) {
next.delete("limit");
} else {
next.set("limit", String(updates.limit));
}
}

const query = next.toString();
Expand Down Expand Up @@ -170,7 +195,8 @@ export default function ActivityPage() {
source: source || undefined,
severity: severity || undefined,
actor: actor.trim() || undefined,
from: fromIso,`n sort,
from: fromIso,
sort,
autoRefresh: true,
simulate: false,
guildId,
Expand All @@ -183,7 +209,11 @@ export default function ActivityPage() {
setSource("");
setSeverity("");
setActor("");
setFrom("");`n setSort("newest");`n setLimit(10);`n updateActivityQuery({ type: "", source: "", severity: "", actor: "", from: "", sort: "newest", limit: 10 });`n };
setFrom("");
setSort("newest");
setLimit(10);
updateActivityQuery({ type: "", source: "", severity: "", actor: "", from: "", sort: "newest", limit: 10 });
};

return (
<DashboardLayout
Expand Down
12 changes: 6 additions & 6 deletions apps/dashboard/app/api/admin/reconcile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ import { getActiveGuildId } from "@/lib/guild-context";
// ── Counting strategies ───────────────────────────────────────────────────────
//
// Same defaults as the CLI script. In production, these should use direct SQL
// queries for performance. For mock mode, they count all entries since members
// and passes are not yet partitioned by guild in the mock data model.
// queries for performance. Member and pass repositories are tenant-scoped, so
// counts are always per guild (see docs/multi-tenancy.md).

async function countMembersForGuild(_guildId: string): Promise<number> {
async function countMembersForGuild(guildId: string): Promise<number> {
const memberRepo = getMemberRepository();
const all = await memberRepo.getAll();
const all = await memberRepo.getAll(guildId);
return all.length;
}

async function countPassesForGuild(_guildId: string): Promise<number> {
async function countPassesForGuild(guildId: string): Promise<number> {
const passRepo = getPassRepository();
const all = await passRepo.getAll();
const all = await passRepo.getAll(guildId);
return all.length;
}

Expand Down
31 changes: 0 additions & 31 deletions apps/dashboard/app/api/guilds/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,37 +41,6 @@ export async function GET(): Promise<NextResponse> {
* ⚠️ In production, resolve the session from the request (JWT / cookie)
* instead of using MOCK_SESSION, then assertPermission against it.
*/
export async function POST(request: Request): Promise<NextResponse> {
try {
assertCsrfToken(request);
} catch (err) {
if (err instanceof CsrfError) {
return apiError(err.message, 403);
}
throw err;
}

try {
assertPermission(MOCK_API_SESSION, "guilds:write");
} catch (err) {
if (err instanceof PermissionDeniedError) {
return apiError(err.message, 403);
}
throw err;
}

return handleApiError(async () => {
// TODO: implement guild request: Request): Promise<NextResponse> {
try {
assertCsrfToken(request);
} catch (err) {
if (err instanceof CsrfError) {
return apiError(err.message, 403);
}
throw err;
}

return { message: "Guild created (stub)" };
export async function POST(request: Request): Promise<NextResponse> {
const guard = await requireSessionAndPermission(request, getActiveGuildId(request), "guilds:write");
if (!guard.ok) return guard.response;
Expand Down
13 changes: 12 additions & 1 deletion apps/dashboard/app/api/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ export async function GET(request: Request): Promise<NextResponse> {
const query = parseMemberListQuery(request);

if (apiMode === "live") {
// Live mode only supports direct lookups; a bare list request is
// unsupported — reject it before constructing a client we won't use.
if (!wallet && !discordUserId) {
return apiUnsupported(
"members.list",
apiMode,
"Live mode requires a lookup (wallet or discordUserId)"
);
}

const testClient = (globalThis as any).__TEST_INTEGRATION_CLIENT;
let client;

Expand Down Expand Up @@ -191,8 +201,9 @@ export async function PATCH(request: Request): Promise<NextResponse> {

const memberRepository = getMemberRepository();
const guildId = getActiveGuildId(request);
const { version: expectedVersion, ...updateData } = validation.data;
const existing = validation.data.roles ? await memberRepository.getById(guildId, id) : null;
const updated = await memberRepository.update(guildId, id, validation.data, expectedVersion);
const updated = await memberRepository.update(guildId, id, updateData, expectedVersion);
if (!updated) throw new NotFoundError("Member not found.");
const rolesChanged = existing && validation.data.roles && JSON.stringify(existing.roles) !== JSON.stringify(validation.data.roles);
if (rolesChanged) {
Expand Down
24 changes: 2 additions & 22 deletions apps/dashboard/app/api/passes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@/lib/api-helpers";
import { NotFoundError } from "@/lib/api-errors";
import { mockPasses, type Pass } from "@/lib/mock-data";
import { getActiveGuildId } from "@/lib/guild-context";
import { requireSessionAndPermission } from "@/lib/auth/require-permission";
import { getApiMode } from "@/lib/env";
import { getPassRepository } from "@/lib/repositories/factory";
Expand Down Expand Up @@ -37,11 +38,7 @@ export async function GET(

try {
const passRepository = getPassRepository();
<<<<<<< HEAD
return await passRepository.query(query);
=======
return await passRepository.query(getActiveGuildId(request), query);
>>>>>>> main
} catch (error) {
console.error("Error fetching passes:", error);
return getFallbackPasses(request, query);
Expand All @@ -66,15 +63,10 @@ function isPassStatus(value: string | null): value is Pass["status"] {
return value !== null && PASS_STATUSES.includes(value as Pass["status"]);
}

<<<<<<< HEAD
function getFallbackPasses(query: PassListQuery) {
const filtered = filterPasses(mockPasses, query);
=======
function getFallbackPasses(request: Request, query: PassListQuery) {
const guildId = getActiveGuildId(request);
const scoped = mockPasses.filter((pass) => pass.guildId === guildId);
const filtered = filterPasses(scoped, query);
>>>>>>> main
return paginateItems(filtered, query);
}

Expand All @@ -97,11 +89,7 @@ export async function POST(request: Request): Promise<NextResponse> {
}

const passRepository = getPassRepository();
<<<<<<< HEAD
const created = await passRepository.create(validation.data);
=======
const created = await passRepository.create(getActiveGuildId(request), validation.data);
>>>>>>> main
await recordDashboardActivity({
type: "pass.created",
entity: { type: "pass", id: created.id, name: created.name },
Expand Down Expand Up @@ -139,11 +127,7 @@ export async function PATCH(request: Request): Promise<NextResponse> {
}

const passRepository = getPassRepository();
<<<<<<< HEAD
const updated = await passRepository.update(id, validation.data);
=======
const updated = await passRepository.update(getActiveGuildId(request), id, validation.data);
>>>>>>> main
if (!updated) throw new NotFoundError("Pass not found.");
await recordDashboardActivity({
type: "pass.updated",
Expand All @@ -170,14 +154,10 @@ export async function DELETE(request: Request): Promise<NextResponse> {

return handleApiError(async () => {
const passRepository = getPassRepository();
<<<<<<< HEAD
const pass = await passRepository.getById(id);
=======
const guildId = getActiveGuildId(request);
const pass = await passRepository.getById(guildId, id);
>>>>>>> main
if (!pass) throw new NotFoundError("Pass not found.");
const success = await passRepository.delete(id);
const success = await passRepository.delete(guildId, id);
if (!success) throw new NotFoundError("Pass not found.");
await recordDashboardActivity({
type: "pass.deleted",
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/api/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ handleApiError,
} from "@/lib/api-helpers";
import { validateLiveModeEnv, getApiMode } from "@/lib/env";
import { IntegrationClient, type VerificationResult } from "@guildpass/integration-client";
import { isValidChecksumAddress, normaliseAddress } from "@/dashboard/lib/address";
import { isValidChecksumAddress, normaliseAddress } from "@/lib/address";

export async function POST(request: Request): Promise<NextResponse> {
return handleApiError(async () => {
Expand Down
65 changes: 65 additions & 0 deletions apps/dashboard/app/api/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import { activityStorage } from "@/lib/activity/storage";
import { publishActivityEvent } from "@/lib/activity/stream";
import { apiError, apiResponse, apiValidationError } from "@/lib/api-helpers";
import { validateWebhookPayload } from "@/lib/activity/validation";
import {
classifyVerificationError,
getClientSource,
getSharedWebhookRateLimiter,
getWebhookAbuseLimits,
logWebhookRejection,
} from "@/lib/webhooks/abuse-guard";

const ENDPOINT = "/api/webhooks";

export async function POST(req: NextRequest) {
try {
Expand All @@ -16,12 +25,59 @@ export async function POST(req: NextRequest) {
return apiError("Webhook secret not configured", 500);
}

const source = getClientSource(req);
const limits = getWebhookAbuseLimits();
const rateLimiter = getSharedWebhookRateLimiter();

// Size guard: reject oversized bodies before any verification work.
const declaredLength = Number(req.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > limits.maxBodyBytes) {
logWebhookRejection({
reason: "oversized_payload",
source,
endpoint: ENDPOINT,
status: 413,
contentLength: declaredLength,
});
return apiError("Payload too large", 413);
}

// Rate guard: sources that keep failing verification get cut off before
// we spend CPU on another HMAC.
if (rateLimiter.isLimited(source)) {
logWebhookRejection({
reason: "rate_limited",
source,
endpoint: ENDPOINT,
status: 429,
});
return apiError("Too many failed webhook attempts", 429);
}

const signatureHeader = req.headers.get("x-guildpass-signature");
if (!signatureHeader) {
rateLimiter.recordFailure(source);
logWebhookRejection({
reason: "malformed_header",
source,
endpoint: ENDPOINT,
status: 401,
});
return apiError("Missing signature header", 401);
}

const rawBody = await req.text();
const actualLength = Buffer.byteLength(rawBody, "utf8");
if (actualLength > limits.maxBodyBytes) {
logWebhookRejection({
reason: "oversized_payload",
source,
endpoint: ENDPOINT,
status: 413,
contentLength: actualLength,
});
return apiError("Payload too large", 413);
}

const verification = verifySignature({
signatureHeader,
Expand All @@ -30,9 +86,18 @@ export async function POST(req: NextRequest) {
});

if (!verification.valid) {
rateLimiter.recordFailure(source);
logWebhookRejection({
reason: classifyVerificationError(verification.error),
source,
endpoint: ENDPOINT,
status: 401,
});
return apiError(verification.error || "Invalid signature", 401);
}

rateLimiter.recordSuccess(source);

const validation = validateWebhookPayload(rawBody);
if (!validation.valid) {
return apiValidationError("Invalid webhook payload", [
Expand Down
Loading
Loading