Contents
Introducción
En muchas instalaciones de WordPress es necesario ofrecer archivos de descarga restringida: recursos premium, archivos adjuntos privados, backups, etc. Firmar URLs con expiración en PHP permite generar enlaces que dejan de ser válidos tras un tiempo, evitando el acceso directo permanente. Este artículo explica en detalle los conceptos, la implementación segura en PHP y cómo integrarlo con WordPress, incluyendo opciones de entrega (X-Sendfile / X-Accel-Redirect) y buenas prácticas de seguridad.
Concepto básico
Una URL firmada es una URL que contiene parámetros que verifican dos cosas principales:
- Validez temporal: la URL contiene una marca de tiempo de expiración (expires).
- Integridad y autenticidad: la URL incluye una firma (sig) que demuestra que el servidor la generó con un secreto conocido solamente por el servidor.
Con estos elementos se puede permitir el acceso sólo si la firma es correcta y la fecha no ha expirado.
Algoritmo recomendado
Usaremos HMAC-SHA256 con un secreto (key) para firmar los datos. La firma será calculada sobre un string canónico que incluya la ruta del recurso y la fecha de expiración. La función hash_equals se usa para comparar firmas de forma segura. Para evitar problemas con caracteres en la URL usaremos una codificación base64 URL-safe (base64url).
Requisitos y buenas prácticas
- Almacenar los archivos protegidos fuera del directorio público web o restringir el acceso directo mediante configuración del servidor.
- Guardar la clave secreta en un lugar seguro (p. ej. en wp-config.php o en un servicio de gestión de secretos). No versionar la clave en repositorios públicos.
- Usar siempre hash_equals para comparar firmas y evitar ataques de timing.
- Limitar el TTL (time-to-live) de las URLs firmadas según la sensibilidad del recurso.
- Registrar accesos y rechazos para auditoría y detección de abuso.
Funciones de utilidad en PHP
Ejemplo de funciones para generar y validar firmas. Estas funciones son pequeñas, reutilizables y compatibles con WordPress si se colocan en un plugin o en functions.php del tema (preferible: plugin).
lt?php // base64url encode/decode function base64url_encode(data) { return rtrim(strtr(base64_encode(data), /, -_), =) } function base64url_decode(data) { remainder = strlen(data) % 4 if (remainder) { padlen = 4 - remainder data .= str_repeat(=, padlen) } return base64_decode(strtr(data, -_, /)) } // Genera la URL firmada. resource puede ser la ruta relativa del archivo. // ttl en segundos. secret -> clave secreta. function generate_signed_url(base_url, resource, ttl, secret) { expires = time() ttl // Construye el string canónico: recursoexpires data = resource . . expires sig_raw = hash_hmac(sha256, data, secret, true) sig = base64url_encode(sig_raw) // Asegúrate de escapar resource para usarlo en query string si no es path url = rtrim(base_url, /) . ?resource= . rawurlencode(resource) . expires= . expires . sig= . sig return url } // Valida la firma. Devuelve true si es válida y no ha expirado. function validate_signed_url(resource, expires, sig, secret) { if (!ctype_digit((string)expires)) { return false } if ((int)expires < time()) { return false // expirado } data = resource . . expires expected_raw = hash_hmac(sha256, data, secret, true) expected_sig = base64url_encode(expected_raw) // Comparación segura return hash_equals(expected_sig, sig) } ?gt
Integración sencilla: script de entrega
Un enfoque simple es tener un endpoint PHP (p. ej. /download-protected.php) que valide la firma y sirva el archivo. Si se dispone de X-Sendfile o X-Accel-Redirect, es preferible delegar la entrega al servidor para eficiencia.
lt?php // download-protected.php require_once __DIR__ . /wp-load.php // si lo usas dentro de WP, o incluye config // Configuración: secreto y base path real donde están los archivos protegidos SECRET = mi_clave_super_secreta_que_debe_guardarse_en_wp_config PROTECTED_BASE = __DIR__ . /protected_files // ideal fuera del webroot resource = isset(_GET[resource]) ? _GET[resource] : expires = isset(_GET[expires]) ? _GET[expires] : sig = isset(_GET[sig]) ? _GET[sig] : resource = ltrim(resource, /) // normalizar if (!validate_signed_url(resource, expires, sig, SECRET)) { header(_SERVER[SERVER_PROTOCOL] . 403 Forbidden) echo Access denied exit } file_path = realpath(PROTECTED_BASE . / . resource) // Evitar directory traversal if (file_path === false strpos(file_path, realpath(PROTECTED_BASE)) !== 0 !is_file(file_path)) { header(_SERVER[SERVER_PROTOCOL] . 404 Not Found) echo File not found exit } // Opción 1: usar X-Sendfile (Apache mod_xsendfile) o similar // header(X-Sendfile: . file_path) // header(Content-Type: application/octet-stream) // header(Content-Disposition: attachment filename= . basename(file_path) . ) // exit // Opción 2: usar X-Accel-Redirect para Nginx (configurar location interna) // header(X-Accel-Redirect: /internal_protected/ . resource) // header(Content-Type: application/octet-stream) // header(Content-Disposition: attachment filename= . basename(file_path) . ) // exit // Opción 3: fallback: enviar por PHP (menos escalable) // Determinar mime-type básico finfo = finfo_open(FILEINFO_MIME_TYPE) mime = finfo_file(finfo, file_path) ?: application/octet-stream finfo_close(finfo) header(Content-Description: File Transfer) header(Content-Type: . mime) header(Content-Disposition: attachment filename= . basename(file_path) . ) header(Content-Length: . filesize(file_path)) readfile(file_path) exit ?gt
Integración directa con WordPress (plugin minimal)
Ejemplo de código para un plugin que genera URLs firmadas para attachments y añade un endpoint personalizado. Este ejemplo usa una constante SECRET definida en wp-config.php (definir: define(SIGNED_URL_SECRET,tu_secreto)).
lt?php / Plugin Name: Protected Signed Downloads Description: Genera URLs firmadas con expiración y sirve archivos protegidos. Version: 1.0 / if (!defined(ABSPATH)) exit add_action(init, psd_add_rewrite_rule) function psd_add_rewrite_rule() { add_rewrite_rule(^protected-download/?,index.php?psd_download=1,top) } add_filter(query_vars, psd_query_vars) function psd_query_vars(qvars) { qvars[] = psd_download qvars[] = resource qvars[] = expires qvars[] = sig return qvars } add_action(template_redirect, psd_template_redirect) function psd_template_redirect() { global wp_query if (isset(wp_query->query_vars[psd_download])) { resource = isset(_GET[resource]) ? _GET[resource] : expires = isset(_GET[expires]) ? _GET[expires] : sig = isset(_GET[sig]) ? _GET[sig] : secret = defined(SIGNED_URL_SECRET) ? SIGNED_URL_SECRET : base = WP_CONTENT_DIR . /protected_files // ejemplo if (!validate_signed_url(resource, expires, sig, secret)) { status_header(403) echo Access denied exit } file = realpath(base . / . ltrim(resource, /)) if (file === false strpos(file, realpath(base)) !== 0 !is_file(file)) { status_header(404) echo File not found exit } // Delegar a X-Accel-Redirect o X-Sendfile si está configurado, o leer por PHP: header(Content-Type: application/octet-stream) header(Content-Disposition: attachment filename= . basename(file) . ) header(Content-Length: . filesize(file)) readfile(file) exit } } // Funciones base (copiar las definidas anteriormente) function base64url_encode(data) { return rtrim(strtr(base64_encode(data), /, -_), =) } function validate_signed_url(resource, expires, sig, secret) { if (!ctype_digit((string)expires)) return false if ((int)expires < time()) return false data = resource . . expires expected_raw = hash_hmac(sha256, data, secret, true) expected_sig = base64url_encode(expected_raw) return hash_equals(expected_sig, sig) } function psd_generate_for_attachment(attachment_id, ttl_seconds = 3600) { url = wp_get_attachment_url(attachment_id) if (!url) return false // Convertir URL a path relativa dentro del directorio protegido (tu lógica) // Aquí suponemos que en resource guardamos el path relativo en el directorio protegido resource = basename(url) base_url = site_url(/protected-download) secret = defined(SIGNED_URL_SECRET) ? SIGNED_URL_SECRET : expires = time() ttl_seconds data = resource . . expires sig = base64url_encode(hash_hmac(sha256, data, secret, true)) return base_url . ?resource= . rawurlencode(resource) . expires= . expires . sig= . sig } ?gt
Configuración del servidor
Para una entrega eficiente y segura, es preferible que el servidor web (Apache/Nginx) entregue el archivo internamente después de que PHP haya validado la firma. A continuación se muestran configuraciones de ejemplo.
Nginx: X-Accel-Redirect
Ejemplo de bloque que define una ubicación interna que apunta al directorio físico donde están los archivos protegidos. La aplicación PHP debe enviar la cabecera X-Accel-Redirect con la ruta interna.
location /protected_files/ { internal alias /var/www/example.com/protected_files/ }
En PHP: header(X-Accel-Redirect: /protected_files/ . resource)
Apache: X-Sendfile (mod_xsendfile)
Habilita mod_xsendfile y configura:
# En la configuración del VirtualHost o .htaccess (si está permitido) XSendFile On XSendFilePath /var/www/example.com/protected_files/
En PHP: header(X-Sendfile: . file_path)
Protección del directorio
Si no puedes mover los archivos fuera del webroot, bloquea el acceso directo mediante reglas del servidor.
# .htaccess en el directorio protegido Order deny,allow Deny from all
En Nginx: configura una location que niegue acceso público y define otra interna para X-Accel-Redirect.
Manejo de Range Requests y streaming
Si esperas que los usuarios reanuden descargas o sirvas videos, necesitarás soporte de Range. Implementar Range por PHP es más complejo y menos eficiente si necesitas Range, usa preferentemente X-Accel-Redirect o X-Sendfile junto con la configuración del servidor que soporte rangos nativamente.
Consideraciones adicionales de seguridad
- Rotación de claves: introduce un esquema de rotación con versiones de clave (p. ej. incluir key_id en la URL y validar con la clave correspondiente) para invalidar enlaces antiguos sin cambiar todo el código.
- Tiempo de expiración razonable: TTL demasiado largo facilita el reuso indebido TTL demasiado corto puede frustrar al usuario.
- Evitar exponer la lógica interna del path: usa identificadores opacos (p. ej. hash del ID o UUID) en lugar de rutas de archivo reales.
- Registra intentos fallidos y bloquea IP que intenten brute force sobre firmas o recursos sensibles.
Alternativa: firmas presignadas de servicios de almacenamiento (S3)
Si usas S3 u otros proveedores, suelen ofrecer URLs presignadas con expiración. Para WordPress, la integración más robusta es generar una URL presignada usando el SDK de AWS desde tu backend y devolverla al cliente. Ese enfoque delega la entrega al proveedor y evita que el servidor web tenga que transmitir el archivo.
Checklist final antes de desplegar
- Clave secreta guardada en un lugar seguro (p. ej. wp-config.php) y no en el repositorio.
- Probar expiración: generar URL con TTL corto y verificar rechazo tras caducar.
- Comprobar que no existe acceso directo si el archivo está en el webroot.
- Si usas X-Accel-Redirect/X-Sendfile, comprobar que el servidor las soporta y que las rutas internas están bien configuradas.
- Limitar tamaño máximo de TTL y documentarlo para el equipo.
- Implementar monitorización / logging de accesos a los recursos protegidos.
Resumen
Firmar URLs de descarga con expiración en PHP es una técnica simple y poderosa para proteger recursos. La receta básica consiste en:
- Calcular una firma HMAC sobre un string canónico (recursoexpires).
- Incluir expires y la firma en la URL.
- Validar ambos en un endpoint que sirva el archivo de manera segura (preferentemente delegando la entrega al servidor con X-Sendfile / X-Accel-Redirect para eficiencia).
La integración con WordPress puede realizarse mediante un plugin que añada un endpoint y funciones para generar URLs firmadas siempre aplicando las buenas prácticas de seguridad mencionadas.
Ejemplo de referencia de generación de URL (resumen)
Para crear una URL firmada:
- resource = ruta/archivo.pdf
- expires = time() ttl
- sig = base64url_encode(HMAC_SHA256(secret, resourceexpires))
- url = base_url ?resource= urlencode(resource) expires= expires sig= sig
Implementando lo anterior con cuidado y probando los flujos (usuario legítimo, intento de uso tras expiración, manipulación de firma) tendrás un sistema robusto para proteger descargas en WordPress.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |