Importa archivos de Amazon S3 de forma segura en navegadores
Mantén las credenciales de AWS en el servidor o en el equipo del operador, emite una URL GET prefirmada de corta duración para un único objeto autorizado y deja que el navegador obtenga sus bytes desde Amazon S3. Este ejemplo importa un objeto de hasta 1 MiB y verifica todo su contenido contra una longitud en bytes y una suma de comprobación SHA-256 de confianza antes de informar que la operación se completó correctamente.
¿Por qué importar directamente desde Amazon S3 en el navegador?
El archivo viaja de S3 al navegador, por lo que el servidor de tu aplicación no retransmite el cuerpo de la respuesta. El servidor sigue decidiendo quién puede recibir una URL y para qué objeto. Este es un pequeño ejemplo ejecutado por un operador, con una transferencia manual a una página local; no implementa el inicio de sesión en la aplicación ni un endpoint público de firma. Una aplicación en producción debe autenticar a quien realiza la solicitud y autorizar el acceso al objeto específico antes de emitir su URL.
Una URL prefirmada es una capacidad de acceso por posesión: cualquiera que la obtenga puede usarla mientras siga vigente, incluso fuera de un navegador. Puede reutilizarse. CORS controla si JavaScript de otro origen puede leer la respuesta; los permisos de S3 y la firma controlan el acceso al objeto.
Configura tu bucket de S3
Usa un bucket de S3 privado y de propósito general en us-east-1, con Block Public Access
habilitado y un objeto pequeño existente. Ten a mano el nombre del bucket, la clave exacta, el ID de
12 dígitos de la cuenta propietaria del bucket, la longitud en bytes y el SHA-256 del registro de
subida del productor de confianza. El tamaño aceptado va de cero a 1.048.576 bytes, ambos inclusive.
La suma de comprobación debe corresponder a los bytes del objeto almacenado, no a un hash calculado
a partir de lo que reciba el navegador. Para este ejemplo, usa un objeto sin codificación de
contenido HTTP, para que la decodificación del navegador no cambie esos bytes.
El operador necesita una fuente de credenciales del AWS SDK ya configurada y con permiso para leer
ese objeto. Para esta lectura sin versión, eso incluye s3:GetObject sobre el ARN exacto del
objeto. Pueden aplicarse requisitos adicionales, como permisos de KMS para objetos SSE-KMS; consulta
los requisitos de autorización de GetObject.
No hagas público el objeto para resolver un error de firma.
Para un bucket dedicado a pruebas, usa esta configuración JSON de CORS para la página local. En un bucket existente, incorpora la regla a su configuración y revisa si otras reglas permiten orígenes más amplios; reemplazar la configuración puede afectar a otras aplicaciones. El ejemplo envía GET sin encabezados personalizados y no inspecciona ETag, por lo que no necesita encabezados de solicitud con comodines ni encabezados de respuesta expuestos. Consulta los elementos de CORS de S3.
[
{
"AllowedOrigins": ["http://127.0.0.1:8787"],
"AllowedMethods": ["GET"],
"MaxAgeSeconds": 0
}
]
Un origen incluye el esquema, el nombre de host y el puerto. localhost y 127.0.0.1
son orígenes diferentes. Usa la URL exacta que imprime el servidor local que aparece más abajo y
ajusta la regla de CORS si seleccionas otro puerto. En producción, sirve la aplicación mediante
HTTPS y permite su origen HTTPS exacto. Al habilitar
CORS, las ACL y las políticas de S3 siguen vigentes.
Usa el AWS SDK para JavaScript
Usa una versión de Node.js 26 con mantenimiento y parches actualizados, Corepack instalado por separado y un navegador actual compatible con flujos de Fetch y Web Crypto. La configuración y los scripts del operador se verificaron con Node.js 26.12.1, Corepack 0.36.0, Yarn 4.12.0 y los paquetes del AWS SDK 3.1073.0; la página se verificó en Chromium 145. Mantén Node actualizado con parches siguiendo sus notas de versiones de seguridad. El navegador usa las API de la plataforma y no recibe ninguna clave de acceso secreta de AWS.
En un directorio con permisos de escritura, pega lo siguiente en Bash. Crea un proyecto nuevo y deja
tu shell en su directorio original. No permite continuar si ya existe un directorio
s3-browser-import. La configuración local de Yarn selecciona deliberadamente una instalación
local de node_modules e ignora la ruta al ejecutable de Yarn de un proyecto contenedor.
(
command -v node >/dev/null &&
command -v corepack >/dev/null &&
mkdir s3-browser-import &&
cd s3-browser-import &&
cat > package.json <<'JSON' &&
{
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@aws-sdk/client-s3": "3.1073.0",
"@aws-sdk/s3-request-presigner": "3.1073.0"
}
}
JSON
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' 'ignorePath: true' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn@4.12.0 install
)
Si la instalación falla, el nuevo directorio se conserva para que puedas inspeccionarlo. Guarda los
siguientes tres archivos dentro de s3-browser-import. Ejecuta los comandos posteriores desde
ese directorio.
Emite un ticket en el equipo del operador
Guarda esto como sign.ts. Sus argumentos son el bucket, la clave, el propietario del
bucket, la longitud esperada en bytes, el SHA-256 esperado y el tiempo de vencimiento en segundos.
Este generador de firmas acepta nombres de bucket formados por letras minúsculas, dígitos y guiones.
El tiempo de vencimiento debe ser un entero entre 1 y 300. Todos los metadatos provienen del registro
del objeto de confianza; la firma por sí sola no verifica que el objeto exista ni que se pueda
acceder a él correctamente.
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const [bucket, key, owner, sizeText, sha256, expiryText] = args
const size = Number(sizeText)
const expiresIn = Number(expiryText)
if (
args.length !== 6 || !bucket || !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(bucket) ||
!key || new TextEncoder().encode(key).length > 1024 || !/^\d{12}$/.test(owner ?? '') ||
!/^\d+$/.test(sizeText ?? '') || !Number.isSafeInteger(size) || size > 1_048_576 ||
!/^[a-f0-9]{64}$/.test(sha256 ?? '') || !/^\d+$/.test(expiryText ?? '') ||
!Number.isSafeInteger(expiresIn) || expiresIn < 1 || expiresIn > 300
) {
throw new Error('Invalid ticket arguments')
}
const client = new S3Client({
region: 'us-east-1',
endpoint: 'https://s3.us-east-1.amazonaws.com',
forcePathStyle: true,
})
try {
const command = new GetObjectCommand({ Bucket: bucket, Key: key, ExpectedBucketOwner: owner })
const url = await getSignedUrl(client, command, { expiresIn })
process.stdout.write(`${JSON.stringify({ url, size, sha256 })}\n`)
} finally {
client.destroy()
}
}
main().catch(() => {
console.error('Could not issue ticket. Check the six arguments and operator credentials.')
process.exitCode = 1
})
Asigna a estas variables de shell los valores de tu registro de confianza: S3_BUCKET,
S3_KEY, S3_OWNER, S3_BYTES y S3_SHA256.
Luego ejecuta el punto de entrada exactamente como se muestra, que imprime un único ticket JSON:
node sign.ts "$S3_BUCKET" "$S3_KEY" "$S3_OWNER" "$S3_BYTES" "$S3_SHA256" 60
El generador de firmas de solicitudes del SDK usa la fuente de credenciales del operador. Mantén esa fuente en el equipo del operador. No pegues credenciales en la página ni agregues un endpoint de firma que acepte nombres de bucket y claves arbitrarios sin autorización.
Importa y verifica el objeto en el navegador
Guarda esta página completa como index.html. El tamaño indicado en el ticket limita el
búfer del objeto en la aplicación; el flujo debe terminar exactamente en ese tamaño y su SHA-256
debe coincidir antes de que la función auxiliar devuelva los bytes. Un temporizador de 30 segundos
solicita la cancelación durante la obtención y las lecturas del cuerpo de la respuesta. Mantén la
pestaña activa durante la importación;
las pestañas en segundo plano pueden retrasar los temporizadores.
Una transferencia más lenta puede fallar incluso por debajo del límite de tamaño. Solo se ejecuta
una importación a la vez.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light dark">
<title>Private S3 import</title>
<link rel="icon" href="data:,">
<h1>Private S3 import</h1>
<form id="import-form">
<label for="ticket">Import ticket</label>
<textarea id="ticket" rows="6" cols="30" autocomplete="off" spellcheck="false"></textarea>
<button type="submit">Import</button>
</form>
<p role="status" id="status">Ready.</p>
<script>
async function importObject(ticket) {
if (!ticket || typeof ticket.url !== 'string' ||
!Number.isSafeInteger(ticket.size) || ticket.size < 0 || ticket.size > 1_048_576 ||
typeof ticket.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(ticket.sha256)) {
throw new Error('Invalid ticket')
}
const url = new URL(ticket.url)
if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
throw new Error('Invalid object URL')
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 30_000)
let reader
try {
const response = await fetch(url.href, {
mode: 'cors', credentials: 'omit', cache: 'no-store', redirect: 'error',
referrerPolicy: 'no-referrer', signal: controller.signal,
})
if (!response.ok || !response.body) throw new Error('Object read failed')
reader = response.body.getReader()
const bytes = new Uint8Array(ticket.size)
let offset = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (value.length > bytes.length - offset) throw new Error('Object exceeds expected size')
bytes.set(value, offset)
offset += value.length
}
if (offset !== bytes.length) throw new Error('Incomplete object')
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
const actual = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')
if (actual !== ticket.sha256) throw new Error('Object checksum mismatch')
return bytes
} finally {
clearTimeout(timer)
controller.abort()
if (reader) {
await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
}
const form = document.getElementById('import-form')
const input = document.getElementById('ticket')
const button = form.querySelector('button')
const status = document.getElementById('status')
let busy = false
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (busy) return
busy = true
button.disabled = true
input.disabled = true
status.textContent = 'Importing…'
const raw = input.value
input.value = ''
try {
const bytes = await importObject(JSON.parse(raw))
status.textContent = `Imported ${bytes.length} verified bytes.`
} catch {
status.textContent = 'Import failed. Check the ticket, object metadata, expiry, and CORS.'
} finally {
busy = false
button.disabled = false
input.disabled = false
}
})
</script>
</html>
importObject devuelve un Uint8Array para que tu aplicación lo utilice; el
formulario muestra el resultado verificado como una cantidad de bytes. No guarda un archivo en disco
ni conserva los bytes importados después de que termine el manejador. Borra el ticket pegado y no
usa almacenamiento del navegador, scripts de terceros ni URL de objeto. El navegador y la
implementación del cálculo de hash siguen teniendo sus propios búferes, por lo que 1 MiB es un
límite del objeto y no un límite de la memoria total del navegador.
Sirve la página localmente
Guarda esto como serve.ts. Sirve únicamente la página en la interfaz de bucle local y
deshabilita la caché de respuestas. No expone las credenciales del operador ni implementa la firma.
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const port = args.length === 0 ? 8787 : Number(args[0])
if (args.length > 1 || (args.length === 1 && !/^\d+$/.test(args[0])) ||
!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('Invalid port')
}
const html = await readFile(new URL('./index.html', import.meta.url))
const server = createServer((request, response) => {
const ok = request.method === 'GET' && request.url === '/'
response.writeHead(ok ? 200 : 404, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
})
response.end(ok ? html : 'Not found')
})
server.on('error', () => {
console.error('Could not start server. Check whether the port is available.')
process.exitCode = 1
})
server.listen(port, '127.0.0.1', () => {
const address = server.address()
if (address && typeof address !== 'string') {
console.log(`Open http://127.0.0.1:${address.port}/`)
}
})
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']
for (const signal of signals) {
process.once(signal, () => {
server.close()
server.closeAllConnections()
})
}
}
main().catch(() => {
console.error('Could not start server. Check the port and index.html.')
process.exitCode = 1
})
node serve.ts 8787
Abre http://127.0.0.1:8787/, pega el JSON recién generado en Import ticket
y selecciona Import. Una importación correcta muestra
Imported 16 verified bytes. para un objeto de 16 bytes, o su cantidad
real de bytes. Detén el servidor en primer plano con Ctrl+C cuando termines. Usa otro puerto si
8787 está ocupado y actualiza tanto la URL de la página como la regla de CORS del bucket.
Consejos para optimizar el rendimiento
Este ejemplo lee un objeto de tamaño acotado en la memoria y calcula su hash antes de usarlo. Limita las importaciones simultáneas si integras la función auxiliar en una aplicación más grande. Para archivos que excedan el presupuesto de memoria de esa aplicación, elige un consumidor de flujos o una descarga normal del navegador; abrir una URL de descarga no proporciona a JavaScript los bytes importados ni confirma que se haya guardado un archivo.
Buenas prácticas de seguridad
Trata todo el ticket como información confidencial. Transmítelo solo al destinatario previsto por un canal protegido; mantenlo fuera de las URL de la aplicación, las herramientas de analítica, los registros, el historial compartido del portapapeles y el almacenamiento del navegador. Borrar el formulario no puede revocar las copias ya realizadas. La cadena de consulta contiene información de firma y puede incluir un token de sesión temporal, aunque no contiene la clave de acceso secreta de AWS. Mantén la fuente de credenciales del operador separada del directorio de la página y evita grabar la terminal cuando muestres tickets.
S3 comprueba el vencimiento de la URL al iniciarse una solicitud. Una descarga en curso puede continuar después del vencimiento; reiniciarla después del vencimiento puede fallar. Las credenciales temporales pueden acortar la vigencia de la URL y los cambios de permisos también pueden impedir el acceso. La URL de 60 segundos del ejemplo no es un mecanismo para borrar los datos descargados. Su suma de comprobación detecta una discrepancia con el registro del productor de confianza; no acredita la fiabilidad de un ticket proporcionado por un atacante.
Problemas comunes y soluciones
- Falla la emisión del ticket: Revisa los seis argumentos, los metadatos esperados y la fuente de credenciales del operador. No traslades las credenciales al navegador para eludir el error.
- El navegador informa de un error: Su mensaje público agrupa los errores deliberadamente. Comprueba si el ticket ha vencido, si el generador de firmas puede leer la clave exacta con el ID del propietario proporcionado y si el origen de la página coincide con CORS. Una respuesta bloqueada por CORS puede ocultar su estado HTTP a JavaScript; cambiar CORS no concede los permisos de S3 que faltan.
- Una respuesta legible sigue sin superar la verificación: Confirma el tamaño y el SHA-256 del productor, comprueba si el objeto se ha reemplazado o tiene codificación de contenido HTTP y emite un nuevo ticket para los bytes previstos. Una respuesta truncada, una discrepancia en la suma de comprobación o una transferencia cancelada no deben notificarse como un objeto importado.
Conclusión
Para importaciones gestionadas por la aplicación que se ejecutan en los servidores de Transloadit en lugar de en el navegador, consulta nuestro Robot de importación desde S3. Para este flujo de trabajo en el navegador, mantén la autorización del objeto a cargo del emisor y transmite al destinatario únicamente el ticket de corta duración.
