Skip to content

Commit 39b1f18

Browse files
committed
feat(security): add authentication, rate limiting, and container hardening
- Add bearer token authentication to HTTP API (optional but recommended) - Add rate limiting with configurable limits (60 req/min default) - Fix path traversal vulnerability in serviceName parameter - Validate service names for Docker compatibility (1-63 chars, lowercase alphanumeric with hyphens) - Expose audit mode via HTTP API for sandboxed execution - Add non-root user (ignite:1001) to both Bun and Node Dockerfiles - Fix /health endpoint to bypass authentication for load balancer checks - Fix memory leak: clear rate limiter cleanup interval on server stop BREAKING CHANGE: None - all new features are opt-in
1 parent 8390f73 commit 39b1f18

File tree

5 files changed

+153
-12
lines changed

5 files changed

+153
-12
lines changed

packages/core/src/service/load-service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ function validateServiceConfig(config: unknown): ServiceValidation {
6666

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

7177
const runtime = service['runtime'];

packages/http/src/server.ts

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Elysia, t } from 'elysia';
22
import { cors } from '@elysiajs/cors';
3+
import { join, resolve } from 'node:path';
34
import { loadService, executeService, runPreflight, getImageName, buildServiceImage } from '@ignite/core';
45
import { logger } from '@ignite/shared';
56
import type {
@@ -14,19 +15,101 @@ export interface ServerOptions {
1415
port?: number;
1516
host?: string;
1617
servicesPath?: string;
18+
/** API key for bearer token authentication. If not set, auth is disabled (NOT RECOMMENDED for production) */
19+
apiKey?: string;
20+
/** Rate limit: max requests per window (default: 60) */
21+
rateLimit?: number;
22+
/** Rate limit window in milliseconds (default: 60000 = 1 minute) */
23+
rateLimitWindow?: number;
24+
}
25+
26+
// Service name validation: lowercase alphanumeric with hyphens, 2-63 chars (Docker compatible)
27+
const SERVICE_NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$/;
28+
29+
/**
30+
* Validates and sanitizes service name to prevent path traversal and ensure Docker compatibility
31+
*/
32+
function validateServiceName(name: string): { valid: boolean; error?: string } {
33+
if (!name || typeof name !== 'string') {
34+
return { valid: false, error: 'Service name is required' };
35+
}
36+
37+
// Block path traversal attempts
38+
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
39+
return { valid: false, error: 'Service name contains invalid characters' };
40+
}
41+
42+
// Validate Docker-compatible naming
43+
if (!SERVICE_NAME_REGEX.test(name)) {
44+
return { valid: false, error: 'Service name must be lowercase alphanumeric with hyphens (1-63 chars)' };
45+
}
46+
47+
return { valid: true };
48+
}
49+
50+
/**
51+
* Simple in-memory rate limiter
52+
*/
53+
function createRateLimiter(maxRequests: number, windowMs: number) {
54+
const requests = new Map<string, { count: number; resetTime: number }>();
55+
56+
return {
57+
check(clientId: string): { allowed: boolean; retryAfter?: number } {
58+
const now = Date.now();
59+
const record = requests.get(clientId);
60+
61+
if (!record || now > record.resetTime) {
62+
requests.set(clientId, { count: 1, resetTime: now + windowMs });
63+
return { allowed: true };
64+
}
65+
66+
if (record.count >= maxRequests) {
67+
return { allowed: false, retryAfter: Math.ceil((record.resetTime - now) / 1000) };
68+
}
69+
70+
record.count++;
71+
return { allowed: true };
72+
},
73+
74+
// Cleanup old entries periodically
75+
cleanup() {
76+
const now = Date.now();
77+
for (const [key, record] of requests.entries()) {
78+
if (now > record.resetTime) {
79+
requests.delete(key);
80+
}
81+
}
82+
}
83+
};
1784
}
1885

1986
const startTime = Date.now();
2087

