Como firmar URLs de descarga con expiración en PHP en WordPress

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

  1. Clave secreta guardada en un lugar seguro (p. ej. wp-config.php) y no en el repositorio.
  2. Probar expiración: generar URL con TTL corto y verificar rechazo tras caducar.
  3. Comprobar que no existe acceso directo si el archivo está en el webroot.
  4. Si usas X-Accel-Redirect/X-Sendfile, comprobar que el servidor las soporta y que las rutas internas están bien configuradas.
  5. Limitar tamaño máximo de TTL y documentarlo para el equipo.
  6. 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:

  1. resource = ruta/archivo.pdf
  2. expires = time() ttl
  3. sig = base64url_encode(HMAC_SHA256(secret, resourceexpires))
  4. 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 🙂



Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *