Dateien aus Amazon S3 sicher im Browser importieren
Belassen Sie AWS-Zugangsdaten auf dem Server oder dem Rechner des Betreibers. Stellen Sie für ein autorisiertes Objekt eine kurzlebige vorsignierte GET-URL aus und lassen Sie den Browser dessen Bytes aus Amazon S3 abrufen. Dieses Beispiel importiert ein Objekt mit bis zu 1 MiB und prüft seinen gesamten Inhalt anhand einer vertrauenswürdigen Byte-Länge und SHA-256-Prüfsumme, bevor es Erfolg meldet.
Warum direkt aus Amazon S3 im Browser importieren?
Die Datei gelangt von S3 zum Browser, sodass Ihr Anwendungsserver den Antwortkörper nicht weiterleiten muss. Der Server entscheidet weiterhin, wer eine URL für welches Objekt erhalten darf. Dies ist ein kleines Beispiel, das der Betreiber ausführt und bei dem die Daten manuell an eine lokale Seite übergeben werden. Es implementiert weder eine Anmeldung an der Anwendung noch einen öffentlichen Signier-Endpunkt. Eine Produktionsanwendung muss den Aufrufer authentifizieren und den Zugriff auf das konkrete Objekt autorisieren, bevor sie dessen URL ausstellt.
Eine vorsignierte URL gewährt ihrem Inhaber Zugriff: Wer sie erhält, kann sie während ihrer Gültigkeit nutzen, auch außerhalb eines Browsers. Sie kann mehrfach verwendet werden. CORS steuert, ob JavaScript von einem anderen Ursprung die Antwort lesen darf; S3-Berechtigungen und die Signatur steuern den Objektzugriff.
Ihren S3-Bucket einrichten
Verwenden Sie einen privaten S3-Bucket für allgemeine Zwecke in us-east-1,
mit aktivierter Einstellung Block Public Access und einem kleinen vorhandenen Objekt. Halten Sie den
Bucket-Namen, den exakten Schlüssel, die 12-stellige Konto-ID des Bucket-Eigentümers, die Byte-Länge
und die SHA-256-Prüfsumme aus dem vertrauenswürdigen Upload-Datensatz des Erstellers bereit. Akzeptiert
werden Größen von null bis einschließlich 1.048.576 Bytes. Die Prüfsumme muss die gespeicherten
Objektbytes beschreiben und darf kein Hash sein, der aus den zufällig vom Browser empfangenen Daten
berechnet wurde. Verwenden Sie für dieses Beispiel ein Objekt ohne HTTP-Inhaltskodierung, damit die
Decodierung im Browser diese Bytes nicht verändert.
Der Betreiber benötigt eine bereits konfigurierte Zugangsdatenquelle für das AWS SDK mit der
Berechtigung, dieses Objekt zu lesen. Für diesen Lesezugriff ohne Versionsangabe gehört dazu
s3:GetObject für den exakten Objekt-ARN. Weitere Anforderungen können gelten,
etwa KMS-Berechtigungen für SSE-KMS-Objekte; siehe die
Autorisierungsanforderungen für GetObject.
Machen Sie das Objekt nicht öffentlich, um einen Signierfehler zu beheben.
Verwenden Sie für einen eigens eingerichteten Test-Bucket diese JSON-CORS-Konfiguration für die lokale Seite. Fügen Sie bei einem vorhandenen Bucket die Regel in dessen Konfiguration ein und prüfen Sie, ob andere Regeln weiter gefasste Ursprünge zulassen. Ein Ersetzen der Konfiguration kann andere Anwendungen beeinträchtigen. Das Beispiel sendet GET ohne benutzerdefinierte Header und wertet ETag nicht aus. Es benötigt daher weder Platzhalter für Anfrageheader noch freigegebene Antwortheader. Siehe die CORS-Elemente von S3.
[
{
"AllowedOrigins": ["http://127.0.0.1:8787"],
"AllowedMethods": ["GET"],
"MaxAgeSeconds": 0
}
]
Ein Ursprung umfasst Schema, Hostname und Port. localhost und
127.0.0.1 sind unterschiedliche Ursprünge.
Verwenden Sie die exakte URL, die der lokale Server unten ausgibt, und passen Sie die CORS-Regel an,
falls Sie einen anderen Port wählen. Stellen Sie die Anwendung im Produktionsbetrieb über HTTPS
bereit und erlauben Sie ihren exakten HTTPS-Ursprung. Auch bei aktiviertem
CORS bleiben S3-ACLs und Richtlinien wirksam.
Das AWS SDK für JavaScript verwenden
Verwenden Sie eine gepflegte Version von Node.js 26 mit aktuellen Patches, separat installiertes Corepack und einen aktuellen Browser mit Fetch-Streams und Web Crypto. Die Einrichtung und die Betreiberskripte wurden mit Node.js 26.12.1, Corepack 0.36.0, Yarn 4.12.0 und den AWS-SDK-Paketen 3.1073.0 geprüft; die Seite wurde in Chromium 145 geprüft. Halten Sie Node anhand seiner Sicherheitshinweise zu Releases auf dem aktuellen Patchstand. Der Browser verwendet Plattform-APIs und erhält keinen geheimen AWS-Zugriffsschlüssel.
Fügen Sie den folgenden Inhalt in einem beschreibbaren Verzeichnis in Bash ein. Er erstellt ein neues
Projekt und belässt Ihre Shell im ursprünglichen Verzeichnis. Wenn das Verzeichnis
s3-browser-import bereits existiert, wird der Vorgang abgelehnt. Die lokale
Yarn-Konfiguration wählt bewusst eine lokale Installation von node_modules
und ignoriert den Pfad zur ausführbaren Yarn-Datei eines übergeordneten Projekts.
(
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
)
Bei einer fehlgeschlagenen Installation bleibt das neue Verzeichnis zur Prüfung erhalten. Speichern
Sie die nächsten drei Dateien in s3-browser-import. Führen Sie die nachfolgenden
Befehle in diesem Verzeichnis aus.
Ein Ticket auf dem Rechner des Betreibers ausstellen
Speichern Sie dies als sign.ts. Die Argumente sind Bucket, Schlüssel,
Bucket-Eigentümer, erwartete Byte-Länge, erwartete SHA-256-Prüfsumme und Gültigkeitsdauer in Sekunden.
Dieses Signierprogramm akzeptiert Bucket-Namen aus Kleinbuchstaben, Ziffern und Bindestrichen.
Die Gültigkeitsdauer muss eine ganze Zahl von 1 bis 300 sein. Alle Metadaten stammen aus dem
vertrauenswürdigen Objektdatensatz. Das Signieren allein prüft weder, ob das Objekt existiert,
noch ob der Zugriff erfolgreich sein wird.
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
})
Setzen Sie diese Shell-Variablen auf die Werte Ihres vertrauenswürdigen Datensatzes:
S3_BUCKET, S3_KEY, S3_OWNER, S3_BYTES und
S3_SHA256. Führen Sie dann den Einstiegspunkt exakt wie angegeben aus.
Er gibt ein JSON-Ticket aus:
node sign.ts "$S3_BUCKET" "$S3_KEY" "$S3_OWNER" "$S3_BYTES" "$S3_SHA256" 60
Der Anfrage-Signierer des SDK verwendet die Zugangsdatenquelle des Betreibers. Belassen Sie diese Quelle auf dessen Rechner. Fügen Sie keine Zugangsdaten in die Seite ein. Ergänzen Sie auch keinen Signier-Endpunkt, der ohne Autorisierung beliebige Bucket-Namen und Schlüssel akzeptiert.
Das Objekt im Browser importieren und verifizieren
Speichern Sie diese vollständige Seite als index.html. Die Größenangabe im
Ticket begrenzt den Objektpuffer der Anwendung. Der Stream muss bei exakt dieser Größe enden, und
seine SHA-256-Prüfsumme muss übereinstimmen, bevor die Hilfsfunktion Bytes zurückgibt.
Ein Timer fordert nach 30 Sekunden während des Abrufs und des Lesens des Antwortkörpers einen
Abbruch an. Halten Sie den Tab während des Imports aktiv;
Hintergrund-Tabs können Timer verzögern.
Eine langsamere Übertragung kann auch unterhalb des Größenlimits fehlschlagen. Es läuft jeweils
nur ein Import.
<!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 gibt einen Wert vom Typ Uint8Array zur
Verwendung in Ihrer Anwendung zurück. Das Formular zeigt das verifizierte Ergebnis als Byte-Anzahl
an. Es speichert keine Datei auf dem Datenträger und behält die importierten Bytes nach Abschluss
der Ereignisbehandlung nicht bei. Es leert das Feld mit dem eingefügten Ticket und verwendet weder
Browserspeicher noch Drittanbieter-Skripte oder Objekt-URLs. Browser und Hash-Implementierung haben
weiterhin eigene Puffer. Daher ist 1 MiB ein Objektlimit und keine Obergrenze für den gesamten
Speicherverbrauch des Browsers.
Die Seite lokal bereitstellen
Speichern Sie dies als serve.ts. Der Server stellt ausschließlich die Seite
über Loopback bereit und deaktiviert das Caching von Antworten. Er legt keine Zugangsdaten des
Betreibers offen und implementiert keine Signierung.
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
Öffnen Sie http://127.0.0.1:8787/, fügen Sie das neue JSON in Import ticket ein
und wählen Sie Import. Ein erfolgreicher Import zeigt
Imported 16 verified bytes. für ein Objekt mit 16 Bytes an oder die tatsächliche
Byte-Anzahl des Objekts. Beenden Sie anschließend den Server im Vordergrund mit Ctrl+C. Verwenden
Sie einen anderen Port, wenn 8787 belegt ist, und aktualisieren Sie sowohl die Seiten-URL als auch
die CORS-Regel des Buckets.
Tipps zur Leistungsoptimierung
Dieses Beispiel liest ein größenbegrenztes Objekt in den Speicher und berechnet vor der Verwendung seinen Hash. Begrenzen Sie gleichzeitige Importe, wenn Sie die Hilfsfunktion in eine größere Anwendung integrieren. Wählen Sie für Dateien, die das Speicherbudget dieser Anwendung überschreiten, stattdessen eine streambasierte Verarbeitung oder einen normalen Browserdownload. Das Öffnen einer Download-URL stellt JavaScript weder die importierten Bytes bereit noch bestätigt es, dass eine Datei gespeichert wurde.
Bewährte Verfahren für die Sicherheit
Behandeln Sie das gesamte Ticket als vertraulich. Geben Sie es nur über einen geschützten Kanal an den vorgesehenen Empfänger weiter. Halten Sie es aus Anwendungs-URLs, Analysedaten, Logs, gemeinsam genutzten Zwischenablageverläufen und dem Browserspeicher heraus. Das Leeren des Formulars kann bereits erstellte Kopien nicht widerrufen. Die Abfragezeichenfolge enthält Signierinformationen und kann ein temporäres Sitzungstoken enthalten, auch wenn sie keinen geheimen AWS-Zugriffsschlüssel enthält. Bewahren Sie die Zugangsdatenquelle des Betreibers getrennt vom Seitenverzeichnis auf und vermeiden Sie Terminalaufzeichnungen, wenn Sie Tickets anzeigen.
S3 prüft die URL-Gültigkeit zu Beginn einer Anfrage. Ein laufender Download kann nach Ablauf der Gültigkeit fortgesetzt werden; ein Neustart danach kann fehlschlagen. Temporäre Zugangsdaten können die Gültigkeitsdauer der URL verkürzen, und Berechtigungsänderungen können den Zugriff ebenfalls verhindern. Die URL mit 60 Sekunden Gültigkeit aus diesem Beispiel ist kein Mechanismus zum Löschen heruntergeladener Daten. Ihre Prüfsumme erkennt eine Abweichung vom vertrauenswürdigen Datensatz des Erstellers. Sie belegt nicht die Vertrauenswürdigkeit eines Tickets, das ein Angreifer bereitstellt.
Häufige Probleme und Lösungen
- Das Ausstellen des Tickets schlägt fehl: Prüfen Sie die sechs Argumente, die erwarteten Metadaten und die Zugangsdatenquelle des Betreibers. Verschieben Sie keine Zugangsdaten in den Browser, um den Fehler zu umgehen.
- Der Browser meldet einen Fehler: Seine öffentlich sichtbare Meldung fasst Fehler bewusst zusammen. Prüfen Sie, ob das Ticket abgelaufen ist, ob das Signierprogramm den exakten Schlüssel mit der angegebenen Eigentümer-ID lesen kann und ob der Ursprung der Seite mit CORS übereinstimmt. Eine durch CORS blockierte Antwort kann ihren HTTP-Status vor JavaScript verbergen. Eine Änderung an CORS erteilt keine fehlenden S3-Berechtigungen.
- Eine lesbare Antwort besteht die Verifizierung dennoch nicht: Bestätigen Sie die Größenangabe und SHA-256-Prüfsumme des Erstellers, prüfen Sie, ob das Objekt ersetzt wurde oder eine HTTP-Inhaltskodierung vorliegt, und stellen Sie ein neues Ticket für die vorgesehenen Bytes aus. Eine abgeschnittene Antwort, eine abweichende Prüfsumme oder eine abgebrochene Übertragung darf nicht als importiertes Objekt gemeldet werden.
Fazit
Für anwendungsgesteuerte Importe, die auf den Servern von Transloadit statt im Browser laufen, verwenden Sie unseren Robot für S3-Importe. Bei diesem Browserworkflow muss die Objekt-Autorisierung beim Aussteller bleiben. Geben Sie nur das kurzlebige Ticket an den Empfänger weiter.