2188
export function createServer(options: ServerOptions = {}) {
22-
const { port = 3000, host = 'localhost', servicesPath = './services' } = options;
89+
const {
90+
port = 3000,
91+
host = 'localhost',
92+
servicesPath = './services',
93+
apiKey,
94+
rateLimit = 60,
95+
rateLimitWindow = 60000,
96+
} = options;
97+
98+
const resolvedServicesPath = resolve(servicesPath);
99+
const rateLimiter = createRateLimiter(rateLimit, rateLimitWindow);
100+
101+
const cleanupInterval = setInterval(() => rateLimiter.cleanup(), rateLimitWindow);
23102

24103
const app = new Elysia()
25104
.use(cors())
26105
.onError(({ code, error, set }) => {
27106
const errorMessage = error instanceof Error ? error.message : String(error);
28-
logger.error(`Request error: ${errorMessage}`);
29-
set.status = code === 'NOT_FOUND' ? 404 : 500;
107+
if (!errorMessage.includes('Rate limit') && !errorMessage.includes('Unauthorized')) {
108+
logger.error(`Request error: ${errorMessage}`);
109+
}
110+
if (set.status === 200) {
111+
set.status = code === 'NOT_FOUND' ? 404 : 500;
112+
}
30113
return {
31114
error: errorMessage,
32115
code: String(code),
@@ -37,14 +120,46 @@ export function createServer(options: ServerOptions = {}) {
37120
version: '0.1.0',
38121
uptime: Math.floor((Date.now() - startTime) / 1000),
39122
}))
123+
.derive(({ request, set }) => {
124+
const clientIp = request.headers.get('x-forwarded-for') || 'unknown';
125+
const rateLimitResult = rateLimiter.check(clientIp);
126+
127+
if (!rateLimitResult.allowed) {
128+
set.status = 429;
129+
set.headers['Retry-After'] = String(rateLimitResult.retryAfter);
130+
throw new Error(`Rate limit exceeded. Retry after ${rateLimitResult.retryAfter} seconds`);
131+
}
132+
133+
if (apiKey) {
134+
const authHeader = request.headers.get('authorization');
135+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
136+
137+
if (!token || token !== apiKey) {
138+
set.status = 401;
139+
throw new Error('Unauthorized: Invalid or missing API key');
140+
}
141+
}
142+
143+
return {};
144+
})
40145
.post(
41146
'/services/:serviceName/execute',
42147
async ({ params, body, set }): Promise<ServiceExecutionResponse> => {
43148
const { serviceName } = params;
44-
const { input, skipPreflight, skipBuild } = body as ServiceExecutionRequest;
149+
const { input, skipPreflight, skipBuild, audit } = body as ServiceExecutionRequest;
150+
151+
const validation = validateServiceName(serviceName);
152+
if (!validation.valid) {
153+
set.status = 400;
154+
return {
155+
success: false,
156+
serviceName,
157+
error: validation.error,
158+
};
159+
}
45160

46161
try {
47-
const servicePath = `${servicesPath}/${serviceName}`;
162+
const servicePath = join(resolvedServicesPath, serviceName);
48163
const service = await loadService(servicePath);
49164

50165
let preflightResult = undefined;
@@ -66,7 +181,7 @@ export function createServer(options: ServerOptions = {}) {
66181
}
67182
}
68183

69-
const metrics = await executeService(service, { input, skipBuild });
184+
const metrics = await executeService(service, { input, skipBuild, audit });
70185

71186
return {
72187
success: true,
@@ -90,14 +205,24 @@ export function createServer(options: ServerOptions = {}) {
90205
input: t.Optional(t.Unknown()),
91206
skipPreflight: t.Optional(t.Boolean()),
92207
skipBuild: t.Optional(t.Boolean()),
208+
audit: t.Optional(t.Boolean()),
93209
}),
94210
}
95211
)
96212
.get('/services/:serviceName/preflight', async ({ params, set }): Promise<ServicePreflightResponse | ErrorResponse> => {
97213
const { serviceName } = params;
98214

215+
const validation = validateServiceName(serviceName);
216+
if (!validation.valid) {
217+
set.status = 400;
218+
return {
219+
error: validation.error!,
220+
code: 'INVALID_SERVICE_NAME',
221+
};
222+
}
223+
99224
try {
100-
const servicePath = `${servicesPath}/${serviceName}`;
225+
const servicePath = join(resolvedServicesPath, serviceName);
101226
const service = await loadService(servicePath);
102227
const imageName = getImageName(service.config.service.name);
103228

@@ -121,7 +246,7 @@ export function createServer(options: ServerOptions = {}) {
121246
.get('/services', async ({ set }): Promise<{ services: string[] } | ErrorResponse> => {
122247
try {
123248
const { readdir } = await import('node:fs/promises');
124-
const entries = await readdir(servicesPath, { withFileTypes: true });
249+
const entries = await readdir(resolvedServicesPath, { withFileTypes: true });
125250
const services = entries.filter((e) => e.isDirectory()).map((e) => e.name);
126251
return { services };
127252
} catch (err) {
@@ -146,6 +271,7 @@ export function createServer(options: ServerOptions = {}) {
146271
},
147272
stop: () => {
148273
if (isRunning) {
274+
clearInterval(cleanupInterval);
149275
app.stop();
150276
isRunning = false;
151277
logger.info('Ignite HTTP server stopped');

packages/http/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export interface ServiceExecutionRequest {
44
input?: unknown;
55
skipPreflight?: boolean;
66
skipBuild?: boolean;
7+
audit?: boolean;
78
}
89

910
export interface ServiceExecutionResponse {

packages/runtime-bun/Dockerfile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
FROM oven/bun:1.3-alpine
22

3+
RUN adduser -D -u 1001 ignite
4+
35
WORKDIR /app
46

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

8-
COPY . .
10+
COPY --chown=ignite:ignite . .
911

1012
ARG ENTRY_FILE=index.ts
1113
ENV ENTRY_FILE=${ENTRY_FILE}
@@ -29,6 +31,8 @@ RUN printf '%s\n' \
2931
' process.stderr.write("IGNITE_INIT_TIME:" + initTime + "\\n");' \
3032
' process.stderr.write("IGNITE_MEMORY_MB:" + mem + "\\n");' \
3133
'});' \
32-
> /entrypoint.ts
34+
> /entrypoint.ts && chown ignite:ignite /entrypoint.ts
35+
36+
USER ignite
3337

3438
CMD ["bun", "run", "/entrypoint.ts"]

packages/runtime-node/Dockerfile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
FROM node:20-alpine
22

3+
RUN adduser -D -u 1001 ignite
4+
35
WORKDIR /app
46

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

10-
COPY . .
12+
COPY --chown=ignite:ignite . .
1113

1214
ARG ENTRY_FILE=index.js
1315
ENV ENTRY_FILE=${ENTRY_FILE}
@@ -31,6 +33,8 @@ RUN printf '%s\n' \
3133
' process.stderr.write("IGNITE_INIT_TIME:" + initTime + "\\n");' \
3234
' process.stderr.write("IGNITE_MEMORY_MB:" + mem + "\\n");' \
3335
'});' \
34-
> /entrypoint.mjs
36+
> /entrypoint.mjs && chown ignite:ignite /entrypoint.mjs
37+
38+
USER ignite
3539

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

0 commit comments

Comments
 (0)