-
Notifications
You must be signed in to change notification settings - Fork 0
feat(security): add authentication, rate limiting, and container hardening #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
|
|
@@ -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]$/; | ||
|
|
||
| /** | ||
| * 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), | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Best practices (Node.js / Express) for safely extracting the real client IP
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 Plain Node / any framework example with
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Timing attack vulnerability in API key comparison. Direct string comparison with 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 |
||
| } | ||
|
|
||
| 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; | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
|
||
|
|
@@ -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) { | ||
|
|
@@ -146,6 +271,7 @@ export function createServer(options: ServerOptions = {}) { | |
| }, | ||
| stop: () => { | ||
| if (isRunning) { | ||
| clearInterval(cleanupInterval); | ||
| app.stop(); | ||
| isRunning = false; | ||
| logger.info('Ignite HTTP server stopped'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
📝 Committable suggestion
🤖 Prompt for AI Agents