Skip to content

Plataforma técnica · Sevastopol

Middleware de Sevastopol

Sevastopol Middleware Seguridad

src/middleware.ts se ejecuta en cada request al servidor SSR de Astro antes de renderizar la página o invocar una ruta API. Tiene tres responsabilidades:

  1. Autorización por sesión — bloquea acceso a páginas HTML protegidas si no hay cookie sid válida.
  2. Validación contra Orchestrator — para páginas HTML sensibles confirma con /api/auth/validate que el sid no esté revocado.
  3. Security Headers + CSP — agrega cabeceras de seguridad a toda respuesta del servidor, incluidas las del BFF proxy.

El cookie sid se emite por /api/auth/login y vive en el navegador con flags HttpOnly y Secure (gestionado por Orchestrator). El middleware nunca lee su contenido, sólo verifica existencia o delega validación al backend.


flowchart TB
  REQ["Request entrante"]
  PUB{"¿Ruta pública?<br/>/ · /public/* · /api/auth/*"}
  TOK["Lee cookie sid"]
  HTML{"¿Es página HTML<br/>protegida?<br/>/dashboard · /settings · /registry"}
  HASCK{"¿hay sid?"}
  E2E{"sid == e2e-bypass-token?"}
  VAL["fetch /api/auth/validate<br/>al Orchestrator"]
  OK{"¿200 OK?"}
  API{"¿Es /api/* sin sid?"}
  NEXT["next() → ruta destino"]
  REDIR["redirect('/', 302)"]
  ERR401["Response 401 JSON"]
  HDRS["withSecurityHeaders()<br/>aplica CSP + headers"]
  RESP["Response al cliente"]
  REQ --> PUB
  PUB -->|sí| NEXT
  PUB -->|no| TOK --> HTML
  HTML -->|sí| HASCK
  HASCK -->|no| REDIR
  HASCK -->|sí| E2E
  E2E -->|sí| NEXT
  E2E -->|no| VAL --> OK
  OK -->|sí| NEXT
  OK -->|no o catch| REDIR
  HTML -->|no| API
  API -->|sí| ERR401
  API -->|no| NEXT
  NEXT --> HDRS
  REDIR --> HDRS
  ERR401 --> HDRS
  HDRS --> RESP

withSecurityHeaders() corre siempre en el camino de salida — ninguna respuesta del servidor sale sin CSP.


Tres prefijos pasan directo sin requerir sesión:

