Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
46 changes: 41 additions & 5 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ const RUNNING_VERSION = (() => {
return null;
}
})();
const SERVER_PORT = Number.parseInt(process.env.SERVER_PORT || '3001', 10);
const HOST = process.env.HOST || '0.0.0.0';
const DISPLAY_HOST = getConnectableHost(HOST);
const VITE_PORT = process.env.VITE_PORT || 5173;
const systemRoutes = createSystemModule({
appRoot: APP_ROOT,
installMode,
Expand Down Expand Up @@ -119,7 +123,43 @@ const wss = createWebSocketServer(server, {
// Make WebSocket server available to routes
app.locals.wss = wss;

app.use(cors({ exposedHeaders: ['X-Refreshed-Token', 'X-Auth-Error'] }));
// CORS — only reflect origins that share a host:port with the server. The
// default `cors()` configuration reflects any Origin header, which lets any
// malicious site make authenticated cross-origin requests against this server
// when a victim visits it in the same browser session. Limiting the
// reflected origin to the server's own loopback / LAN addresses keeps the
// browser same-origin policy intact without breaking the local Electron app
// or LAN-hosted deployments.
const corsOriginReflector = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
// No Origin header → same-origin request (e.g. server-to-server, curl);
// these are not subject to CORS and should always be allowed through.
if (!origin) {
callback(null, true);
return;
}

try {
const parsed = new URL(origin);
const requestHost = parsed.hostname;
const requestPort = parsed.port || (parsed.protocol === 'https:' ? '443' : '80');
const serverHost = HOST === '0.0.0.0' || HOST === '::' ? requestHost : HOST;
const serverPort = String(SERVER_PORT);

if (requestHost === serverHost && requestPort === serverPort) {
callback(null, true);
return;
}
} catch {
// Malformed Origin header — refuse.
}

callback(null, false);
};

app.use(cors({
origin: corsOriginReflector,
exposedHeaders: ['X-Refreshed-Token', 'X-Auth-Error'],
}));
app.use(express.json({
limit: '50mb',
type: (req) => {
Expand Down Expand Up @@ -272,10 +312,6 @@ app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
});
});

const SERVER_PORT = Number.parseInt(process.env.SERVER_PORT || '3001', 10);
const HOST = process.env.HOST || '0.0.0.0';
const DISPLAY_HOST = getConnectableHost(HOST);
const VITE_PORT = process.env.VITE_PORT || 5173;
const LOCAL_SERVER_MARKER_PATH = path.join(os.homedir(), '.cloudcli', 'local-server.json');

function getErrorCode(error: unknown): string | undefined {
Expand Down
19 changes: 17 additions & 2 deletions server/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,24 @@ import express from 'express';
import type { RequestHandler } from 'express';

import type { createAuthService } from './auth.service.js';
import { createRateLimiter } from './rate-limit.middleware.js';

type AuthenticatedRequest = express.Request & { user?: unknown };

// 10 attempts per IP per minute is generous for legitimate use (the only
// registered user is the local admin) but tight enough to slow down online
// credential stuffing. Lockouts extend the window by an additional minute so
// a misconfigured client cannot hammer the endpoint forever.
const AUTH_RATE_LIMIT_MAX = 10;
const AUTH_RATE_LIMIT_WINDOW_MS = 60_000;
const AUTH_RATE_LIMIT_LOCKOUT_MS = 60_000;

const authRateLimit = createRateLimiter({
maxAttempts: AUTH_RATE_LIMIT_MAX,
windowMs: AUTH_RATE_LIMIT_WINDOW_MS,
lockoutMs: AUTH_RATE_LIMIT_LOCKOUT_MS,
});

/**
* Creates the Auth transport adapter. Handlers only parse request data and
* delegate authentication behavior to the injected application service.
Expand All @@ -23,7 +38,7 @@ export function createAuthRouter(
}
});

router.post('/register', async (req, res, next) => {
router.post('/register', authRateLimit.middleware, async (req, res, next) => {
try {
const body = req.body as { username?: unknown; password?: unknown };
res.json(await service.register(body.username, body.password));
Expand All @@ -32,7 +47,7 @@ export function createAuthRouter(
}
});

router.post('/login', async (req, res, next) => {
router.post('/login', authRateLimit.middleware, async (req, res, next) => {
try {
const body = req.body as { username?: unknown; password?: unknown };
res.json(await service.login(body.username, body.password));
Expand Down
127 changes: 127 additions & 0 deletions server/modules/auth/rate-limit.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// rate-limit middleware exposes a single factory that the auth router mounts
// on the login and register endpoints. Keeping the failure path explicit (no
// shared store) so the limit applies per-process without coupling to the
// persistence layer.
import type { Request, RequestHandler, Response } from 'express';

type RateLimiterOptions = {
/** Maximum number of attempts allowed within the rolling window. */
maxAttempts: number;
/** Length of the rolling window, in milliseconds. */
windowMs: number;
/** How long a locked-out client should be told to wait before retrying. */
lockoutMs?: number;
/** Optional clock for tests. */
now?: () => number;
};

type AttemptRecord = {
/** Timestamps (ms) of attempts that fall inside the rolling window. */
timestamps: number[];
/** Earliest time at which the client may try again after a lockout. */
blockedUntil: number;
};

function readClientKey(req: Request): string {
// Prefer the address of the TCP peer; fall back to a header chain that
// includes the most common reverse-proxy forwarded-for conventions.
const socketAddress = req.socket?.remoteAddress;
if (socketAddress && socketAddress !== '::1' && socketAddress !== '127.0.0.1') {
return socketAddress;
}

const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]!.trim();
}
if (Array.isArray(forwarded) && forwarded.length > 0) {
return forwarded[0]!.split(',')[0]!.trim();
}

return socketAddress || 'unknown';
}

/**
* Builds a per-key sliding-window rate limiter suitable for protecting the
* login and registration endpoints against credential-stuffing attempts.
*
* The limiter keeps an in-memory `Map` of client-key → attempt history. Each
* incoming request drops expired timestamps from the history; if the count
* after dropping exceeds `maxAttempts`, the request is rejected with HTTP
* 429 until enough time has elapsed for at least one slot to expire.
*/
export function createRateLimiter(options: RateLimiterOptions): {
middleware: RequestHandler;
reset: () => void;
} {
const maxAttempts = options.maxAttempts;
const windowMs = options.windowMs;
const lockoutMs = options.lockoutMs ?? windowMs;
const clock = options.now ?? (() => Date.now());

const records = new Map<string, AttemptRecord>();

const middleware: RequestHandler = (req: Request, res: Response, next) => {
const clientKey = readClientKey(req);
const now = clock();
let record = records.get(clientKey);
if (!record) {
record = { timestamps: [], blockedUntil: 0 };
records.set(clientKey, record);
}

// An active lockout short-circuits the limiter; do not consume an attempt
// slot so a misbehaving client cannot extend the lockout indefinitely.
if (record.blockedUntil > now) {
const retryAfterSeconds = Math.max(1, Math.ceil((record.blockedUntil - now) / 1000));
res.setHeader('Retry-After', String(retryAfterSeconds));
res.status(429).json({
success: false,
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts. Please try again later.',
retryAfterSeconds,
},
});
return;
}

// Drop timestamps that have aged out of the rolling window.
const cutoff = now - windowMs;
record.timestamps = record.timestamps.filter((timestamp) => timestamp > cutoff);

if (record.timestamps.length >= maxAttempts) {
// `lockoutMs` is the minimum backoff, but `Retry-After` must reflect
// the *next* time a request can succeed — i.e. when at least one of
// the retained timestamps ages out of the rolling window. If the
// operator configured `lockoutMs < windowMs`, the rolling window
// outlives the lockout, so blocking for only `lockoutMs` would let a
// client come back and immediately trigger another lockout despite the
// previous response telling it to wait.
const earliestExpiry = record.timestamps.length > 0
? record.timestamps[0]! + windowMs
: now + windowMs;
const nextAvailableAt = Math.max(now + lockoutMs, earliestExpiry);
record.blockedUntil = nextAvailableAt;
const retryAfterSeconds = Math.max(1, Math.ceil((nextAvailableAt - now) / 1000));
res.setHeader('Retry-After', String(retryAfterSeconds));
res.status(429).json({
success: false,
error: {
code: 'RATE_LIMITED',
message: 'Too many attempts. Please try again later.',
retryAfterSeconds,
},
});
return;
}

record.timestamps.push(now);
next();
};

return {
middleware,
reset: () => records.clear(),
};
}
Loading