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
2 changes: 2 additions & 0 deletions packages/backend/src/middleware/discordAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
discordAccessToken: string;
discordRefreshToken: string;
discordGuildFlags: Awaited<ReturnType<typeof getCurrentUserGuildFlags>>;
discordGuildFlagsFetchedAt: number;
},
) => void,
_consume: ConsumableAPI,
Expand All @@ -65,6 +66,7 @@
discordAccessToken: accessToken,
discordRefreshToken: refreshToken,
discordGuildFlags: userGuildFlags,
discordGuildFlagsFetchedAt: Date.now(),
});
} catch (error) {
done(error as Error, undefined);
Expand All @@ -83,20 +85,20 @@
passport.deserializeUser<DiscordUserProfile>((user, done) => {
done(null, user);
});

app.use(
session({
cookie: {
maxAge: 24 * 60 * 60 * 1000, // 1 day (in ms)
},
// having a random secret would mess with persistent sessions
secret: config.expressSessionSecret,
resave: true,
saveUninitialized: false,
store: new PrismaSessionStore(prisma, {
checkPeriod: 2 * 60 * 1000, //ms
dbRecordIdIsSessionId: true,
dbRecordIdFunction: undefined,

Check failure

Code scanning / CodeQL

Missing CSRF middleware High

This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a
request handler
without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
This cookie middleware is serving a request handler without CSRF protection.
}),
}),
);
Expand Down
14 changes: 14 additions & 0 deletions packages/backend/src/middleware/ratelimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,17 @@ export const frameMutationLimiter = rateLimit({
standardHeaders: true,
legacyHeaders: false,
});

/**
* Rate limiter for the manual guild-membership refresh endpoint. Each call
* bypasses our session cache and hits Discord's `/users/@me/guilds`, so we
* tighten the budget to protect both our service and the user's Discord token.
* Allows 3 requests per minute per IP address.
*/
export const guildRefreshLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 3,
message: "You have been rate limited",
standardHeaders: true,
legacyHeaders: false,
});
51 changes: 35 additions & 16 deletions packages/backend/src/routes/api/v1/discord.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { DiscordUserProfile, GuildData } from "@blurple-canvas-web/types";
import type { DiscordUserProfile } from "@blurple-canvas-web/types";
import { Router } from "express";
import type { SessionData } from "express-session";
import passport from "passport";

import config from "@/config";
import { UnauthorizedError } from "@/errors";
import ApiError from "@/errors/ApiError";
import { guildRefreshLimiter } from "@/middleware/ratelimit";
import {
getCurrentUserGuildFlags,
getCachedUserGuildFlags,
getGuildPermissionsForUser,
refreshCachedUserGuildFlags,
syncDiscordGuildRecords,
} from "@/services/discordGuildService";
import { saveDiscordProfile } from "@/services/discordProfileService";
Expand Down Expand Up @@ -40,11 +44,9 @@ discordRouter.get("/guilds/permissions-map", async (req, res) => {
throw new UnauthorizedError("User is not authenticated");
}

const guildFlags =
req.session.discordGuildFlags ??
(await withDiscordAccessToken(req.session, (accessToken) =>
getCurrentUserGuildFlags(accessToken),
));
const guildFlags = await withDiscordAccessToken(req.session, (accessToken) =>
getCachedUserGuildFlags(req.session, accessToken),
);

req.session.discordGuildFlags = guildFlags;

Expand All @@ -53,6 +55,29 @@ discordRouter.get("/guilds/permissions-map", async (req, res) => {
});
});

discordRouter.post("/guilds/refresh", guildRefreshLimiter, async (req, res) => {
try {
const profile = req.user as DiscordUserProfile;

if (!profile?.id) {
throw new UnauthorizedError("User is not authenticated");
}

const guildFlags = await withDiscordAccessToken(
req.session,
(accessToken) => refreshCachedUserGuildFlags(req.session, accessToken),
);

res.status(200).json({
guilds: guildFlags,
});

await syncDiscordGuildRecords(guildFlags);
} catch (error) {
ApiError.sendError(res, error);
}
});

