From 17e070b61bda22c14ca746f12aa389b108c22fdd Mon Sep 17 00:00:00 2001 From: Ludvig Zapffe Date: Fri, 13 Nov 2026 18:26:43 +0100 Subject: [PATCH 1/3] feat: add abuse protection and request limits for anonymous build publishing --- README.md | 16 ++++ backend/server/.env.example | 6 +- backend/server/package-lock.json | 9 ++- backend/server/package.json | 3 +- backend/server/src/config.ts | 11 +++ backend/server/src/index.ts | 44 ++++++++++- backend/server/src/limits.ts | 66 ++++++++++++++++ backend/server/src/rateLimit.ts | 38 ++++++++++ backend/server/src/resolvers.ts | 9 +++ backend/server/src/test/limits.test.ts | 71 +++++++++++++++++ .../server/src/test/publishRateLimit.test.ts | 76 +++++++++++++++++++ backend/server/src/test/resolvers.test.ts | 3 + 12 files changed, 346 insertions(+), 6 deletions(-) create mode 100644 backend/server/src/limits.ts create mode 100644 backend/server/src/rateLimit.ts create mode 100644 backend/server/src/test/limits.test.ts create mode 100644 backend/server/src/test/publishRateLimit.test.ts diff --git a/README.md b/README.md index 30e0618..17b7f7b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Full Circuit is a web application for exploring PC components and planning a cus - [What is set up on the VM](#what-is-set-up-on-the-vm) - [Working with it](#working-with-it) - [Run the backend locally](#run-the-backend-locally) + - [Abuse protection and request limits](#abuse-protection-and-request-limits) - [Current prototype](#current-prototype) - [PC builder](#pc-builder) - [Navigation and shared build state](#navigation-and-shared-build-state) @@ -146,6 +147,21 @@ npm ci npm run dev ``` +### Abuse protection and request limits + +Publishing stays anonymous, so the backend limits how much a single client can do. The limits are set in `backend/server/src/config.ts`. + +| Limit | Value | When exceeded | +| --- | --- | --- | +| Publishing builds (`publishBuild`) | 5 attempts per client IP in 10 minutes. | HTTP `429` with a `Retry-After` header and error code `RATE_LIMITED`. Nothing is written to the database. | +| Query depth | At most 8 levels of nested fields. | HTTP `400`, error code `GRAPHQL_VALIDATION_FAILED`. | +| Query size | At most 100 selected fields per operation (fragments and aliases are counted). | HTTP `400`, error code `GRAPHQL_VALIDATION_FAILED`. | +| Request body | At most 100 KB. | HTTP `413`, error code `PAYLOAD_TOO_LARGE`. | + +Every publishing attempt counts, including invalid ones, and each alias of `publishBuild` in one request counts separately. Reading data is not rate limited, and introspection queries are not counted towards the depth and size limits. The counters are kept in the server's memory, so they reset when the backend restarts, and they are per backend process. + +The limit is per IP address, so the backend must see the visitor's real address. On the VM, Apache forwards requests to the backend, so `TRUST_PROXY=1` must be set in `backend/server/.env`. Without it, all visitors share the proxy's address and one limit. + ## Current prototype The frontend is built with React, TypeScript, Vite, and Tailwind CSS. The catalog supports search, filtering, sorting, and switching between list and grid layouts. It loads 40 components at a time through infinite scrolling or the **Load more** button, and handles loading, error, and empty states. Recent catalogue responses are cached for one minute. Previous cards remain visible while new results load, and unchanged cards can skip rendering through memoization. diff --git a/backend/server/.env.example b/backend/server/.env.example index c2a0aa4..2e7f10d 100644 --- a/backend/server/.env.example +++ b/backend/server/.env.example @@ -8,9 +8,13 @@ PORT=3001 # Comma-separated list of browser origins allowed to read API responses, or * for any. # On the VM this is the address the frontend is served from. CORS_ORIGIN=http://it2810-15.idi.ntnu.no +# Number of reverse proxies in front of the backend. On the VM, Apache forwards requests to the +# backend, so this must be 1 for the rate limit to count each visitor separately by IP address. +TRUST_PROXY=1 # --- Running backend and frontend locally, with the database on the VM --- # Needs NTNU network or VPN, and the VM's Postgres must accept outside connections. -# Use these two lines in your own .env instead of the ones above: +# Use these three lines in your own .env instead of the ones above (there is no proxy locally): # DATABASE_URL=postgresql://buildapc:@it2810-15.idi.ntnu.no:5432/buildapc # CORS_ORIGIN=http://localhost:5173 +# TRUST_PROXY=0 diff --git a/backend/server/package-lock.json b/backend/server/package-lock.json index 947b44b..74241de 100644 --- a/backend/server/package-lock.json +++ b/backend/server/package-lock.json @@ -15,7 +15,8 @@ "dotenv": "^18.0.7", "express": "^5.3.0", "graphql": "^16.14.2", - "pg": "^8.23.1" + "pg": "^8.23.1", + "rate-limiter-flexible": "^11.4.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -4139,6 +4140,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rate-limiter-flexible": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-11.4.1.tgz", + "integrity": "sha512-rT3HoDh6M+xhmmFKoqwMfIVO+Qw5GpoMZbvt9tgVLAj2H2NsHiEAtEIdZ72rKMXutQ/TntxrYc/BkzjizdrcGw==", + "license": "ISC" + }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", diff --git a/backend/server/package.json b/backend/server/package.json index f628e57..9ae4d0d 100644 --- a/backend/server/package.json +++ b/backend/server/package.json @@ -36,7 +36,8 @@ "dotenv": "^18.0.7", "express": "^5.3.0", "graphql": "^16.14.2", - "pg": "^8.23.1" + "pg": "^8.23.1", + "rate-limiter-flexible": "^11.4.1" }, "overrides": { "minimatch": "^11.0.1" diff --git a/backend/server/src/config.ts b/backend/server/src/config.ts index 3527484..2d7d46d 100644 --- a/backend/server/src/config.ts +++ b/backend/server/src/config.ts @@ -18,4 +18,15 @@ export const config = { corsOrigins: (process.env["CORS_ORIGIN"] ?? "*") .split(",") .map((origin) => origin.trim()), + /** Number of reverse proxies (for example Apache) in front of the server. 0 means none. */ + trustProxy: Number(process.env["TRUST_PROXY"] ?? 0), + limits: { + /** Build publishing: at most this many attempts per client IP in each time window. */ + publishMax: 5, + publishWindowSeconds: 600, + /** GraphQL request limits. */ + maxBodySize: "100kb", + maxQueryDepth: 8, + maxQueryFields: 100, + }, }; diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index 52eee5f..046f8f6 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -5,11 +5,23 @@ import express from "express"; import path from "node:path"; import { config } from "./config.js"; import { pool } from "./db.js"; -import { resolvers } from "./resolvers.js"; +import { limitOperationSize } from "./limits.js"; +import { resolvers, type Context } from "./resolvers.js"; import { typeDefs } from "./schema.js"; const app = express(); -const apollo = new ApolloServer({ typeDefs, resolvers }); +// Behind a reverse proxy, trust its forwarded address so each visitor is rate limited by their own IP. +app.set("trust proxy", config.trustProxy); +const apollo = new ApolloServer({ + typeDefs, + resolvers, + validationRules: [ + limitOperationSize({ + maxDepth: config.limits.maxQueryDepth, + maxFields: config.limits.maxQueryFields, + }), + ], +}); await apollo.start(); app.use( @@ -32,7 +44,33 @@ app.get("/health", async (_req, res) => { } }); -app.use("/graphql", express.json(), expressMiddleware(apollo)); +app.use( + "/graphql", + express.json({ limit: config.limits.maxBodySize }), + expressMiddleware(apollo, { + context: async ({ req }) => ({ ip: req.ip ?? "unknown" }), + }), +); + +// Oversized request bodies are rejected by express.json before they reach GraphQL. +app.use( + ( + error: { type?: string }, + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + if (error.type !== "entity.too.large") return next(error); + res.status(413).json({ + errors: [ + { + message: "Request body too large", + extensions: { code: "PAYLOAD_TOO_LARGE" }, + }, + ], + }); + }, +); app.listen(config.port, () => { console.log( diff --git a/backend/server/src/limits.ts b/backend/server/src/limits.ts new file mode 100644 index 0000000..afcf913 --- /dev/null +++ b/backend/server/src/limits.ts @@ -0,0 +1,66 @@ +import { + GraphQLError, + Kind, + type SelectionSetNode, + type ValidationRule, +} from "graphql"; + +interface Limits { + maxDepth: number; + maxFields: number; +} + +/** + * Validation rule that rejects operations that are nested too deeply or select too many + * fields. Fragments are expanded, so they cannot be used to hide the real size, and + * introspection fields (__schema, __type) are not counted. + */ +export function limitOperationSize({ + maxDepth, + maxFields, +}: Limits): ValidationRule { + return (context) => ({ + OperationDefinition(operation) { + let fields = 0; + const spreading = new Set(); + + // Returns an error message as soon as a limit is passed, so huge operations are cheap to reject. + function check( + selectionSet: SelectionSetNode, + depth: number, + ): string | null { + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + if (selection.name.value.startsWith("__")) continue; + if (depth > maxDepth) + return `Query is nested too deeply (maximum depth is ${maxDepth}).`; + if (++fields > maxFields) + return `Query selects too many fields (maximum is ${maxFields}).`; + if (selection.selectionSet) { + const error = check(selection.selectionSet, depth + 1); + if (error) return error; + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + const error = check(selection.selectionSet, depth); + if (error) return error; + } else { + const name = selection.name.value; + const fragment = context.getFragment(name); + // Fragment cycles are reported by another rule; just do not loop here. + if (!fragment || spreading.has(name)) continue; + spreading.add(name); + const error = check(fragment.selectionSet, depth); + spreading.delete(name); + if (error) return error; + } + } + return null; + } + + const message = check(operation.selectionSet, 1); + if (message) { + context.reportError(new GraphQLError(message, { nodes: [operation] })); + } + }, + }); +} diff --git a/backend/server/src/rateLimit.ts b/backend/server/src/rateLimit.ts new file mode 100644 index 0000000..f7ff2b4 --- /dev/null +++ b/backend/server/src/rateLimit.ts @@ -0,0 +1,38 @@ +import { GraphQLError } from "graphql"; +import { RateLimiterMemory } from "rate-limiter-flexible"; +import { config } from "./config.js"; + +const publishLimiter = new RateLimiterMemory({ + points: config.limits.publishMax, + duration: config.limits.publishWindowSeconds, +}); + +/** + * Counts one build publishing attempt for the client and throws a rate limit error + * once the client has used up its attempts. Must run before anything is written. + */ +export async function consumePublishAttempt(ip: string): Promise { + try { + await publishLimiter.consume(ip); + } catch (rejection) { + // The limiter rejects with its result object when the limit is reached. + const retryAfter = Math.max( + 1, + Math.ceil( + ((rejection as { msBeforeNext?: number }).msBeforeNext ?? 0) / 1000, + ), + ); + throw new GraphQLError( + `Too many builds published. Try again in ${retryAfter} seconds.`, + { + extensions: { + code: "RATE_LIMITED", + http: { + status: 429, + headers: new Map([["Retry-After", String(retryAfter)]]), + }, + }, + }, + ); + } +} diff --git a/backend/server/src/resolvers.ts b/backend/server/src/resolvers.ts index 75e7404..3f0ad40 100644 --- a/backend/server/src/resolvers.ts +++ b/backend/server/src/resolvers.ts @@ -1,6 +1,12 @@ import { GraphQLError, GraphQLScalarType } from "graphql"; import path from "node:path"; import { pool } from "./db.js"; +import { consumePublishAttempt } from "./rateLimit.js"; + +/** Per-request values made available to every resolver. */ +export interface Context { + ip: string; +} const MAX_PAGE_SIZE = 100; @@ -386,7 +392,10 @@ async function publishBuild( }: { input: { tittel: string; comment?: string | null; partIds: string[] }; }, + { ip }: Context, ) { + // Counted first, so blocked or invalid attempts never reach the database. + await consumePublishAttempt(ip); const tittel = input.tittel.trim(); const comment = input.comment?.trim() || null; const invalid = (message: string) => diff --git a/backend/server/src/test/limits.test.ts b/backend/server/src/test/limits.test.ts new file mode 100644 index 0000000..e97d905 --- /dev/null +++ b/backend/server/src/test/limits.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { buildSchema, parse, specifiedRules, validate } from "graphql"; +import { limitOperationSize } from "../limits.js"; +import { typeDefs } from "../schema.js"; + +const schema = buildSchema(typeDefs); + +function errors(query: string, maxDepth = 8, maxFields = 100) { + return validate(schema, parse(query), [ + ...specifiedRules, + limitOperationSize({ maxDepth, maxFields }), + ]).map((error) => error.message); +} + +describe("limitOperationSize", () => { + it("accepts normal catalog and build queries", () => { + expect( + errors(`{ parts(first: 40) { edges { node { id name priceEur } cursor } + totalCount pageInfo { hasNextPage endCursor } } }`), + ).toEqual([]); + expect(errors(`{ builds { id tittel parts { id name } } }`)).toEqual([]); + }); + + it("rejects queries nested deeper than the maximum", () => { + // parts > edges > node > id is 4 levels deep. + const query = `{ parts { edges { node { id } } } }`; + expect(errors(query, 4)).toEqual([]); + expect(errors(query, 3)).toEqual([ + "Query is nested too deeply (maximum depth is 3).", + ]); + }); + + it("rejects queries that select too many fields", () => { + const aliases = Array.from( + { length: 11 }, + (_, index) => `a${index}: manufacturers`, + ).join(" "); + expect(errors(`{ ${aliases} }`, 8, 10)).toEqual([ + "Query selects too many fields (maximum is 10).", + ]); + }); + + it("counts fields and depth inside fragments", () => { + const query = ` + query { builds { ...BuildFields } } + fragment BuildFields on Build { id parts { ...PartFields } } + fragment PartFields on Part { id name }`; + expect(errors(query, 3)).toEqual([]); + expect(errors(query, 2)).toEqual([ + "Query is nested too deeply (maximum depth is 2).", + ]); + expect(errors(query, 8, 4)).toEqual([ + "Query selects too many fields (maximum is 4).", + ]); + }); + + it("does not loop on fragment cycles", () => { + const query = ` + { builds { ...A } } + fragment A on Build { id ...B } + fragment B on Build { id ...A }`; + expect(errors(query)).not.toContain(expect.stringContaining("too")); + }); + + it("does not limit introspection queries", () => { + expect( + errors(`{ __schema { types { fields { type { ofType { ofType { ofType { + ofType { ofType { name } } } } } } } } } }`), + ).toEqual([]); + }); +}); diff --git a/backend/server/src/test/publishRateLimit.test.ts b/backend/server/src/test/publishRateLimit.test.ts new file mode 100644 index 0000000..b9a5e70 --- /dev/null +++ b/backend/server/src/test/publishRateLimit.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 2, publishWindowSeconds: 600 } }, +})); + +const query = vi.hoisted(() => vi.fn()); +vi.mock("../db.js", () => ({ pool: { query } })); + +import { resolvers } from "../resolvers.js"; + +const partId = "11111111-1111-4111-8111-111111111111"; +const input = { tittel: "My build", partIds: [partId] }; +const publish = (ip: string) => + resolvers.Mutation.publishBuild(null, { input }, { ip }); +const inserts = () => + query.mock.calls.filter(([sql]) => String(sql).startsWith("INSERT")); + +beforeEach(() => { + query.mockReset(); + query.mockImplementation(async (sql: string) => + sql.startsWith("INSERT") + ? { rows: [{ id: "1" }] } + : { rows: [{ id: partId, category: "CPU" }] }, + ); +}); + +describe("publishBuild rate limit", () => { + it("allows normal publishing", async () => { + await expect(publish("10.0.0.1")).resolves.toEqual({ id: "1" }); + expect(inserts()).toHaveLength(1); + }); + + it("blocks excessive publishing without creating database entries", async () => { + await publish("10.0.0.2"); + await publish("10.0.0.2"); + expect(inserts()).toHaveLength(2); + query.mockClear(); + + await expect(publish("10.0.0.2")).rejects.toMatchObject({ + extensions: { + code: "RATE_LIMITED", + http: { status: 429, headers: expect.any(Map) }, + }, + }); + expect(query).not.toHaveBeenCalled(); + }); + + it("limits each IP address separately", async () => { + await publish("10.0.0.3"); + await publish("10.0.0.3"); + await expect(publish("10.0.0.3")).rejects.toMatchObject({ + extensions: { code: "RATE_LIMITED" }, + }); + + await expect(publish("10.0.0.4")).resolves.toEqual({ id: "1" }); + }); + + it("counts invalid attempts too", async () => { + const invalid = (ip: string) => + resolvers.Mutation.publishBuild( + null, + { input: { tittel: "", partIds: [partId] } }, + { ip }, + ); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "BAD_USER_INPUT" }, + }); + await expect(invalid("10.0.0.5")).rejects.toMatchObject({ + extensions: { code: "RATE_LIMITED" }, + }); + }); +}); diff --git a/backend/server/src/test/resolvers.test.ts b/backend/server/src/test/resolvers.test.ts index d154e7a..2648650 100644 --- a/backend/server/src/test/resolvers.test.ts +++ b/backend/server/src/test/resolvers.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const query = vi.hoisted(() => vi.fn()); vi.mock("../db.js", () => ({ pool: { query } })); +vi.mock("../config.js", () => ({ + config: { limits: { publishMax: 5, publishWindowSeconds: 600 } }, +})); import { resolvers } from "../resolvers.js"; From 4b4f641f5e62879f4f5e88480da742ebd2bf9167 Mon Sep 17 00:00:00 2001 From: Ludvig Zapffe Date: Fri, 13 Nov 2026 18:53:03 +0100 Subject: [PATCH 2/3] feat: improve text contrast in compatibility panel and update status colors --- frontend/src/components/MobileComponentDetailPage.tsx | 2 +- .../pcBuilderPage/components/CompatabilityComponent.tsx | 8 ++++---- .../src/pages/pcBuilderPage/components/PublishBuild.tsx | 2 +- frontend/src/styles/colors.css | 8 +++++++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/MobileComponentDetailPage.tsx b/frontend/src/components/MobileComponentDetailPage.tsx index 0ef906e..2988a5d 100644 --- a/frontend/src/components/MobileComponentDetailPage.tsx +++ b/frontend/src/components/MobileComponentDetailPage.tsx @@ -106,7 +106,7 @@ export default function ComponentDetailPage({ part }: { part: PcPart }) { key={label} className="grid shrink-0 grid-cols-2 gap-4 border-b border-gray-600 py-1" > -
{label}
+
{label}
{formatSpecification(value)}
))} diff --git a/frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx b/frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx index 4b92480..8d1dc51 100644 --- a/frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx +++ b/frontend/src/pages/pcBuilderPage/components/CompatabilityComponent.tsx @@ -15,25 +15,25 @@ const resultTypes = [ status: "[INCOMPATIBLE]", label: "Incompatible", Icon: CircleX, - color: "text-red-500", + color: "text-status-incompatible", }, { status: "[WARNING]", label: "Warning", Icon: TriangleAlert, - color: "text-orange-500", + color: "text-status-warning", }, { status: "[UNKNOWN]", label: "Unknown", Icon: CircleQuestionMark, - color: "text-yellow-500", + color: "text-status-unknown", }, { status: "[COMPATIBLE]", label: "Compatible", Icon: ThumbsUp, - color: "text-green-500", + color: "text-status-compatible", }, ]; diff --git a/frontend/src/pages/pcBuilderPage/components/PublishBuild.tsx b/frontend/src/pages/pcBuilderPage/components/PublishBuild.tsx index 56ae95e..375c6fe 100644 --- a/frontend/src/pages/pcBuilderPage/components/PublishBuild.tsx +++ b/frontend/src/pages/pcBuilderPage/components/PublishBuild.tsx @@ -108,7 +108,7 @@ export default function PublishBuild() { /> {error && ( -

+

{error}

)} diff --git a/frontend/src/styles/colors.css b/frontend/src/styles/colors.css index 1dc6fab..a5f000a 100644 --- a/frontend/src/styles/colors.css +++ b/frontend/src/styles/colors.css @@ -3,6 +3,12 @@ --color-surface: #485458; --color-surface-dark: #343e42; --color-primary: #4adea0; - --color-secondary: #a7b3b0; + --color-secondary: #c0cbc8; --color-accent: #ff922b; + + /* Status text colors, at least 4.5:1 on surface and background (WCAG AA). */ + --color-status-incompatible: #ffb3b3; + --color-status-warning: #ffc07a; + --color-status-unknown: #fdc700; + --color-status-compatible: #4ee39a; } From 89b9d5cf7095a47a481c7cc909688b4474a74d4b Mon Sep 17 00:00:00 2001 From: Ludvig Zapffe Date: Fri, 13 Nov 2026 18:54:43 +0100 Subject: [PATCH 3/3] feat: enhance text contrast for status colors in compatibility panel --- frontend/src/styles/colors.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/styles/colors.css b/frontend/src/styles/colors.css index a5f000a..6a620ab 100644 --- a/frontend/src/styles/colors.css +++ b/frontend/src/styles/colors.css @@ -7,8 +7,8 @@ --color-accent: #ff922b; /* Status text colors, at least 4.5:1 on surface and background (WCAG AA). */ - --color-status-incompatible: #ffb3b3; - --color-status-warning: #ffc07a; - --color-status-unknown: #fdc700; - --color-status-compatible: #4ee39a; + --color-status-incompatible: #ffb7b3; + --color-status-warning: #ffc28c; + --color-status-unknown: #e5d183; + --color-status-compatible: #9be5b4; }