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
6 changes: 6 additions & 0 deletions packages/core/src/service/load-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ function validateServiceConfig(config: unknown): ServiceValidation {

if (typeof service['name'] !== 'string' || !service['name']) {
errors.push('service.name is required');
} else {
const name = service['name'] as string;
const dockerNameRegex = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$/;
if (!dockerNameRegex.test(name)) {
errors.push('service.name must be lowercase alphanumeric with hyphens (1-63 chars, Docker compatible)');
}
}

const runtime = service['runtime'];
Expand Down
142 changes: 134 additions & 8 deletions packages/http/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Elysia, t } from 'elysia';
import { cors } from '@elysiajs/cors';
import { join, resolve } from 'node:path';
import { loadService, executeService, runPreflight, getImageName, buildServiceImage } from '@ignite/core';
import { logger } from '@ignite/shared';
import type {
Expand All @@ -14,19 +15,101 @@ export interface ServerOptions {
port?: number;
host?: string;
servicesPath?: string;
/** API key for bearer token authentication. If not set, auth is disabled (NOT RECOMMENDED for production) */
apiKey?: string;
/** Rate limit: max requests per window (default: 60) */
rateLimit?: number;
/** Rate limit window in milliseconds (default: 60000 = 1 minute) */
rateLimitWindow?: number;
}

// Service name validation: lowercase alphanumeric with hyphens, 2-63 chars (Docker compatible)
const SERVICE_NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$/;
Comment on lines +26 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Minor: Comment states "2-63 chars" but regex allows single-character names.

The comment says "2-63 chars" but the regex alternation |^[a-z0-9]$ allows single-character names. The error message on line 44 correctly states "1-63 chars".

Suggested fix
-// Service name validation: lowercase alphanumeric with hyphens, 2-63 chars (Docker compatible)
+// Service name validation: lowercase alphanumeric with hyphens, 1-63 chars (Docker compatible)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Service name validation: lowercase alphanumeric with hyphens, 2-63 chars (Docker compatible)
const SERVICE_NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$/;
// Service name validation: lowercase alphanumeric with hyphens, 1-63 chars (Docker compatible)
const SERVICE_NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$/;
🤖 Prompt for AI Agents
In `@packages/http/src/server.ts` around lines 26 - 27, The inline comment above
SERVICE_NAME_REGEX is incorrect: it says "2-63 chars" while the regex
alternation (SERVICE_NAME_REGEX) allows a single-character name and the error
message later states "1-63 chars"; update the comment to match the regex and
error text (e.g., "1-63 chars") so the comment accurately reflects
SERVICE_NAME_REGEX and the validation behavior.


/**
* Validates and sanitizes service name to prevent path traversal and ensure Docker compatibility
*/
function validateServiceName(name: string): { valid: boolean; error?: string } {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'Service name is required' };
}

// Block path traversal attempts
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
return { valid: false, error: 'Service name contains invalid characters' };
}

// Validate Docker-compatible naming
if (!SERVICE_NAME_REGEX.test(name)) {
return { valid: false, error: 'Service name must be lowercase alphanumeric with hyphens (1-63 chars)' };
}

return { valid: true };
}

/**
* Simple in-memory rate limiter
*/
function createRateLimiter(maxRequests: number, windowMs: number) {
const requests = new Map<string, { count: number; resetTime: number }>();

return {
check(clientId: string): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const record = requests.get(clientId);

if (!record || now > record.resetTime) {
requests.set(clientId, { count: 1, resetTime: now + windowMs });
return { allowed: true };
}

if (record.count >= maxRequests) {
return { allowed: false, retryAfter: Math.ceil((record.resetTime - now) / 1000) };
}

record.count++;
return { allowed: true };
},

// Cleanup old entries periodically
cleanup() {
const now = Date.now();
for (const [key, record] of requests.entries()) {
if (now > record.resetTime) {
requests.delete(key);
}
}
}
};
}

const startTime = Date.now();