/**
* Delete the active session associated with the user. This will invalidate the existing session
* cookie.
Expand All @@ -73,22 +98,16 @@ discordRouter.get(
}),
async (req, res) => {
const discordProfile = req.user as DiscordUserProfile;
const authInfo = req.authInfo as
| {
discordAccessToken?: string;
discordRefreshToken?: string;
discordTokenExpiresAt?: number;
discordTokenLifetimeMs?: number;
discordGuildFlags?: Record<string, GuildData>;
}
| undefined;
const authInfo = req.authInfo as Partial<SessionData> | undefined;

if (authInfo?.discordAccessToken) {
req.session.discordAccessToken = authInfo.discordAccessToken;
req.session.discordRefreshToken = authInfo.discordRefreshToken;
req.session.discordTokenExpiresAt = authInfo.discordTokenExpiresAt;
req.session.discordTokenLifetimeMs = authInfo.discordTokenLifetimeMs;
req.session.discordGuildFlags = authInfo.discordGuildFlags;
req.session.discordGuildFlagsFetchedAt =
authInfo.discordGuildFlagsFetchedAt ?? Date.now();
Comment thread
stijnvdkolk marked this conversation as resolved.
}

res.cookie("profile", JSON.stringify(discordProfile), {
Expand Down
10 changes: 9 additions & 1 deletion packages/backend/src/routes/api/v1/pixel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
PlacePixelBodyModel,
} from "@/models/pixel.models";
import { updateManyCachedPixels } from "@/services/canvasService";
import { getCachedUserGuildFlags } from "@/services/discordGuildService";
import { withDiscordAccessToken } from "@/services/discordTokenService";
import {
placePixel,
validateColor,
Expand Down Expand Up @@ -74,8 +76,14 @@ pixelRouter.post(
}

const coordinates: Point = { x, y };
const guildFlags = await withDiscordAccessToken(
req.session,
(accessToken) => getCachedUserGuildFlags(req.session, accessToken),
);
const userGuildIds = new Set(Object.keys(guildFlags));

const [color] = await Promise.all([
validateColor(colorId, req.params.canvasId),
validateColor(colorId, req.params.canvasId, userGuildIds),
validatePixel(req.params.canvasId, coordinates, true),
validateUser(BigInt(profile.id)),
]);
Expand Down
139 changes: 139 additions & 0 deletions packages/backend/src/services/discordGuildService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { SessionData } from "express-session";
import fetchWithRetries from "@/utils/fetchWithRetries";
import { getCachedUserGuildFlags } from "./discordGuildService";

vi.mock("@/utils/fetchWithRetries", () => ({
default: vi.fn(),
}));

const mockFetch = vi.mocked(fetchWithRetries);

function mockGuildsResponse(
guilds: Array<{
id: string;
name: string;
permissions?: string;
approximate_member_count?: number;
}>,
) {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify(guilds), {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
}),
);
}

function makeSession(overrides: Partial<SessionData> = {}): SessionData {
return { cookie: {} as SessionData["cookie"], ...overrides };
}

const sampleGuilds = [
{
id: "1",
name: "Guild 1",
permissions: "0",
approximate_member_count: 10,
},
];

describe("getCachedUserGuildFlags", () => {
beforeEach(() => {
mockFetch.mockReset();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
});

afterEach(() => {
vi.useRealTimers();
});

it("fetches fresh flags when the session has no cache", async () => {
mockGuildsResponse(sampleGuilds);

const session = makeSession();
const result = await getCachedUserGuildFlags(session, "token");

expect(result).toMatchObject({ "1": { name: "Guild 1" } });
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(session.discordGuildFlags).toEqual(result);
expect(session.discordGuildFlagsFetchedAt).toBe(Date.now());
});

it("returns cached flags within the TTL window without hitting Discord", async () => {
const cachedFlags = {
"1": {
name: "Cached Guild",
memberCount: 10,
administrator: false,
manageGuild: false,
},
};
const session = makeSession({
discordGuildFlags: cachedFlags,
discordGuildFlagsFetchedAt: Date.now(),
});

vi.advanceTimersByTime(14 * 60 * 1000);

const result = await getCachedUserGuildFlags(session, "token");

expect(result).toEqual(cachedFlags);
expect(mockFetch).not.toHaveBeenCalled();
});

it("refetches when the cached flags are older than the TTL", async () => {
const cachedFlags = {
"1": {
name: "Stale Guild",
memberCount: 10,
administrator: false,
manageGuild: false,
},
};
const session = makeSession({
discordGuildFlags: cachedFlags,
discordGuildFlagsFetchedAt: Date.now(),
});

vi.advanceTimersByTime(15 * 60 * 1000 + 1);

mockGuildsResponse([
{
id: "2",
name: "Refreshed Guild",
permissions: "0",
approximate_member_count: 5,
},
]);

const result = await getCachedUserGuildFlags(session, "token");

expect(result).toMatchObject({ "2": { name: "Refreshed Guild" } });
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(session.discordGuildFlags).toEqual(result);
expect(session.discordGuildFlagsFetchedAt).toBe(Date.now());
});

it("refetches when discordGuildFlagsFetchedAt is missing", async () => {
mockGuildsResponse(sampleGuilds);

const session = makeSession({
discordGuildFlags: {
old: {
name: "Old",
memberCount: null,
administrator: false,
manageGuild: false,
},
},
});

const result = await getCachedUserGuildFlags(session, "token");

expect(result).toMatchObject({ "1": { name: "Guild 1" } });
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(session.discordGuildFlagsFetchedAt).toBe(Date.now());
});
});
31 changes: 31 additions & 0 deletions packages/backend/src/services/discordGuildService.ts
Comment thread
stijnvdkolk marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GuildData } from "@blurple-canvas-web/types";
import type { SessionData } from "express-session";
import { prisma } from "@/client";
import config from "@/config";
import { ApiError } from "@/errors";
Expand All @@ -8,6 +9,8 @@ import TooManyRequestsError from "@/errors/TooManyRequestsError";
import UnauthorizedError from "@/errors/UnauthorizedError";
import fetchWithRetries from "@/utils/fetchWithRetries";

const GUILD_FLAGS_CACHE_TTL_MS = 900_000; // 15 min

const DISCORD_API_BASE_URL = "https://discord.com/api/v10";
const ADMINISTRATOR_PERMISSION = 0x8n;
const MANAGE_GUILD_PERMISSION = 0x20n;
Expand Down Expand Up @@ -219,6 +222,34 @@ export async function getCurrentUserGuildFlags(
);
}

export async function getCachedUserGuildFlags(
session: SessionData,
accessToken: string,
): Promise<Record<string, GuildData>> {
const cached = session.discordGuildFlags;
const fetchedAt = session.discordGuildFlagsFetchedAt;
const isFresh =
cached !== undefined &&
typeof fetchedAt === "number" &&
Date.now() - fetchedAt < GUILD_FLAGS_CACHE_TTL_MS;

if (isFresh) {
return cached;
}

return await refreshCachedUserGuildFlags(session, accessToken);
}

export async function refreshCachedUserGuildFlags(
session: SessionData,
accessToken: string,
): Promise<Record<string, GuildData>> {
const guildFlags = await getCurrentUserGuildFlags(accessToken);
session.discordGuildFlags = guildFlags;
session.discordGuildFlagsFetchedAt = Date.now();
return guildFlags;
}

function getPermissions(permissions: bigint): GuildPermissionsSummary {
const administrator =
(permissions & ADMINISTRATOR_PERMISSION) === ADMINISTRATOR_PERMISSION;
Expand Down
Loading