En React 19, el paradigma de Server Functions (denominadas Server Actions en el contexto de formularios) elimina la fricción sintáctica entre cliente y servidor: una función asíncrona del backend puede importarse e invocarse directamente en un componente cliente como si fuera un método local en memoria. Sin embargo, bajo este elegante azúcar sintáctico se oculta una compleja arquitectura distribuida de llamadas a procedimiento remoto (RPC, Remote Procedure Call) que opera sobre la red pública de internet.
Cualquier función designada con la directiva 'use server' es compilada por el empaquetador en un endpoint HTTP POST públicamente accesible, expuesto a invocaciones externas directas desde cualquier lugar de la web.
Considerar una Server Action como una «simple función interna de módulo» constituye una de las fuentes de vulnerabilidades más críticas en aplicaciones React 19 y Next.js:
curl o scripts automatizados, enviando valores arbitrarios que omiten máscaras de campos, validaciones de interfaz y botones con estado disabled.deleteInvoice(invoiceId) no comprueba que la sesión activa posee permisos sobre ese invoiceId específico, un atacante podrá eliminar facturas ajenas mediante enumeración secuencial.server-only.Origin, Host y metadatos de obtención (Sec-Fetch-Site) impide que sitios web maliciosos ejecuten mutaciones en nombre de un usuario autenticado.Cuando el empaquetador encuentra la directiva 'use server', divide el código en dos elementos independientes. En el servidor registra una tabla de enrutamiento RPC, mientras que en el bundle de cliente sustituye el cuerpo de la función por un stub ligero denominado Server Reference.
Al invocarse la función, el stub cliente genera una petición HTTP POST con la cabecera React-Server-Action provista del Action ID criptográfico. El despachador de servidor verifica el origen de la petición (Origin/Host), localiza la función en el manifiesto, valida la sesión activa y ejecuta la mutación en un entorno seguro Node.js o Edge.
Si la función de servidor se crea con argumentos vinculados (por ejemplo, updateTodo.bind(null, todo.id)), React cifra dichos argumentos en el servidor mediante una clave simétrica de sesión antes de transmitirlos al cliente. El navegador no puede modificar el token cifrado todo.id, garantizando la integridad de los identificadores a nivel de framework.
El usuario envía un formulario o ejecuta la acción. El runtime serializa los argumentos en una carga Flight RPC.
El servidor comprueba que Origin coincide con Host y evalúa Sec-Fetch-Site. Peticiones no autorizadas reciben 403.
Se descifran los argumentos vinculados; un esquema Zod de backend valida tipos y rangos de datos.
Se comprueban los permisos de la sesión activa (RBAC / IDOR defense) y se ejecutan las transacciones en la BD.
El resultado y los subárboles de componentes actualizados se serializan en el flujo Flight para actualizar la interfaz.
¿Cuál de las siguientes afirmaciones describe con exactitud el modelo de seguridad de las funciones marcadas con 'use server'?
Ejecución de transacciones con verificación estricta de la sesión y saldo en el propio cuerpo de la función, prescindiendo por completo de datos de saldo calculados en cliente o campos ocultos del formulario.
Asociación segura de IDs de registros en botones de tablas mediante action.bind(null, id) con cifrado simétrico, garantizando que el usuario no pueda manipular el identificador en memoria cliente.
Protección de tokens y clientes de infraestructura en módulos con import 'server-only', imposibilitando la fuga involuntaria de credenciales a bundles públicos.
Integración nativa de Server Actions con los hooks de React 19 useOptimistic y useActionState sin requerir bibliotecas pesadas de gestión de estado global.
El usuario envía un formulario o invoca la acción. El runtime serializa los parámetros en un stream Flight RPC.
El servidor evalúa Origin, Host y Sec-Fetch-Site. Peticiones de orígenes cruzados no autorizados reciben 403.
El Action ID localiza la función en el registro. Se descifran los argumentos vinculados (bind args) con clave simétrica.
Se verifica la sesión (RBAC/IDOR), se valida el esquema con Zod y se ejecutan las transacciones en la base de datos.
Cuando un archivo se marca con 'use server', el compilador genera un objeto proxy en el cliente:
import { createServerReference } from 'react-server-dom-webpack/client';
// Stub de cliente: solo contiene el Action ID y la capa de transporte HTTP POST
export const transferMoney = createServerReference(
'action-id-9f8a3c2e1b', // Identificador criptográfico de la función
callServer // Función encargada del transporte de red
);Paralelamente, en el backend se genera un manifiesto de despacho protegido:
export const actionsManifest = {
'action-id-9f8a3c2e1b': {
module: './src/actions/banking.ts',
name: 'transferMoney',
// Hash de compilación para blindaje contra repetición de paquetes (Replay Attacks)
buildHash: 'v1.42.0-prod',
},
};React 19 integra validación rigurosa de origen. Cuando se ejecuta una Server Action, el servidor contrasta la cabecera Origin con la cabecera Host:
import { Request } from 'express';
export function validateActionSecurity(req: Request): boolean {
// 1. El método debe ser estrictamente POST
if (req.method !== 'POST') {
return false;
}
// 2. Validación de la cabecera Sec-Fetch-Site de navegadores modernos
const fetchSite = req.headers['sec-fetch-site'];
if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'same-site') {
console.error(`[CSRF Block] Sec-Fetch-Site no permitido: ${fetchSite}`);
return false;
}
// 3. Comprobación estricta de paridad entre Origin y Host
const origin = req.headers['origin'];
const host = req.headers['host'];
if (origin && host) {
const originHost = new URL(origin).host;
if (originHost !== host) {
console.error(`[CSRF Block] Origin (${originHost}) no coincide con Host (${host})`);
return false;
}
}
return true;
}A continuación se muestra un ejemplo canónico de función de servidor para transferencias bancarias con protección anti-IDOR, validación exhaustiva con Zod, transacciones ACID y ocultación de secretos:
'use server';
import 'server-only'; // Blindaje contra fugas hacia el cliente
import { z } from 'zod';
import { db } from '@/lib/db';
import { getAuthenticatedUser } from '@/lib/auth';
// Esquema de validación en backend
const TransferPayloadSchema = z.object({
recipientIban: z.string().regex(/^DE\d{20}$/, 'Formato de IBAN alemán inválido'),
amountCents: z.number().int().positive('El importe debe ser positivo'),
referenceNote: z.string().max(140).optional(),
});
export interface TransferResult {
success: boolean;
transactionId?: string;
error?: string;
}
export async function transferFunds(
targetAccountId: string, // Vinculado de forma segura mediante .bind()
prevState: TransferResult,
formData: FormData
): Promise<TransferResult> {
// 1. AUTENTICACIÓN: Obtención del usuario verificado desde la sesión
const currentUser = await getAuthenticatedUser();
if (!currentUser) {
return { success: false, error: 'Autenticación requerida' };
}
// 2. AUTORIZACIÓN (Defensa anti-IDOR): ¿Es targetAccountId propiedad del usuario actual?
const sourceAccount = await db.accounts.findFirst({
where: { id: targetAccountId, ownerId: currentUser.id },
});
if (!sourceAccount) {
console.warn(`[Security Alert] El usuario ${currentUser.id} intentó adeudar una cuenta no autorizada ${targetAccountId}`);
return { success: false, error: 'Cuenta de origen no encontrada o acceso denegado' };
}
// 3. VALIDACIÓN DE ENTRADAS
const rawData = {
recipientIban: formData.get('recipientIban'),
amountCents: Number(formData.get('amountCents')),
referenceNote: formData.get('referenceNote') || undefined,
};
const parsed = TransferPayloadSchema.safeParse(rawData);
if (!parsed.success) {
const issue = parsed.error.issues[0]?.message || 'Parámetros de entrada inválidos';
return { success: false, error: issue };
}
// 4. LÓGICA DE NEGOCIO: Comprobación de saldo
if (sourceAccount.balanceCents < parsed.data.amountCents) {
return { success: false, error: 'Saldo insuficiente en cuenta' };
}
// 5. EJECUCIÓN DE TRANSACCIÓN ACID
try {
const tx = await db.$transaction(async (prisma) => {
await prisma.accounts.update({
where: { id: sourceAccount.id },
data: { balanceCents: { decrement: parsed.data.amountCents } },
});
return prisma.transactions.create({
data: {
sourceAccountId: sourceAccount.id,
recipientIban: parsed.data.recipientIban,
amountCents: parsed.data.amountCents,
note: parsed.data.referenceNote,
},
});
});
return { success: true, transactionId: tx.id };
} catch (err: unknown) {
// 6. TRATAMIENTO SEGURO DE ERRORES: Ocultar topología de base de datos
console.error('[Transaction Failure]:', err);
return { success: false, error: 'Error en el procesamiento del pago. Inténtelo más tarde.' };
}
}¿Por qué es indispensable que el desarrollador verifique la propiedad del recurso en la base de datos aunque React cifre los argumentos pasados mediante action.bind(null, id)?
Para estandarizar la autenticación, autorización y gestión uniforme de respuestas en aplicaciones empresariales:
import { z } from 'zod';
export interface ActionError {
fieldErrors?: Record<string, string[]>;
message: string;
}
export interface ActionState<TOutput> {
data: TOutput | null;
error: ActionError | null;
success: boolean;
}
export interface SecurityContext<TUser> {
user: TUser;
}
export function createSecureAction<TSchema extends z.ZodTypeAny, TOutput, TUser>(
schema: TSchema,
authResolver: () => Promise<TUser | null>,
handler: (input: z.infer<TSchema>, ctx: SecurityContext<TUser>) => Promise<TOutput>
) {
return async (
prevState: ActionState<TOutput>,
payload: unknown
): Promise<ActionState<TOutput>> => {
// 1. Verificación de sesión
const user = await authResolver();
if (!user) {
return {
data: null,
error: { message: 'Acceso no autorizado. Debe iniciar sesión.' },
success: false,
};
}
// 2. Validación estricta con Zod
const validation = schema.safeParse(payload);
if (!validation.success) {
return {
data: null,
error: {
fieldErrors: validation.error.flatten().fieldErrors,
message: 'Parámetros de petición inválidos',
},
success: false,
};
}
// 3. Ejecución dentro del contexto de seguridad
try {
const result = await handler(validation.data, { user });
return {
data: result,
error: null,
success: true,
};
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : 'Error interno del servidor';
return {
data: null,
error: { message: errMsg },
success: false,
};
}
};
}La vulnerabilidad más destructiva al adoptar Server Actions es confiar en un identificador recibido (por ejemplo, deletePost(postId)). Si la consulta a la base de datos no exige where: { id: postId, authorId: session.userId }, un atacante podrá eliminar contenidos de terceros modificando el identificador numérico o UUID en un bucle automatizado.
// ❌ VULNERABILIDAD CRÍTICA: Eliminación sin comprobar el propietario (IDOR)
export async function deletePost(postId: string) {
'use server';
await db.post.delete({ where: { id: postId } }); // ¡Cualquier usuario puede borrar cualquier post!
}
// ✅ CORRECTO: Verificación de propiedad en la propia consulta a base de datos
export async function deletePost(postId: string) {
'use server';
const user = await getCurrentUser();
await db.post.delete({
where: { id: postId, authorId: user.id }, // Solo registros del autor
});
}Si un archivo inicializa conexiones a bases de datos, claves de APIs o métodos de hashing sin incluir import 'server-only', cualquier importación accidental de una constante desde ese archivo a un componente cliente causará que el empaquetador filtre secretos al bundle público.
Devolver el mensaje directo de error de base de datos (catch (e) { return { error: e.message } }) expone nombres de tablas, columnas y rutas de archivos ante errores SQL. Los mensajes para el usuario deben estar siempre higienizados y ser genéricos.
Al construir servidores propios con Express o Node.js sin frameworks integrados, a menudo se omite la validación de Origin y Host, dejando las Server Actions expuestas a ataques CSRF desde sitios web de terceros.