Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example.compose
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ RATE_LIMIT_WINDOW_MS=60000
# Behind a GCP external ALB use 2 (client IP + forwarding-rule IP), plus 1 per
# additional in-cluster proxy hop.
TRUST_PROXY=0
# GraphQL query-cost limits (optional; conservative defaults shown)
GRAPHQL_MAX_DEPTH=12
GRAPHQL_MAX_ALIASES=15
GRAPHQL_MAX_TOKENS=1000
GRAPHQL_MAX_COST=5000
ENABLE_GRAPHIQL="true"
ENABLE_INTROSPECTION="true"
ENABLE_LOGGING="true"
Expand Down
4 changes: 4 additions & 0 deletions .env.example.lightnet
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ SHUTDOWN_TIMEOUT_MS=20000
LOG_LEVEL="info"
CORS_ORIGIN="*"
READINESS_PING_TIMEOUT_MS=2000
GRAPHQL_MAX_DEPTH=12
GRAPHQL_MAX_ALIASES=15
GRAPHQL_MAX_TOKENS=1000
GRAPHQL_MAX_COST=5000

PG_CONN="postgresql://postgres:postgres@localhost:5432/archive"

Expand Down
4 changes: 4 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ The server reads config from environment variables. `PG_CONN` is the only requir
| `RATE_LIMIT_MAX` | `600` | Max requests per client IP per window; `0` disables rate limiting |
| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window length in milliseconds |
| `TRUST_PROXY` | _(unset)_ | Number of trusted proxy hops in front of the API. Required when `RATE_LIMIT_MAX > 0`: the limiter stays disabled until it is set. `0` ignores `X-Forwarded-For` and keys on the socket address |
| `GRAPHQL_MAX_DEPTH` | `12` | Max query selection-set nesting depth. Do not set below `8`: known clients send depth-7 probes, and a depth rejection replaces the `Cannot query field` error those clients rely on for schema-tier fallback |
| `GRAPHQL_MAX_ALIASES` | `15` | Max aliases allowed in a single operation |
| `GRAPHQL_MAX_TOKENS` | `1000` | Max lexical tokens allowed in a query document |
| `GRAPHQL_MAX_COST` | `5000` | Max estimated query cost (depth/field heuristic) |
| `ENABLE_GRAPHIQL` | `false` | If `true`, serves the GraphiQL playground at `/` |
| `ENABLE_INTROSPECTION` | `false` | If `true`, allows GraphQL schema introspection |
| `ENABLE_LOGGING` | `false` | Enable request logging |
Expand Down
101 changes: 98 additions & 3 deletions package-lock.json

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

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
"@envelop/disable-introspection": "^9.1.1",
"@envelop/graphql-jit": "^11.1.1",
"@envelop/opentelemetry": "^9.1.1",
"@escape.tech/graphql-armor-block-field-suggestions": "^3.0.1",
"@escape.tech/graphql-armor-cost-limit": "^2.4.3",
"@escape.tech/graphql-armor-max-aliases": "^2.6.2",
"@escape.tech/graphql-armor-max-depth": "^2.4.2",
"@escape.tech/graphql-armor-max-tokens": "^2.5.1",
"@graphql-tools/executor-http": "^3.0.4",
"@graphql-tools/graphql-file-loader": "^8.1.2",
"@graphql-tools/load": "^8.1.2",
Expand Down
4 changes: 4 additions & 0 deletions src/envionment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ declare global {
RATE_LIMIT_MAX?: string;
RATE_LIMIT_WINDOW_MS?: string;
TRUST_PROXY?: string;
GRAPHQL_MAX_DEPTH?: string;
GRAPHQL_MAX_ALIASES?: string;
GRAPHQL_MAX_TOKENS?: string;
GRAPHQL_MAX_COST?: string;
ENABLE_LOGGING?: bool;
ENABLE_METRICS?: bool;
ENABLE_INTROSPECTION?: bool;
Expand Down
78 changes: 78 additions & 0 deletions src/server/graphql-armor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { maxDepthPlugin } from '@escape.tech/graphql-armor-max-depth';
import { maxAliasesPlugin } from '@escape.tech/graphql-armor-max-aliases';
import { maxTokensPlugin } from '@escape.tech/graphql-armor-max-tokens';
import { costLimitPlugin } from '@escape.tech/graphql-armor-cost-limit';
import { blockFieldSuggestionsPlugin } from '@escape.tech/graphql-armor-block-field-suggestions';

export { buildArmorPlugins, resolveArmorConfig, ARMOR_DEFAULTS };
export type { ArmorConfig };

/**
* Query-cost protections for the public GraphQL endpoint. Without these a single
* deeply-nested, heavily-aliased, or otherwise expensive query can be turned into
* a denial-of-service against the backing Postgres. The limits are deliberately
* conservative — they comfortably allow every query this API legitimately serves
* (the deepest known downstream probe is 7 levels) while rejecting abusive shapes before execution — and
* each is tunable via the environment.
*/
interface ArmorConfig {
/** Max selection-set nesting depth. */
maxDepth: number;
/** Max number of aliases in a single operation. */
maxAliases: number;
/** Max number of lexical tokens in a document. */
maxTokens: number;
/** Max estimated query cost (graphql-armor's depth/field heuristic). */
maxCost: number;
}

const ARMOR_DEFAULTS: ArmorConfig = {
// Deepest query in production use is 7 (mina-explorer's SearchTransaction
// FULL-tier probe). Keep the default comfortably above that: an armor depth
// rejection aborts validation before clients see the `Cannot query field`
// message they use for schema-tier fallback.
maxDepth: 12,
maxAliases: 15,
maxTokens: 1000,
maxCost: 5000,
};

type EnvSource = Record<string, string | undefined>;

/**
* Parse a positive integer from an env value, falling back to `fallback` when it
* is missing or malformed. We never throw, so a stray typo can't silently remove
* a protection — it just reverts to the safe default.
*/
function intFromEnv(value: string | undefined, fallback: number): number {
if (value === undefined || value.trim() === '') return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) return fallback;
return parsed;
}

