feat: clean install (Gitea en dur, scan off) + durcissement sécurité entreprise
Gitea - lien « Code source » hardcodé (REPO_SOURCE_URL) vers le dépôt, toujours visible - retrait complet du réglage configurable (api.ts, route+store settings, SettingsView, i18n, help, tests) Découverte des dépôts - aucune racine de scan par défaut → pas de scan au premier démarrage (clean install) - corrige la découverte des dépôts à l'INTÉRIEUR d'une racine qui est elle-même un repo (depth 0 = conteneur de scan, on descend ; depth > 0 = feuille) Sécurité « enterprise-deployable » - en-têtes HTTP durcis (CSP, X-Frame-Options, nosniff, Referrer-Policy, HSTS conditionnel, no-store API), header Server retiré - permissions DB 0o600 / dossier de données 0o700 ; error handler sanitisé ; garde Content-Type sur les mutations - chiffrement au repos AES-256-GCM des secrets (server_secret, clé privée VAPID) via SecretBox - journal d'audit (migration #7) + endpoint /audit-logs + RGPD export/effacement + UI Réglages - SECURITY.md, docs/ENTERPRISE_DEPLOYMENT.md, sections README EN/FR, SBOM CycloneDX en CI CI - pack-smoke packe depuis le contexte du package (cd packages/server) au lieu de « npm pack -w » : corrige l'embarquement de @arboretum/shared dans le tarball Purge des données personnelles du dépôt public - suppression de spikes/s4-discovery/result.json, reformulation du VERDICT - chemin de test générique, anonymisation des 5 fixtures de dialogues
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||
import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -14,6 +14,7 @@ import { WorktreeManager } from './core/worktree-manager.js';
|
||||
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
||||
import { GroupManager } from './core/group-manager.js';
|
||||
import { PushService } from './core/push-service.js';
|
||||
import { loadSecretBox } from './core/secret-box.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerRepoRoutes } from './routes/repos.js';
|
||||
@@ -22,7 +23,37 @@ import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
import { registerPushRoutes } from './routes/push.js';
|
||||
import { registerSettingsRoutes } from './routes/settings.js';
|
||||
import { registerFsRoutes } from './routes/fs.js';
|
||||
import { registerAuditRoutes } from './routes/audit.js';
|
||||
import { registerDataRoutes } from './routes/data.js';
|
||||
import { registerWsGateway } from './ws/gateway.js';
|
||||
import { isHttpsRequest } from './routes/auth.js';
|
||||
|
||||
// En-têtes de sécurité posés sur TOUTES les réponses (defense-in-depth + conformité scanners
|
||||
// entreprise). Le terminal web reste du RCE par conception : ces en-têtes durcissent la SPA et
|
||||
// le transport, ils ne remplacent pas le modèle loopback + auth + Origin.
|
||||
const SECURITY_HEADERS: Record<string, string> = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=()',
|
||||
// CSP calibrée pour la SPA : Tailwind/xterm injectent du style inline ; le WebSocket impose
|
||||
// ws:/wss: en connect-src ; le service worker push impose worker-src 'self'.
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' ws: wss:",
|
||||
"worker-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"form-action 'self'",
|
||||
].join('; '),
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
@@ -46,9 +77,11 @@ export interface AppBundle {
|
||||
|
||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
||||
const auth = new AuthService(db);
|
||||
// Chiffrement au repos des secrets (server_secret, clé privée VAPID) dans la base.
|
||||
const box = loadSecretBox(config.dataDir);
|
||||
const auth = new AuthService(db, box);
|
||||
const limiter = new LoginRateLimiter();
|
||||
const push = new PushService(db, config.vapidContact);
|
||||
const push = new PushService(db, config.vapidContact, undefined, box);
|
||||
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
||||
const discovery = new DiscoveryService({
|
||||
ptyManager: manager,
|
||||
@@ -60,6 +93,43 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
|
||||
const groups = new GroupManager(db);
|
||||
|
||||
// En-têtes de sécurité sur toute réponse + no-store sur les réponses sensibles (API/WS).
|
||||
// onSend DOIT retourner le payload (sinon Fastify vide la réponse).
|
||||
app.addHook('onSend', async (req, reply, payload) => {
|
||||
reply.removeHeader('Server'); // anti-fingerprinting (no-op si absent)
|
||||
for (const [k, v] of Object.entries(SECURITY_HEADERS)) reply.header(k, v);
|
||||
if (isHttpsRequest(req)) {
|
||||
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
||||
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
// Garde CSRF defense-in-depth : une mutation porteuse d'un corps DOIT être en application/json
|
||||
// (bloque les soumissions de formulaire cross-site en text/plain, que Fastify parserait sinon).
|
||||
app.addHook('preHandler', async (req, reply) => {
|
||||
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return;
|
||||
const len = req.headers['content-length'];
|
||||
if (!len || len === '0') return; // pas de corps : rien à valider
|
||||
const ct = (req.headers['content-type'] ?? '').toLowerCase();
|
||||
if (!ct.startsWith('application/json')) {
|
||||
return reply.status(415).send({ error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: 'Content-Type must be application/json' } });
|
||||
}
|
||||
});
|
||||
|
||||
// Handler d'erreur : ne jamais fuiter de stack/chemin au client ; détail complet côté logs.
|
||||
app.setErrorHandler((err: FastifyError, req, reply) => {
|
||||
const status = err.statusCode && err.statusCode >= 400 && err.statusCode < 600 ? err.statusCode : 500;
|
||||
if (status >= 500) {
|
||||
req.log.error({ err }, 'unhandled error');
|
||||
return reply.status(status).send({ error: { code: 'INTERNAL', message: 'Internal Server Error' } });
|
||||
}
|
||||
// erreurs client (4xx) : code générique, message court non sensible.
|
||||
return reply.status(status).send({ error: { code: err.code ?? 'BAD_REQUEST', message: err.message } });
|
||||
});
|
||||
|
||||
void app.register(fastifyCookie);
|
||||
void app.register(fastifyWebsocket, {
|
||||
options: { maxPayload: 1024 * 1024 },
|
||||
@@ -97,14 +167,16 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
}
|
||||
});
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||
registerSessionRoutes(app, manager, discovery);
|
||||
registerRepoRoutes(app, worktrees, db);
|
||||
registerGroupRoutes(app, groups);
|
||||
registerGroupRoutes(app, groups, db);
|
||||
registerWorktreeRoutes(app, worktrees);
|
||||
registerPushRoutes(app, push);
|
||||
registerPushRoutes(app, push, db);
|
||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||
registerFsRoutes(app);
|
||||
registerAuditRoutes(app, db);
|
||||
registerDataRoutes(app, db, auth);
|
||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||
void app.register(async (scoped) => {
|
||||
|
||||
Reference in New Issue
Block a user