feat: harden control panel authentication

This commit is contained in:
thanhnv
2026-07-10 23:48:12 +09:00
parent 48b1186439
commit b56f7d357e
11 changed files with 571 additions and 73 deletions
@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { execFileSync, spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
@@ -83,12 +83,15 @@ function safeTenant(value: string): string {
@Injectable()
export class GoalsService {
private readonly startWindows = new Map<string, number[]>();
async start(input: GoalStartInput, actor: SettingsActor): Promise<GoalJob> {
this.requireRead(actor);
const goal = String(input.goal ?? '').trim();
if (goal.length < 10 || goal.length > 8000) {
throw new BadRequestException('GOAL_LENGTH_INVALID');
}
this.enforceStartLimit(actor);
const connections = this.connections(actor);
const local = connections.find((connection) => connection.connected && connection.kind === 'local');
@@ -200,6 +203,7 @@ export class GoalsService {
}
private async accountReviewer(): Promise<'claude' | 'codex' | ''> {
if (process.env.CASAN_PROVIDER_ACCOUNT_AUTH_ENABLED !== '1') return '';
const bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || '').replace(/\/$/, '');
const bridgeToken = process.env.CASAN_AUTH_BRIDGE_TOKEN || '';
if (!bridgeUrl || !bridgeToken) return '';
@@ -219,6 +223,27 @@ export class GoalsService {
return '';
}
private enforceStartLimit(actor: SettingsActor): void {
const key = `${safeTenant(actor.tenant)}:${actor.actor}`;
const timestamp = Date.now();
const recent = (this.startWindows.get(key) ?? []).filter((value) => timestamp - value < 10 * 60_000);
if (recent.length >= 5) {
throw new HttpException('GOAL_RATE_LIMITED', HttpStatus.TOO_MANY_REQUESTS);
}
const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant));
if (existsSync(directory)) {
const active = readdirSync(directory)
.filter((name) => /^[a-f0-9-]{36}\.json$/.test(name))
.map((name) => parseJson<GoalJob>(readFileSync(join(directory, name), 'utf8')))
.filter((job) => job?.actor === actor.actor && (job.status === 'queued' || job.status === 'running'));
if (active.length >= 2) {
throw new HttpException('GOAL_CONCURRENCY_LIMITED', HttpStatus.TOO_MANY_REQUESTS);
}
}
recent.push(timestamp);
this.startWindows.set(key, recent);
}
private runPython(script: string, args: string[], environment: NodeJS.ProcessEnv): string {
try {
return execFileSync('python3', [script, ...args], {