Importer des fichiers Amazon S3 en sécurité dans le navigateur
Conservez les données d’accès AWS sur le serveur ou la machine de l’opérateur, générez une URL GET présignée à courte durée de validité pour un seul objet autorisé, puis laissez le navigateur récupérer ses octets depuis Amazon S3. Cet exemple importe un objet de 1 MiB maximum et vérifie l’intégralité de son contenu par rapport à une taille en octets et une empreinte SHA-256 de confiance avant de signaler la réussite.
Pourquoi importer directement depuis Amazon S3 dans le navigateur ?
Le fichier passe de S3 au navigateur : votre serveur d’application ne relaie donc pas le corps de la réponse. Le serveur décide toujours qui peut recevoir une URL et pour quel objet. Il s’agit d’un petit exemple exécuté par un opérateur, avec une transmission manuelle à une page locale ; il n’implémente ni connexion à l’application ni point de terminaison public de signature. Une application en production doit authentifier l’appelant et autoriser l’accès à l’objet précis avant de générer son URL.
Une URL présignée confère un droit d’accès à son détenteur : quiconque l’obtient peut l’utiliser tant qu’elle reste valide, y compris hors d’un navigateur. Elle peut être réutilisée. CORS détermine si du JavaScript exécuté sur une autre origine peut lire la réponse ; les autorisations S3 et la signature contrôlent l’accès à l’objet.
Configurer votre bucket S3
Utilisez un bucket S3 privé à usage général dans us-east-1, avec Block Public Access
activé et un petit objet existant. Munissez-vous du nom du bucket, de la clé exacte, de l’ID à
12 chiffres du compte propriétaire du bucket, de la taille en octets et de l’empreinte SHA-256
figurant dans l’enregistrement du téléversement du producteur de confiance. La taille acceptée va
de zéro à 1 048 576 octets, bornes incluses. La somme de contrôle doit correspondre aux octets de
l’objet stocké, et non à une empreinte calculée à partir de ce que le navigateur reçoit. Pour cet
exemple, utilisez un objet sans encodage de contenu HTTP, afin que le décodage par le navigateur ne
modifie pas ces octets.
L’opérateur doit disposer d’une source de données d’accès déjà configurée pour le SDK AWS et
autorisant la lecture de cet objet. Pour cette lecture sans version, cela inclut
s3:GetObject sur l’ARN exact de l’objet. Des exigences supplémentaires peuvent
s’appliquer, par exemple des autorisations KMS pour les objets SSE-KMS ; consultez les
exigences d’autorisation de GetObject.
Ne rendez pas l’objet public pour résoudre un échec de signature.
Pour un bucket de test dédié, utilisez cette configuration CORS JSON pour la page locale. Dans un bucket existant, intégrez la règle à sa configuration et vérifiez si d’autres règles autorisent des origines plus larges ; remplacer la configuration peut perturber d’autres applications. L’exemple envoie une requête GET sans en-têtes personnalisés et n’inspecte pas ETag : il ne nécessite donc ni en-têtes de requête avec caractères génériques ni en-têtes de réponse exposés. Consultez les éléments CORS de S3.
[
{
"AllowedOrigins": ["http://127.0.0.1:8787"],
"AllowedMethods": ["GET"],
"MaxAgeSeconds": 0
}
]
Une origine comprend le schéma, le nom d’hôte et le port. localhost et
127.0.0.1 sont des origines différentes. Utilisez l’URL exacte affichée par le
serveur local ci-dessous et adaptez la règle CORS si vous choisissez un autre port. En production,
servez l’application en HTTPS et autorisez son origine HTTPS exacte. L’activation de
CORS laisse les ACL et les politiques S3 en vigueur.
Utiliser le SDK AWS pour JavaScript
Utilisez une version de Node.js 26 maintenue et à jour des correctifs, Corepack installé séparément et un navigateur récent prenant en charge les flux Fetch et Web Crypto. Les scripts de configuration et ceux de l’opérateur ont été vérifiés avec Node.js 26.12.1, Corepack 0.36.0, Yarn 4.12.0 et les paquets du SDK AWS 3.1073.0 ; la page a été vérifiée dans Chromium 145. Maintenez Node à jour des correctifs en consultant ses notes de versions de sécurité. Le navigateur utilise les API de la plateforme et ne reçoit aucune clé d’accès secrète AWS.
Dans un répertoire accessible en écriture, collez ce qui suit dans Bash. Cela crée un nouveau
projet et laisse votre shell dans son répertoire d’origine. L’opération refuse de se poursuivre si
un répertoire s3-browser-import existe déjà. La configuration Yarn locale sélectionne
délibérément une installation locale de node_modules et ignore le chemin de
l’exécutable Yarn d’un projet englobant.
(
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
)
En cas d’échec de l’installation, le nouveau répertoire est conservé pour inspection. Enregistrez
les trois fichiers suivants dans s3-browser-import. Exécutez les commandes suivantes
depuis ce répertoire.
Générer un ticket sur la machine de l’opérateur
Enregistrez ce fichier sous sign.ts. Ses arguments sont le bucket, la clé,
le propriétaire du bucket, la taille attendue en octets, l’empreinte SHA-256 attendue et le délai
d’expiration en secondes. Ce composant de signature accepte les noms de bucket composés de lettres
minuscules, de chiffres et de traits d’union. Le délai d’expiration doit être un entier compris
entre 1 et 300 inclus. Toutes les métadonnées proviennent de l’enregistrement de confiance de
l’objet ; la signature seule ne vérifie ni l’existence de l’objet ni la réussite future de l’accès.
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
})
Définissez ces variables du shell à partir de votre enregistrement de confiance :
S3_BUCKET, S3_KEY, S3_OWNER,
S3_BYTES et S3_SHA256. Exécutez ensuite le point d’entrée
exactement comme indiqué ; il affiche un ticket JSON :
node sign.ts "$S3_BUCKET" "$S3_KEY" "$S3_OWNER" "$S3_BYTES" "$S3_SHA256" 60
Le composant de présignature des requêtes du SDK utilise la source de données d’accès de l’opérateur. Conservez cette source sur la machine de l’opérateur. Ne collez pas de données d’accès dans la page et n’ajoutez pas de point de terminaison de signature qui accepte des noms de bucket et des clés arbitraires sans autorisation.
Importer et vérifier l’objet dans le navigateur
Enregistrez cette page complète sous index.html. La taille indiquée dans le
ticket borne le tampon de l’objet dans l’application ; le flux doit se terminer à cette taille
exacte et son empreinte SHA-256 doit correspondre avant que la fonction utilitaire ne renvoie les
octets. Un minuteur de 30 secondes demande l’annulation pendant la récupération et les lectures du
corps de la réponse. Gardez l’onglet actif pendant l’importation ;
les onglets en arrière-plan peuvent retarder les minuteurs.
Un transfert plus lent peut échouer même sous la limite de taille. Une seule importation s’exécute
à la fois.
<!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 renvoie un Uint8Array que votre application peut
exploiter ; le formulaire présente le résultat vérifié sous la forme d’un nombre d’octets. Il
n’enregistre aucun fichier sur le disque et ne conserve pas les octets importés après la fin du
gestionnaire. Il efface le ticket collé et n’utilise ni stockage du navigateur, ni scripts tiers,
ni URL d’objet. Le navigateur et l’implémentation du calcul d’empreinte disposent toutefois de leurs
propres tampons : 1 MiB est donc une limite par objet, et non un plafond de mémoire totale du
navigateur.
Servir la page localement
Enregistrez ce fichier sous serve.ts. Il sert uniquement la page sur
l’interface de bouclage et désactive la mise en cache des réponses. Il n’expose pas les données
d’accès de l’opérateur et n’implémente pas la signature.
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
Ouvrez http://127.0.0.1:8787/, collez le JSON nouvellement généré dans
Import ticket,
puis sélectionnez Import. Une importation
réussie affiche Imported 16 verified bytes. pour un objet de
16 octets, ou le nombre réel d’octets de l’objet. Arrêtez le serveur au premier plan avec Ctrl+C
lorsque vous avez terminé. Utilisez un autre port si 8787 est occupé et mettez à jour l’URL de la
page ainsi que la règle CORS du bucket.
Conseils d’optimisation des performances
Cet exemple lit un objet de taille bornée en mémoire et calcule son empreinte avant de l’utiliser. Limitez les importations simultanées si vous intégrez la fonction utilitaire à une application plus vaste. Pour les fichiers qui dépassent le budget mémoire de cette application, choisissez plutôt un composant qui traite les données en flux ou un téléchargement classique par le navigateur ; ouvrir une URL de téléchargement ne fournit pas les octets importés à JavaScript et ne confirme pas qu’un fichier a été enregistré.
Bonnes pratiques de sécurité
Traitez le ticket entier comme une donnée sensible. Transmettez-le uniquement au destinataire prévu par un canal protégé ; excluez-le des URL de l’application, des outils d’analyse, des journaux, de l’historique partagé du presse-papiers et du stockage du navigateur. Effacer le formulaire ne peut pas révoquer les copies déjà effectuées. La chaîne de requête contient des informations de signature et peut inclure un jeton de session temporaire, même si elle ne contient pas la clé d’accès secrète AWS. Conservez la source de données d’accès de l’opérateur à l’écart du répertoire de la page et évitez d’enregistrer le terminal lors de l’affichage des tickets.
S3 vérifie l’expiration de l’URL au début d’une requête. Un téléchargement en cours peut se poursuivre après l’expiration ; un redémarrage après l’expiration peut échouer. Des données d’accès temporaires peuvent raccourcir la durée de validité de l’URL, et des changements d’autorisations peuvent également empêcher l’accès. L’URL de l’exemple, valide pendant 60 secondes, n’est pas un mécanisme d’effacement des données téléchargées. La somme de contrôle du ticket de cet exemple détecte une différence avec l’enregistrement du producteur de confiance ; elle n’établit pas la fiabilité d’un ticket fourni par un attaquant.
Problèmes courants et solutions
- La génération du ticket échoue : Vérifiez les six arguments, les métadonnées attendues et la source de données d’accès de l’opérateur. Ne déplacez pas les données d’accès dans le navigateur pour contourner l’échec.
- Le navigateur signale un échec : Son message public regroupe délibérément les échecs. Vérifiez si le ticket a expiré, si le composant de signature peut lire la clé exacte avec l’ID de propriétaire fourni et si l’origine de la page correspond à la règle CORS. Une réponse bloquée par CORS peut masquer son statut HTTP à JavaScript ; modifier CORS n’accorde pas les autorisations S3 manquantes.
- Une réponse lisible échoue malgré tout à la vérification : Confirmez la taille et l’empreinte SHA-256 du producteur, vérifiez si l’objet a été remplacé ou si un encodage de contenu HTTP est appliqué, puis générez un nouveau ticket pour les octets prévus. Une réponse tronquée, une somme de contrôle différente ou un transfert annulé ne doivent pas être signalés comme un objet importé.
Conclusion
Pour des importations gérées par l’application qui s’exécutent sur les serveurs de Transloadit plutôt que dans le navigateur, consultez notre Robot d’importation S3. Pour ce flux de travail dans le navigateur, laissez à l’émetteur la responsabilité d’autoriser l’accès à l’objet et transmettez uniquement le ticket à courte durée de validité au destinataire.
