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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion backend/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:<password>@it2810-15.idi.ntnu.no:5432/buildapc
# CORS_ORIGIN=http://localhost:5173
# TRUST_PROXY=0
9 changes: 8 additions & 1 deletion backend/server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions backend/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
44 changes: 41 additions & 3 deletions backend/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context>({
typeDefs,
resolvers,
validationRules: [
limitOperationSize({
maxDepth: config.limits.maxQueryDepth,
maxFields: config.limits.maxQueryFields,
}),
],
});
await apollo.start();

app.use(
Expand All @@ -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(
Expand Down
66 changes: 66 additions & 0 deletions backend/server/src/limits.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

// 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] }));
}
},
});
}
38 changes: 38 additions & 0 deletions backend/server/src/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)]]),
},
},
},
);
}
}
9 changes: 9 additions & 0 deletions backend/server/src/resolvers.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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) =>
Expand Down
71 changes: 71 additions & 0 deletions backend/server/src/test/limits.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading