svelte, formularios, y nunca dejar que el navegador tenga el token

25 de junio de 2026 meta devlogsveltekit

viniendo de React, aterrizando en SvelteKit

Esta es la herramienta más nueva de todo el stack para mí, y también la que hace más trabajos a la vez. SvelteKit renderiza páginas en el servidor, maneja el envío de formularios, y hace de proxy para las llamadas API a los dos servicios en Go, todo desde un solo proceso de Node. Viniendo de un fondo sobre todo en React y MERN stack antes de esto, la idea de que la carga de una página y el envío de un formulario vivan en el mismo archivo, con una función load y un objeto actions uno al lado del otro, me costó un poco acostumbrarme. No es una single-page app llamando a una API REST desde JavaScript del lado del cliente, la mayor parte de lo importante aquí nunca toca el motor de scripts del navegador para nada.

cómo llega en realidad una petición del navegador a un servicio en Go

el navegador nunca llega a tener el token

Esta es la decisión de arquitectura que más protejo de todo este proyecto. El access token que demuestra quién eres vive en una cookie httponly, lo que significa que ningún JavaScript corriendo en el navegador, ni el mío, ni una extensión del navegador, ni un payload de XSS si alguna vez se colara uno, puede leerlo. Pero igual tiene que llegar desde esa cookie hasta el header Authorization que esperan los servicios en Go, y esa traducción pasa enteramente en el servidor, dentro de SvelteKit.

Cada petición al backend pasa por una ruta de proxy catch-all en el servidor del frontend. El navegador llama a /api/lo-que-sea en el propio origen del frontend, SvelteKit lee la cookie del lado del servidor y la adjunta como un bearer token real antes de reenviar la petición:

export async function forwardRequest(
	event: RequestEvent,
	target: URL,
	extraHeaders: Record<string, string> = {}
): Promise<Response> {
	const allowed = getAllowedOrigins();
	if (!isAllowedTarget(target, allowed)) {
		throw new Error(`target origin not in allowed origins: ${target.origin}`);
	}

	const headers = new Headers(extraHeaders);
	const contentType = event.request.headers.get('content-type');
	if (contentType) headers.set('content-type', contentType);
	...
	return fetch(target, init);
}

Esa comprobación de isAllowedTarget contra una lista fija de orígenes internos está ahí para que un bug en algún lugar más arriba no pueda ser engañado para reenviar una petición a un host arbitrario con nuestros headers de auth pegados encima, básicamente protección contra SSRF para algo que en la superficie solo parece una función de proxy normal.

estado de sesión sin verificar nunca, a propósito

El otro sitio donde los tokens me confundieron durante un tiempo: hooks.server.ts corre en cada petición y decide quién está conectado para esa petición, pero solo decodifica el JWT, no verifica la firma para nada.

export const handle: Handle = async ({ event, resolve }) => {
	event.locals.user = null;

	let sessionFromAccessToken = false;
	const accessToken = event.cookies.get('access_token');
	if (accessToken) {
		const claims = decodeAccessToken(accessToken);
		if (claims && !isExpired(claims)) {
			event.locals.user = claimsToLocalsUser(claims);
			sessionFromAccessToken = true;
		}
	}

	if (!sessionFromAccessToken) {
		const refreshToken = event.cookies.get('refresh_token');
		if (refreshToken) {
			const response = await event.fetch('/auth/refresh', { method: 'POST' });
			...
		}
	}
	...
};

Se lee como un agujero de seguridad la primera vez que lo miras, y lo sería, si locals.user se tratara alguna vez como prueba de algo. No lo es. Solo decide qué renderiza la página: si el link de admin aparece en la navegación, si un formulario muestra "cerrar sesión" en vez de "iniciar sesión." Cada escritura real todavía pasa por los servicios en Go, y son ellos los que verifican la firma contra las claves públicas de auth antes de hacer nada con eso. Si alguien falsificara una cookie diciendo ser admin, la interfaz le mostraría contenta el link de navegación de admin, y luego cada petición que hiciera sería rechazada de todas formas por la comprobación real en el otro extremo. Cosmético de este lado, estructural del otro.