export function createServer(options: ServerOptions = {}) {
const { port = 3000, host = 'localhost', servicesPath = './services' } = options;
const {
port = 3000,
host = 'localhost',
servicesPath = './services',
apiKey,
rateLimit = 60,
rateLimitWindow = 60000,
} = options;

const resolvedServicesPath = resolve(servicesPath);
const rateLimiter = createRateLimiter(rateLimit, rateLimitWindow);

const cleanupInterval = setInterval(() => rateLimiter.cleanup(), rateLimitWindow);

const app = new Elysia()
.use(cors())
.onError(({ code, error, set }) => {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Request error: ${errorMessage}`);
set.status = code === 'NOT_FOUND' ? 404 : 500;
if (!errorMessage.includes('Rate limit') && !errorMessage.includes('Unauthorized')) {
logger.error(`Request error: ${errorMessage}`);
}
if (set.status === 200) {
set.status = code === 'NOT_FOUND' ? 404 : 500;
}
return {
error: errorMessage,
code: String(code),
Expand All @@ -37,14 +120,46 @@ export function createServer(options: ServerOptions = {}) {
version: '0.1.0',
uptime: Math.floor((Date.now() - startTime) / 1000),
}))
.derive(({ request, set }) => {
const clientIp = request.headers.get('x-forwarded-for') || 'unknown';
const rateLimitResult = rateLimiter.check(clientIp);

if (!rateLimitResult.allowed) {
set.status = 429;
set.headers['Retry-After'] = String(rateLimitResult.retryAfter);
throw new Error(`Rate limit exceeded. Retry after ${rateLimitResult.retryAfter} seconds`);
}
Comment on lines +123 to +131
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

safely extract client IP x-forwarded-for header Node.js best practices

💡 Result:

Best practices (Node.js / Express) for safely extracting the real client IP

  1. Treat X-Forwarded-For as untrusted input unless you control the proxy chain. Clients can send/spoof X-Forwarded-* headers; only trust them when your app is definitely behind known reverse proxies/load balancers you control. The same general warning applies to the standardized Forwarded header too. [4]

  2. In Express, prefer req.ip / req.ips + correctly configured trust proxy (don’t manually split X-Forwarded-For). Express will select the client IP by walking the chain and finding the first untrusted address (right-to-left) based on your trust proxy configuration. Misconfiguring trust proxy can let clients influence what IP you see. [1]

  3. Configure trust proxy narrowly (CIDRs / known proxy IPs / exact hop count), not just true, whenever possible.

    • app.set('trust proxy', true) makes Express treat the left-most X-Forwarded-For value as the client IP (easy to spoof if requests can reach your app without going through your proxy). [1]
    • Prefer explicit IPs/CIDRs of your proxy tiers, or a fixed hop count only if the path length is guaranteed. Express documents pitfalls if multiple path lengths exist. [1]
  4. If you’re not using Express, use a battle-tested parser (proxy-addr) with a trust policy. Express uses proxy-addr internally; it returns the closest untrusted address according to your trust function/list. [2]

  5. Prefer single-purpose “real IP” headers from your edge provider when available (and only accept them from that provider).

    • Example (Cloudflare): CF-Connecting-IP / True-Client-IP contain a single IP and are recommended over X-Forwarded-For for origin apps/logs, but still must be trusted only when the request actually came from Cloudflare. [3]

Express example (recommended pattern)

import express from "express";

const app = express();

// Trust only your proxy/load balancer networks (examples; use YOUR real CIDRs/IPs).
app.set("trust proxy", [
  "10.0.0.0/8",
  "172.16.0.0/12",
  "192.168.0.0/16",
]);

app.get("/", (req, res) => {
  // Express picks the correct client IP based on trust proxy settings
  res.json({ ip: req.ip, chain: req.ips });
});

(How Express determines req.ip/req.ips and why correct trust proxy matters is described here. [1])


Plain Node / any framework example with proxy-addr

import proxyaddr from "proxy-addr";

const trust = proxyaddr.compile(["10.0.0.0/8", "172.16.0.0/12"]); // your proxies

function getClientIp(req) {
  return proxyaddr(req, trust); // closest untrusted address
}

(proxy-addr API and “closest untrusted address” behavior. [2])


Sources

[1] Express “Behind Proxies” (trust proxy, req.ip, right-to-left evaluation, hop-count caveats) (expressjs.com)
[2] proxy-addr documentation (closest untrusted address; compile trust) (npmjs.com)
[3] Cloudflare headers guidance (prefer CF-Connecting-IP / True-Client-IP; X-Forwarded-For behavior) (developers.cloudflare.com)
[4] RFC 7239 security considerations (forwarded headers can’t be relied on; whitelist trusted proxies) (pike.lysator.liu.se)

Citations:


🌐 Web query:

x-forwarded-for spoofing security risks rate limiting

💡 Result:

Why X‑Forwarded‑For (XFF) spoofing matters (especially for rate limiting)

X-Forwarded-For is just an HTTP request header. Unless you control and trust the proxy that sets it, the client can send any value they want, and many servers/frameworks will mistakenly treat it as the “real client IP” (often by taking the first IP in the list). This enables straightforward bypasses of IP-based controls like rate limiting and allow/deny lists. [2]

RFC 7239 (the standardized Forwarded header) explicitly warns that forwarding headers cannot be relied on because they can be modified by any node in the path (including the client), and even “whitelisting proxies” still doesn’t make the earlier IP chain trustworthy. [1]

Common security risks

  • Rate-limit bypass / abuse amplification: attacker rotates fake XFF values to appear as many different clients, evading per-IP throttles. [2]
  • Access-control bypass: systems that allow/deny based on “internal/trusted IPs” from XFF can be tricked by sending private/internal IPs in XFF. [4]
  • Bad audit logs / forensics gaps: logs attribute actions to spoofed IPs, harming incident response and abuse detection. (Implied by the header’s lack of integrity.) [1]
  • Incorrect geo/risk decisions: geo-fencing, fraud scoring, bot detection, and anomaly detection keyed to client IP become unreliable. [1]

How to do rate limiting safely when proxies/CDNs are involved

  1. Never trust XFF from the open Internet. Only use it if the request arrived from a trusted reverse proxy/CDN IP range that you control/explicitly trust. [1]
  2. At the edge, overwrite/normalize forwarding headers. Your edge proxy/CDN should set the “client IP” signal itself and ignore/strip client-supplied values (otherwise spoofed values remain at the front of XFF). [2]
  3. Prefer a single-IP header your provider guarantees at the origin. Example: Cloudflare recommends using CF-Connecting-IP (single IP) rather than XFF (list that may include client-supplied values). [3]
  4. Parse forwarding chains defensively. If you must consume XFF/Forwarded, don’t blindly take the first IP; determine the client IP relative to your known trusted proxy chain (because only the portion added by trusted infrastructure is meaningful). [1][2]
  5. Don’t key rate limits only on IP. Use stronger identifiers when available (API key, user ID, session, device fingerprint), with IP as a secondary signal—this was a key mitigation after real-world XFF-based rate-limit bypasses. [2]

Rule of thumb: if an attacker can send it as a header, it’s input—treat it as untrusted unless your network boundary guarantees otherwise. [1][2]

Sources: RFC 7239 security considerations [1]; StackHawk write-up on XFF rate-limit bypass and fixes [2]; Cloudflare header guidance (CF-Connecting-IP vs XFF) [3]; Acunetix advisory on XFF-based security bypass [4].


🌐 Web query:

x-forwarded-for comma separated multiple IPs format

💡 Result:

X-Forwarded-For (XFF) is a comma-separated list of IP addresses:

  • Format / syntax:
    X-Forwarded-For: <client>, <proxy1>, <proxy2> (commas may have optional surrounding whitespace). [1]
  • Ordering meaning (when proxies behave correctly):
    Leftmost = original client, and each proxy that forwards the request appends its own address to the right; rightmost = most recent proxy. [1]
  • Multiple header lines:
    A request can include multiple X-Forwarded-For headers; they must be treated as one combined list (effectively concatenated, in order, with commas). [1]
  • Security note:
    Don’t trust the leftmost value for security decisions unless you’re only accepting traffic through trusted proxies and you validate/strip incoming XFF appropriately. [1]

Example:
X-Forwarded-For: 203.0.113.195, 2001:db8::1, 198.51.100.178 [1]

Related standard header (preferred when supported): Forwarded (RFC 7239). [2]

Sources: [1] MDN X-Forwarded-For header, [2] RFC 7239.


Rate limiting can be bypassed via spoofed x-forwarded-for header without a trusted proxy configuration.

The code extracts client IP directly from the x-forwarded-for header without validating that the request came through a trusted reverse proxy, allowing clients to send arbitrary header values to bypass rate limits. Additionally, x-forwarded-for is comma-separated (format: client, proxy1, proxy2), but the code uses the raw header value without parsing. The 'unknown' fallback also creates a shared rate limit bucket that could be exploited.

Address this by:

  1. Documenting that this service must run exclusively behind a trusted reverse proxy that overwrites untrusted x-forwarded-for values
  2. Using a library like proxy-addr (or Express's trust proxy configuration) with an explicit whitelist of trusted proxy IPs/CIDRs rather than manual header extraction
  3. If direct client connection without proxies must be supported, add a configuration option to switch IP extraction strategies (e.g., use req.socket.remoteAddress when not proxied)
🤖 Prompt for AI Agents
In `@packages/http/src/server.ts` around lines 123 - 131, The current extraction
of client IP in the .derive(({ request, set }) => { ... }) middleware reads
request.headers.get('x-forwarded-for') raw and falls back to 'unknown', which is
spoofable and improperly parsed; update this to (1) support a configuration flag
(e.g., trustProxy / trustedProxies) that determines whether to use proxied
headers or direct socket address, (2) when trusting proxies, use a proven
library like proxy-addr (or equivalent) with an explicit trustedProxies
whitelist to resolve the client IP instead of reading the header directly, (3)
if you cannot use proxy-addr, at minimum parse x-forwarded-for by taking the
first non-empty, trimmed token before any comma and validate it, (4) when not
trusting proxies, use request.socket.remoteAddress (or framework equivalent) as
the client IP, and (5) remove the 'unknown' shared-bucket fallback—require a
resolved IP and handle missing/invalid values by denying or using a distinct
per-request fallback; pass the normalized IP to rateLimiter.check(clientIp) and
keep setting set.status and Retry-After as before.


if (apiKey) {
const authHeader = request.headers.get('authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;

if (!token || token !== apiKey) {
set.status = 401;
throw new Error('Unauthorized: Invalid or missing API key');
}
Comment on lines +137 to +140
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Timing attack vulnerability in API key comparison.

Direct string comparison with !== leaks timing information that could allow attackers to guess the API key character by character. Use a constant-time comparison function.

Suggested fix using crypto.timingSafeEqual
+import { timingSafeEqual } from 'node:crypto';
+
+function safeCompare(a: string, b: string): boolean {
+  if (a.length !== b.length) {
+    // Compare against itself to maintain constant time even on length mismatch
+    const buf = Buffer.from(a);
+    timingSafeEqual(buf, buf);
+    return false;
+  }
+  return timingSafeEqual(Buffer.from(a), Buffer.from(b));
+}

// Then in the derive block:
-        if (!token || token !== apiKey) {
+        if (!token || !safeCompare(token, apiKey)) {
🤖 Prompt for AI Agents
In `@packages/http/src/server.ts` around lines 137 - 140, Replace the direct
string comparison of token !== apiKey with a constant-time comparison: convert
both token and apiKey to Buffers (ensuring lengths match) and use
crypto.timingSafeEqual to compare them; handle the case where token is missing
or lengths differ by failing the check and keeping the same response behavior
(set.status = 401; throw new Error(...)). Update the check around the token
variable in the request handling block (the code that currently uses token and
apiKey) to perform this buffered, timing-safe comparison and preserve the
existing error message and status on mismatch.

}

return {};
})
.post(
'/services/:serviceName/execute',
async ({ params, body, set }): Promise<ServiceExecutionResponse> => {
const { serviceName } = params;
const { input, skipPreflight, skipBuild } = body as ServiceExecutionRequest;
const { input, skipPreflight, skipBuild, audit } = body as ServiceExecutionRequest;

const validation = validateServiceName(serviceName);
if (!validation.valid) {
set.status = 400;
return {
success: false,
serviceName,
error: validation.error,
};
}

try {
const servicePath = `${servicesPath}/${serviceName}`;
const servicePath = join(resolvedServicesPath, serviceName);
const service = await loadService(servicePath);

let preflightResult = undefined;
Expand All @@ -66,7 +181,7 @@ export function createServer(options: ServerOptions = {}) {
}
}

const metrics = await executeService(service, { input, skipBuild });
const metrics = await executeService(service, { input, skipBuild, audit });

return {
success: true,
Expand All @@ -90,14 +205,24 @@ export function createServer(options: ServerOptions = {}) {
input: t.Optional(t.Unknown()),
skipPreflight: t.Optional(t.Boolean()),
skipBuild: t.Optional(t.Boolean()),
audit: t.Optional(t.Boolean()),
}),
}
)
.get('/services/:serviceName/preflight', async ({ params, set }): Promise<ServicePreflightResponse | ErrorResponse> => {
const { serviceName } = params;

const validation = validateServiceName(serviceName);
if (!validation.valid) {
set.status = 400;
return {
error: validation.error!,
code: 'INVALID_SERVICE_NAME',
};
}

try {
const servicePath = `${servicesPath}/${serviceName}`;
const servicePath = join(resolvedServicesPath, serviceName);
const service = await loadService(servicePath);
const imageName = getImageName(service.config.service.name);

Expand All @@ -121,7 +246,7 @@ export function createServer(options: ServerOptions = {}) {
.get('/services', async ({ set }): Promise<{ services: string[] } | ErrorResponse> => {
try {
const { readdir } = await import('node:fs/promises');
const entries = await readdir(servicesPath, { withFileTypes: true });
const entries = await readdir(resolvedServicesPath, { withFileTypes: true });
const services = entries.filter((e) => e.isDirectory()).map((e) => e.name);
return { services };
} catch (err) {
Expand All @@ -146,6 +271,7 @@ export function createServer(options: ServerOptions = {}) {
},
stop: () => {
if (isRunning) {
clearInterval(cleanupInterval);
app.stop();
isRunning = false;
logger.info('Ignite HTTP server stopped');
Expand Down
1 change: 1 addition & 0 deletions packages/http/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface ServiceExecutionRequest {
input?: unknown;
skipPreflight?: boolean;
skipBuild?: boolean;
audit?: boolean;
}

export interface ServiceExecutionResponse {
Expand Down
8 changes: 6 additions & 2 deletions packages/runtime-bun/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
FROM oven/bun:1.3-alpine

RUN adduser -D -u 1001 ignite

WORKDIR /app

COPY package.json bun.lockb* ./
RUN if [ -f package.json ]; then bun install --production --frozen-lockfile 2>/dev/null || bun install --production; fi

COPY . .
COPY --chown=ignite:ignite . .

ARG ENTRY_FILE=index.ts
ENV ENTRY_FILE=${ENTRY_FILE}
Expand All @@ -29,6 +31,8 @@ RUN printf '%s\n' \
' process.stderr.write("IGNITE_INIT_TIME:" + initTime + "\\n");' \
' process.stderr.write("IGNITE_MEMORY_MB:" + mem + "\\n");' \
'});' \
> /entrypoint.ts
> /entrypoint.ts && chown ignite:ignite /entrypoint.ts

USER ignite

CMD ["bun", "run", "/entrypoint.ts"]
8 changes: 6 additions & 2 deletions packages/runtime-node/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
FROM node:20-alpine

RUN adduser -D -u 1001 ignite

WORKDIR /app

COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci --only=production; \
elif [ -f package.json ]; then npm install --only=production; \
fi

COPY . .
COPY --chown=ignite:ignite . .

ARG ENTRY_FILE=index.js
ENV ENTRY_FILE=${ENTRY_FILE}
Expand All @@ -31,6 +33,8 @@ RUN printf '%s\n' \
' process.stderr.write("IGNITE_INIT_TIME:" + initTime + "\\n");' \
' process.stderr.write("IGNITE_MEMORY_MB:" + mem + "\\n");' \
'});' \
> /entrypoint.mjs
> /entrypoint.mjs && chown ignite:ignite /entrypoint.mjs

USER ignite

CMD ["node", "/entrypoint.mjs"]