function resolveArmorConfig(env: EnvSource = process.env): ArmorConfig {
return {
maxDepth: intFromEnv(env.GRAPHQL_MAX_DEPTH, ARMOR_DEFAULTS.maxDepth),
maxAliases: intFromEnv(env.GRAPHQL_MAX_ALIASES, ARMOR_DEFAULTS.maxAliases),
maxTokens: intFromEnv(env.GRAPHQL_MAX_TOKENS, ARMOR_DEFAULTS.maxTokens),
maxCost: intFromEnv(env.GRAPHQL_MAX_COST, ARMOR_DEFAULTS.maxCost),
};
}

/**
* Build the graphql-armor envelop plugins that enforce the configured limits.
* Introspection is ignored by the depth/cost rules so the GraphiQL explorer keeps
* working when it is explicitly enabled; field suggestions are always blocked so
* error messages don't leak schema shape (complementing `useDisableIntrospection`).
*/
function buildArmorPlugins(env: EnvSource = process.env) {
const config = resolveArmorConfig(env);
return [
maxDepthPlugin({ n: config.maxDepth, ignoreIntrospection: true }),
maxAliasesPlugin({ n: config.maxAliases }),
maxTokensPlugin({ n: config.maxTokens }),
costLimitPlugin({ maxCost: config.maxCost, ignoreIntrospection: true }),
blockFieldSuggestionsPlugin(),
];
}
5 changes: 5 additions & 0 deletions src/server/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { initJaegerProvider } from '../tracing/jaeger-tracing.js';
import { useMetrics } from './metrics.js';
import { useRateLimit } from './rate-limit.js';
import { buildArmorPlugins } from './graphql-armor.js';

export { buildPlugins };

Expand All @@ -19,6 +20,10 @@ async function buildPlugins() {
// so over-limit traffic is rejected as cheaply as possible.
plugins.push(useRateLimit());

// Query-cost protections (depth / aliases / tokens / cost). These reject
// abusive query shapes before execution.
plugins.push(...buildArmorPlugins());

if (process.env.ENABLE_METRICS === 'true') {
// Prometheus /metrics endpoint + RED metrics for every request.
plugins.push(useMetrics());
Expand Down
Loading
Loading