una form action, no una llamada fetch

El login es un formulario HTML normal, enviado como una form action de SvelteKit, lo que significa que sigue funcionando incluso con JavaScript apagado:

export const actions: Actions = {
	default: async ({ request, fetch, url }) => {
		const form = await request.formData();
		const username = String(form.get('username') ?? '');
		const password = String(form.get('password') ?? '');

		let response: Response;
		try {
			response = await fetch('/auth/login', {
				method: 'POST',
				headers: { 'content-type': 'application/json' },
				body: JSON.stringify({ username, password })
			});
		} catch (err) {
			const apiError = await parseApiError(err);
			return fail(502, { error: apiError.message, requestId: apiError.requestId, username });
		}

		if (!response.ok) {
			const apiError = await parseApiError(response);
			return fail(response.status, { error: apiError.message, requestId: apiError.requestId, username });
		}

		throw redirect(303, safeRedirectTarget(url.searchParams.get('redirectTo')));
	}
};

Esa llamada fetch es el fetch propio del lado del servidor de SvelteKit, corriendo dentro de la form action, golpeando la ruta interna /auth/login que es a su vez el proxy de arriba. El navegador nunca ve nada de esto salvo un POST de formulario normal y una redirección al final.

La primera vez que conecté esto seguía intentando llamar al servicio de auth directamente desde un <script> en el componente como habría hecho en React:

<script lang="ts">
	// what I tried first, straight out of a React habit
	async function login(username: string, password: string) {
		const res = await fetch('http://localhost:8081/auth/login', {
			method: 'POST',
			body: JSON.stringify({ username, password })
		});
	}
</script>

Me topé con CORS una y otra vez, ya que eso es un navegador llamando directamente a un origen completamente distinto, y al final acepté que no, así no es como funciona esto aquí, el servidor lo hace en su lugar.

markdown que no puede correr scripts

Tanto el contenido de los posts como los comentarios se escriben como markdown y se renderizan a HTML, lo que significa que la entrada del usuario termina convirtiéndose en DOM real en la página. Ese es el montaje de libro de texto para XSS almacenado si no tienes cuidado con eso. El renderizado pasa por marked y luego directo por DOMPurify con una lista de permitidos explícita, no una lista de bloqueados:

const POST_ALLOWED_TAGS = [
	'p', 'br', 'strong', 'em', 'del', 'a', 'ul', 'ol', 'li',
	'h2', 'h3', 'h4', 'blockquote', 'code', 'pre', 'span',
	'img', 'video', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td'
];

const COMMENT_ALLOWED_TAGS = ['p', 'br', 'a', 'strong', 'em', 'code'];
const COMMENT_ALLOWED_ATTR = ['href'];

Los posts obtienen una lista de permitidos más amplia que los comentarios, porque solo los admins escriben posts, el propio API de blog rechaza a cualquier otro en el endpoint de crear/actualizar, así que confío más en ese contenido. Los comentarios vienen de extraños anónimos de internet, así que reciben la lista más pequeña, sin imágenes, sin estilos, apenas más que un párrafo y un link. Hasta la etiqueta de video recibe una comprobación extra encima de la lista de permitidos, un hook que solo deja que un elemento <video> conserve su src si el origen de esa URL coincide con nuestro propio host de medios, así un comentario no puede apuntar una etiqueta de video a un dominio completamente distinto.

Sistema pequeño, pero es el único sitio donde sentí que realmente entendí un principio de seguridad real (lista de permitidos gana a lista de bloqueados, siempre) en vez de que me lo contaran en una charla y yo solo asintiera.

0 comentarios

Inicia sesión para comentar.

Iniciar sesión

¿No tienes cuenta?