PatrónRazón
/Landing + página de login (formulario inicial)
/public/*Recursos accesibles sin sesión (assets, páginas marketing)
/api/auth/*Login, logout, validate, 2FA — el flujo de autenticación

Cualquier otra ruta pasa por el resto del middleware.


Tres prefijos representan el shell autenticado de Sevastopol:

if (
url.pathname.startsWith('/dashboard') ||
url.pathname.startsWith('/settings') ||
url.pathname.startsWith('/registry')
) {
// ...
}

Para estas rutas el middleware exige doble verificación:

const token = cookies.get('sid')?.value;
if (!token) {
return withSecurityHeaders(ctx.redirect('/', 302));
}

Sin sid el navegador vuelve al login (/).

Con cookie, el middleware verifica que la sesión esté vigente:

const validateRes = await fetch(`${ORCHESTRATOR_BASE_URL}/api/auth/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': `sid=${token}`,
},
credentials: 'include',
});
if (!validateRes.ok) {
return withSecurityHeaders(ctx.redirect('/', 302));
}

El catch también redirige — si Orchestrator está caído, no se sirve la página. Esto previene servir el shell con datos desactualizados o cookie comprometida.

Para tests de Playwright existe un atajo:

if (token === 'e2e-bypass-token') {
return withSecurityHeaders(await next());
}

Con ese valor literal el middleware salta la validación con Orchestrator. Sólo debe usarse en entornos de test — el valor está hardcodeado en el código y no es un secreto.


Para rutas bajo /api/* (las del BFF proxy), la verificación es más liviana:

if (url.pathname.startsWith('/api/') && !token) {
return withSecurityHeaders(jsonErr(401, 'No session found'));
}

Sólo se verifica existencia de cookie. La validación real (firma, expiración, scopes) la hace Orchestrator cuando el proxy le reenvía la request. Esto evita una llamada extra a /api/auth/validate por cada petición de datos.

Tipo de rutaVerificación middlewarePor qué
Página HTML protegidaExistencia + validaciónRenderizar el shell tiene costo; mejor abortar temprano
/api/* (BFF proxy)Sólo existencia de cookieOrchestrator validará al recibir el reenvío

Una llamada API con sid revocada llega a Orchestrator, recibe 401, y el frontend dispara redirección al login vía handleAuthResponse.


Toda respuesta (HTML, JSON, redirect, error) pasa por withSecurityHeaders() antes de salir.

HeaderValorPropósito
Content-Security-PolicyVer detalle abajoRestringe orígenes de scripts, estilos, conexiones
X-Frame-OptionsDENYProhíbe embeber Sevastopol en <iframe> externo
X-Content-Type-OptionsnosniffBloquea MIME sniffing en navegadores
X-XSS-Protection1; mode=blockActiva filtro XSS legacy (defensa en profundidad)
Referrer-Policystrict-origin-when-cross-originLimita información del referer cross-origin
Permissions-Policycamera=(), microphone=(), geolocation=()Deshabilita APIs sensibles del navegador
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self' <ORCHESTRATOR_ORIGIN> ws://localhost:*;
frame-src 'self' http://localhost:4322;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';

Decisiones por directiva:

  • script-src 'unsafe-inline' 'unsafe-eval' — requerido por SolidJS en modo dev (HMR + hidratación). En producción ideal eliminarlo, pero las islands hidratadas usan eval para reactividad.
  • connect-src — incluye dinámicamente ORCHESTRATOR_ORIGIN (calculado desde ORCHESTRATOR_BASE_URL) y ws://localhost:* para HMR de Vite.
  • frame-src 'self' http://localhost:4322 — permite embeber jean_d_arc en dev (página de docs interna). Quitar en producción si no se usa.
  • frame-ancestors 'none' — refuerza X-Frame-Options: DENY con el mecanismo moderno.

ORCHESTRATOR_ORIGIN se computa al cargar el módulo, no por request:

import { ORCHESTRATOR_BASE_URL } from '@/lib/orchestratorUrl';
const ORCHESTRATOR_ORIGIN = new URL(ORCHESTRATOR_BASE_URL).origin;

Cambiar la URL del Orchestrator requiere reiniciar el servidor para que el CSP refleje el nuevo origen.


src/middleware.ts
export const onRequest = defineMiddleware(async (ctx, next) => {
const { url, cookies } = ctx;
// 1. Rutas públicas
if (
url.pathname === '/' ||
url.pathname.startsWith('/public') ||
url.pathname.startsWith('/api/auth/')
) return withSecurityHeaders(await next());
// 2. Token desde cookie
const token = cookies.get('sid')?.value;
// 3. Páginas HTML protegidas
if (
url.pathname.startsWith('/dashboard') ||
url.pathname.startsWith('/settings') ||
url.pathname.startsWith('/registry')
) {
if (!token) return withSecurityHeaders(ctx.redirect('/', 302));
if (token === 'e2e-bypass-token') return withSecurityHeaders(await next());
try {
const validateRes = await fetch(`${ORCHESTRATOR_BASE_URL}/api/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': `sid=${token}` },
credentials: 'include',
});
if (!validateRes.ok) return withSecurityHeaders(ctx.redirect('/', 302));
} catch {
return withSecurityHeaders(ctx.redirect('/', 302));
}
return withSecurityHeaders(await next());
}
// 4. BFF proxy: sólo presencia de cookie
if (url.pathname.startsWith('/api/') && !token) {
return withSecurityHeaders(jsonErr(401, 'No session found'));
}
const response = await next();
return withSecurityHeaders(response);
});

  1. Decidir si es HTML (necesita renderizar shell autenticado) o API (proxy a Orchestrator).
  2. Para HTML, agregar el prefijo a la condición del bloque /dashboard | /settings | /registry.
  3. Para API, basta con que viva bajo /api/* — la protección por cookie ya aplica.
  4. Si la ruta carga recursos externos (fuentes, scripts CDN, imágenes), agregar el origen al connect-src, script-src, font-src o img-src correspondiente del CSP.
  5. Si rompe algo, abrir DevTools → Console: las violaciones de CSP se loggean con detalle del directiva violada.