Seguridad y validación: Zod, autorización y HttpError
Cada función de servidor es un endpoint público: cualquiera puede enviarle el payload que quiera, saltándose tu interfaz. De ahí tres disciplinas innegociables: validar la entrada en el servidor con Zod porque el tipo de TypeScript se borra en runtime, autorizar comprobando la sesión dentro del cuerpo y no en la UI, y cortar el flujo con las respuestas del framework —`redirect` y `json` de `@solidjs/router`, o un `Response` directo— más una clase `HttpError` propia para errores de estado tipados. El hilo conductor: la interfaz es una sugerencia; el endpoint es el perímetro de seguridad.
Hay una frase que conviene tatuar antes de escribir la primera función de servidor: cada una es un endpoint público. El stub del cliente es una comodidad, no una puerta única; por debajo hay una URL a la que cualquiera puede lanzar el payload que se le antoje, con las cabeceras que quiera, saltándose por completo tu interfaz y tus validaciones de formulario. Programar funciones de servidor con seguridad es asumir esa hostilidad de base y responder con tres disciplinas: validar toda entrada, autorizar toda acción y cortar el flujo con las respuestas correctas. Esta lección las reúne.
- Interiorizar que una función de servidor es un endpoint público y que su entrada es, por defecto, hostil.
- Validar la entrada en el servidor con Zod, porque el tipo de TypeScript no existe en tiempo de ejecución.
- Autorizar dentro del cuerpo comprobando la sesión, nunca confiando en que la interfaz ya lo hizo.
- Cortar el flujo con
redirectyjsonde@solidjs/router, unResponse, o una claseHttpErrorpropia.
El tipo se borra: valida en el servidor con Zod
El engaño más peligroso es creer que la firma de TypeScript protege el endpoint. No lo hace: los tipos se borran en compilación y en tiempo de ejecución la función recibe lo que sea que llegó por la red. Un atacante no invoca tu stub tipado; hace un POST crudo con el cuerpo que elige. Por eso la entrada se valida en el servidor con un esquema real —Zod es el estándar—, que comprueba forma y contenido y produce un valor ya tipado y confiable.
import { z } from "zod";
const NuevaTarea = z.object({
texto: z.string().min(1).max(280),
prioridad: z.enum(["baja", "alta"]).default("baja"),
});
export async function crearTarea(entrada: unknown) {
"use server";
const datos = NuevaTarea.parse(entrada); // lanza si la forma no encaja
return db.tareas.crear(datos); // datos ya es confiable y tipado
}
Fíjate en el tipo del argumento: unknown, no la forma esperada. Declararlo unknown es honestidad sobre el runtime —no sabes qué llegó hasta que lo validas— y obliga a pasar por parse antes de tocar nada. Con FormData, el patrón es idéntico: extraes los campos y los pasas por el mismo esquema.
export async function crearDesdeFormulario(form: FormData) {
"use server";
const datos = NuevaTarea.parse({
texto: form.get("texto"),
prioridad: form.get("prioridad"),
});
return db.tareas.crear(datos);
}
Autoriza dentro del cuerpo, no en la interfaz
Validar responde a «¿es esta entrada bien formada?»; autorizar responde a «¿tiene este usuario derecho a esta acción?». Son preguntas distintas y ambas viven en el servidor. Ocultar un botón en la interfaz no protege nada: el endpoint sigue ahí, y quien lo llame directo se salta la interfaz entera. La autorización se resuelve leyendo la sesión de la petición —vía getRequestEvent o el locals que dejó el middleware— y decidiendo dentro del cuerpo.
import { getRequestEvent } from "solid-js/web";
import { redirect } from "@solidjs/router";
export async function borrarTarea(id: string) {
"use server";
const usuario = getRequestEvent()?.locals.usuario;
if (!usuario) throw redirect("/login"); // sin sesion: fuera
if (usuario.rol !== "admin") throw new HttpError(403, "Prohibido");
return db.tareas.borrar(id);
}
flowchart TD I[peticion al endpoint publico] --> V[valida la entrada] V -->|no valida| E1[HttpError 422 o json con error] V -->|valida| A[comprueba autorizacion] A -->|no sesion| R[redirect a login] A -->|sin permiso| E2[HttpError 403] A -->|si| OK[ejecuta la operacion] style I fill:#89b4fa,color:#11111b style OK fill:#a6e3a1,color:#11111b style E1 fill:#f38ba8,color:#11111b style E2 fill:#f38ba8,color:#11111b
Cortar el flujo: redirect, json y HttpError
Desde el servidor puedes devolver o lanzar una respuesta, y el router de Solid sabe interpretar las suyas. redirect(url) produce una redirección; su tipo es never, así que no contamina el tipo de retorno de tu función y puedes lanzarla o devolverla con naturalidad. json(datos, opciones) empaqueta datos con cabeceras o estado a medida. Y siempre puedes lanzar un Response crudo cuando quieras control total.
import { json, redirect } from "@solidjs/router";
export async function exportar() {
"use server";
const evento = getRequestEvent();
if (!evento?.locals.usuario) throw redirect("/login");
return json({ url: await generarInforme() }, { headers: { "cache-control": "no-store" } });
}
Para errores de estado HTTP conviene una clase propia, HttpError, que transporte el código junto al mensaje. El framework no impone una: la defines en tres líneas, la lanzas desde cualquier función de servidor, y la traduces a un Response en tu límite de error o en un middleware. Su valor es semántico —un 403 es distinto de un 422— y hace que el manejo de errores sea uniforme.
export class HttpError extends Error {
constructor(readonly status: number, mensaje: string) {
super(mensaje);
this.name = "HttpError";
}
}
Componer las tres disciplinas en una sola puerta
Repetir validación y autorización en cada función invita a olvidarlas justo en la que importa. El patrón robusto agrupa las mutaciones en un archivo con la directiva de archivo y extrae un guardián reutilizable que resuelve la sesión y el permiso; cada función exportada lo invoca antes de rozar los datos. Nada de esto vive en un cierre con directiva por función —que arrastraría problemas de serialización al capturar variables no transportables—: son funciones de servidor planas que comparten un helper, también de servidor por estar en el mismo archivo.
// data/tareas.ts — el archivo entero es de servidor
"use server";
import { getRequestEvent } from "solid-js/web";
import { redirect } from "@solidjs/router";
async function exigir(rol: "admin" | "editor") {
const usuario = getRequestEvent()?.locals.usuario;
if (!usuario) throw redirect("/login"); // sin sesion
if (usuario.rol !== rol && usuario.rol !== "admin") {
throw new HttpError(403, "Prohibido"); // sin permiso
}
return usuario;
}
export async function crearTarea(entrada: unknown) {
const usuario = await exigir("editor"); // autoriza
const datos = NuevaTarea.parse(entrada); // valida
return db.tareas.crear({ ...datos, autor: usuario.id }); // ejecuta
}
El valor de esta arquitectura no es ahorrar líneas, sino hacer que la seguridad sea la ruta por defecto en lugar de una lista de comprobaciones que recordar cada vez. Cuando validación y autorización viven en el borde común por el que todas las mutaciones entran, olvidarlas deja de ser posible: no queda ninguna puerta trasera que alguien pueda dejar entornada por descuido. La disciplina, así dispuesta, se convierte en arquitectura.
Validar
Zod comprueba la entrada en runtime, donde el tipo de TypeScript ya no existe; declara el argumento como unknown.
Autorizar
Lee la sesión de la petición y decide en el cuerpo; ocultar un botón en la interfaz no protege el endpoint.
Cortar
redirect y json del router, un Response crudo, o un HttpError propio para estados tipados.
Validar en el cliente mejora la experiencia —feedback inmediato, menos viajes— pero no es seguridad, porque el atacante no usa tu cliente. Cualquier control que solo viva en la interfaz es una sugerencia que se ignora con un curl. Por eso la validación del servidor no es una duplicación redundante de la del cliente: es la única que cuenta. Cuando dudes de si una comprobación puede omitirse en el servidor porque «ya la hace el formulario», la respuesta es siempre no. El formulario protege al usuario honesto de equivocarse; el servidor te protege a ti del resto.
El error mental que causa casi todos los fallos de seguridad en aplicaciones con funciones de servidor es tratar la interfaz como si fuera el límite del sistema. No lo es. La interfaz es una vista amable de un conjunto de endpoints públicos, y todo lo que allí parece imposible —un botón deshabilitado, un campo con máximo de caracteres, una ruta que solo ven los administradores— es perfectamente posible para quien habla directamente con el endpoint, porque el endpoint no sabe nada de tu interfaz. Esta es la razón profunda de que las tres disciplinas de esta lección no sean opcionales ni acumulables a gusto: son las tres caras del único perímetro real. Validas porque el endpoint recibe cualquier payload, no solo el que tu formulario produce. Autorizas porque el endpoint atiende a cualquiera, no solo a quien tu interfaz dejó llegar hasta el botón. Y cortas el flujo con la respuesta correcta porque el endpoint debe comunicar su veredicto en el idioma del protocolo —un 401, un 403, una redirección— y no en el de una interfaz que el atacante ni siquiera cargó. Cuando trasladas mentalmente el perímetro de seguridad desde la pantalla hasta el cuerpo de cada función de servidor, empiezas a escribir cada una como si fuera lo primero que un desconocido hostil va a tocar, porque lo es. Ahí, dentro del cuerpo, después de "use server" y antes de rozar la base de datos, es donde se decide si tu aplicación es segura. Todo lo de fuera es decoración; esto es la muralla.
- Escribe
crearTareacon el argumento tipado comounknowny un esquema Zod; envíale un payload malformado con una petición cruda y confirma queparselo rechaza. - Repite la validación tomando la entrada de un
FormDatay comprueba que el mismo esquema sirve para ambas formas. - Autoriza
borrarTarealeyendo la sesión dellocals; llama al endpoint sin sesión y verifica queredirectte lleva a login. - Define la clase
HttpError, lánzala con un403cuando el rol no basta, y tradúcela a unResponseen un límite de error. - Argumenta, con un ejemplo de
curl, por qué una comprobación que solo existe en el cliente no aporta ninguna seguridad.