Utiliser des URL signées pour sécuriser le contenu de votre CDN
Votre application peut autoriser un téléchargement sans rendre le fichier public. Avec AWS CloudFront, votre serveur signe l’URL d’un objet et lui attribue une date d’expiration. Cette recette fournit un programme de signature TypeScript exécutable et un test qui vérifie que l’URL renvoie les octets attendus tandis que les autres voies d’accès restent fermées.
Comprendre la sécurité des CDN et les URL signées
Une URL signée constitue un justificatif d’accès au porteur : toute personne qui la reçoit peut l’utiliser jusqu’à son expiration. Autorisez l’utilisateur dans votre application avant d’émettre l’URL et choisissez l’objet côté serveur plutôt que de signer des URL arbitraires fournies par un client.
CloudFront vérifie la signature à l’aide d’une clé publique appartenant à un groupe de clés de confiance associé au comportement de cache correspondant. La clé privée correspondante reste sur votre serveur. L’exemple utilise une stratégie prédéfinie qui limite l’accès à une seule URL et à une échéance donnée ; elle ne lie pas le téléchargement à la personne qui l’a demandé. Consultez le fonctionnement des URL signées CloudFront.
Séparer l’autorisation des visiteurs de l’accès à l’origine
Deux contrôles doivent être cohérents. Le groupe de clés de confiance contrôle les requêtes des visiteurs vers CloudFront. Pour une origine S3 privée, un contrôle d’accès à l’origine (OAC) et une stratégie de bucket permettent à CloudFront de lire l’objet, tandis que les requêtes anonymes adressées directement à S3 restent refusées. Configurer un seul de ces contrôles ne suffit pas à établir les deux protections.
Cette page se concentre sur le programme de signature de l’application. Pour en savoir plus sur
l’infrastructure, consultez notre
guide de CDN d’images avec S3 et CloudFront.
Suivez les instructions de configuration d’OAC d’AWS
pour la stratégie d’origine et sa condition AWS:SourceArn propre à la distribution.
Utilisez une origine de bucket S3 classique ; un point de terminaison de site web S3 ne peut pas
utiliser OAC.
Prérequis pour la mise en œuvre
Préparez les éléments suivants avant d’exécuter le programme de signature :
- Une distribution CloudFront déployée avec une URL d’objet HTTPS et un objet privé connu dans S3. Conservez une copie locale de cet objet pour la comparaison des octets ci-dessous.
- Une paire de clés RSA-2048 dédiée au format PEM. Téléversez la clé publique dans CloudFront, placez-la dans un groupe de clés de confiance et associez ce groupe au comportement de cache qui sert l’objet. Suivez les instructions d’AWS sur les paires de clés et les groupes de clés de confiance.
- L’identifiant de la clé publique et le fichier de clé privée correspondant, lisible uniquement par le compte du serveur. Cet identifiant n’est ni une clé d’accès IAM ni un identifiant de groupe de clés. La signature locale ne nécessite ni clé d’accès AWS ni profil d’informations d’accès pour le SDK.
- Un bucket privé avec S3 Block Public Access activé, OAC configuré pour toujours signer les requêtes vers l’origine et une stratégie de bucket accordant un accès en lecture à cette distribution. Attendez la fin du déploiement avant de tester. Maintenez l’horloge du serveur de signature synchronisée.
- Node.js 26 et Corepack installé séparément. Utilisez une version
corrective maintenue de Node.js. Les versions utilisées pour reproduire l’exemple sont
Node.js 26.8.1, Yarn 4.12.0 et
@aws-sdk/cloudfront-signer3.1069.0 ; ce sont des versions testées, pas des versions minimales intrinsèquement requises.
Mettre en place des URL signées avec AWS CloudFront
Exécutez cette configuration dans Bash depuis un répertoire accessible en écriture. Elle crée
cloudfront-url-demo et laisse votre shell dans son répertoire initial. Un répertoire
existant est refusé ; choisissez un nouveau nom plutôt que d’écraser un projet. Le fichier de
verrouillage du sous-projet et ses paramètres Yarn locaux isolent l’installation de ses paquets de
l’espace de travail parent, même si celui-ci utilise Plug’n’Play.
(
command -v node >/dev/null &&
command -v corepack >/dev/null &&
mkdir cloudfront-url-demo &&
cd cloudfront-url-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0","dependencies":{"@aws-sdk/cloudfront-signer":"3.1069.0"}}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' 'plugins: []' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn install
)
Si l’installation échoue, conservez le projet et examinez l’erreur du gestionnaire de paquets.
Après en avoir corrigé la cause, exécutez corepack yarn install dans
cloudfront-url-demo ; ne répétez pas l’étape de création du répertoire. Enregistrez ce
programme complet dans cloudfront-url-demo/signed-url.mts :
import { createPrivateKey } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import { getSignedUrl } from '@aws-sdk/cloudfront-signer'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const [resourceUrl, lifetime = '300'] = args
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID
const keyFile = process.env.CLOUDFRONT_PRIVATE_KEY_FILE
if (args.length < 1 || args.length > 2 || !resourceUrl || !keyPairId || !keyFile) {
throw new Error('Missing configuration')
}
if (!/^[A-Za-z0-9]+$/u.test(keyPairId) || !isAbsolute(keyFile)) {
throw new Error('Invalid key configuration')
}
const url = new URL(resourceUrl)
if (
url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
url.href !== resourceUrl
) {
throw new Error('Use an exact HTTPS object URL without a query or fragment')
}
const seconds = Number(lifetime)
if (!/^\d+$/u.test(lifetime) || !Number.isSafeInteger(seconds) || seconds < 1 || seconds > 3600) {
throw new Error('Lifetime must be an integer from 1 to 3600 seconds')
}
const privateKey = await readFile(keyFile, 'utf8')
const key = createPrivateKey(privateKey)
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails?.modulusLength !== 2048) {
throw new Error('Use an RSA-2048 private key')
}
const signedUrl = getSignedUrl({
url: resourceUrl,
keyPairId,
privateKey,
dateLessThan: new Date(Date.now() + seconds * 1000).toISOString(),
})
process.stdout.write(`${signedUrl}\n`)
}
main().catch(() => {
console.error('Could not sign URL. Check the HTTPS URL, key ID, RSA-2048 PEM file, and 1–3600 second lifetime.')
process.exitCode = 1
})
Remplacez les trois valeurs de configuration par les vôtres. Le deuxième argument est une durée de validité en secondes ; si vous l’omettez, la durée choisie est de 300 secondes. Cet exemple n’accepte volontairement que des URL HTTPS exactes, sans chaîne de requête ni fragment, et limite la durée de validité à une heure.
CLOUDFRONT_KEY_PAIR_ID='YOUR_PUBLIC_KEY_ID' \
CLOUDFRONT_PRIVATE_KEY_FILE='/absolute/path/to/private-key.pem' \
node cloudfront-url-demo/signed-url.mts \
'https://YOUR_DISTRIBUTION.cloudfront.net/private/guide.pdf' 300
En cas de réussite, une seule URL est écrite sur la sortie standard. Une configuration invalide, une clé illisible ou mal formée, ou une durée de validité invalide entraînent un code de sortie non nul, sans URL. Une clé bien formée mais ne correspondant pas à la clé attendue peut néanmoins signer localement ; seul CloudFront peut établir que le comportement déployé lui fait confiance.
Tests et validation
Générer une chaîne signée ne prouve pas que le CDN applique les restrictions. Enregistrez le
programme suivant dans cloudfront-url-demo/verify-private.mts. Il exécute le programme de signature,
télécharge la réponse complète et la compare à votre original local avant de tester les quatre
voies d’accès interdites. Chaque vérification HTTP dispose d’un délai de 15 secondes, corps de la
réponse compris, et toute redirection fait échouer la vérification.
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
async function main(): Promise<void> {
const [objectUrl, directS3Url, originalFile, ...extra] = process.argv.slice(2)
assert(objectUrl && directS3Url && originalFile && extra.length === 0, 'Supply both URLs and the original file')
const expected = await readFile(originalFile)
const signerFile = fileURLToPath(new URL('./signed-url.mts', import.meta.url))
function sign(seconds: number): string {
return execFileSync(process.execPath, [signerFile, objectUrl, String(seconds)], {
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
}
async function check(url: string, allowed: boolean): Promise<void> {
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(15_000) })
const bytes = Buffer.from(await response.arrayBuffer())
if (allowed) {
assert.equal(response.status, 200, 'Valid URL was denied')
assert.equal(Buffer.compare(bytes, expected), 0, 'Downloaded bytes differ from the original')
} else {
assert.equal(response.status, 403, 'Private access was not denied')
assert.notEqual(Buffer.compare(bytes, expected), 0, 'Denied response disclosed the object')
}
}
const valid = sign(300)
await check(valid, true)
await check(objectUrl, false)
const tampered = new URL(valid)
const signature = tampered.searchParams.get('Signature')
assert(signature, 'Missing signature')
tampered.searchParams.set('Signature', `${signature[0] === 'A' ? 'B' : 'A'}${signature.slice(1)}`)
await check(tampered.href, false)
const expired = sign(1)
await delay(Math.max(0, Number(new URL(expired).searchParams.get('Expires')) * 1000 - Date.now()) + 1500)
await check(expired, false)
await check(directS3Url, false)
console.log('Private delivery checks passed.')
}
main().catch(() => {
console.error('Private delivery check failed. Check the object bytes and the CloudFront and S3 access configuration.')
process.exitCode = 1
})
Exécutez-le avec l’URL de votre objet CloudFront, l’URL S3 directe du même objet et son original local :
CLOUDFRONT_KEY_PAIR_ID='YOUR_PUBLIC_KEY_ID' \
CLOUDFRONT_PRIVATE_KEY_FILE='/absolute/path/to/private-key.pem' \
node cloudfront-url-demo/verify-private.mts \
'https://YOUR_DISTRIBUTION.cloudfront.net/private/guide.pdf' \
'https://YOUR_BUCKET.s3.us-east-1.amazonaws.com/private/guide.pdf' \
'/absolute/path/to/original-guide.pdf'
Le script affiche Private delivery checks passed. uniquement après réception des octets attendus et après que les quatre requêtes interdites ont renvoyé le code 403 sans divulguer ces octets. Le test d’expiration lance une nouvelle requête après l’expiration. CloudFront vérifie l’expiration au début d’une requête ; il n’interrompt pas un téléchargement déjà en cours lorsque cette échéance est dépassée.
Résoudre les problèmes courants
Si la requête valide échoue, vérifiez d’abord le comportement de cache déployé, l’identifiant de la clé publique du groupe de confiance et la clé privée correspondante. Un code 403 ne permet pas à lui seul de distinguer une signature incorrecte d’un problème d’accès à l’origine. Vérifiez aussi le chemin de l’objet, OAC et la stratégie du bucket. Le guide de dépannage des erreurs 403 d’AWS détaille ces vérifications.
Si la requête valide renvoie des octets différents, examinez l’URL de l’objet et l’original local, puis recherchez un éventuel objet plus ancien dans le cache. Si une requête non signée aboutit, examinez le comportement de cache qui correspond réellement à ce chemin. Si l’accès direct à S3 aboutit, corrigez la stratégie d’accès du bucket avant d’émettre des liens de téléchargement. Le programme de vérification ne teste que cet objet et ce chemin de distribution ; répétez le test pour les autres comportements protégés.
Bonnes pratiques de sécurité
Gardez la clé privée hors des fichiers destinés au navigateur, du système de gestion de versions et des journaux. Montez-la depuis le stockage des secrets de votre serveur sous forme de fichier à accès restreint. Traitez également l’URL signée comme un secret : évitez de l’enregistrer dans les outils d’analyse, les journaux de requêtes ou les messages au support. Ne la partagez qu’avec le destinataire autorisé, via HTTPS.
Choisissez une courte durée de validité qui laisse suffisamment de temps pour commencer le téléchargement. Pour la rotation, déployez la nouvelle clé publique aux côtés de l’ancienne avant de modifier le programme de signature ; conservez l’ancienne clé jusqu’à l’expiration des URL encore valides, sauf si vous souhaitez les révoquer. AWS décrit cette période de coexistence dans sa procédure de rotation des clés.
Une fois les tests de diffusion privée réussis, reliez le programme de signature à la décision d’autorisation de votre application. Ne renvoyez une URL que pour l’objet auquel l’utilisateur authentifié a le droit d’accéder et conservez les mêmes tests de diffusion lorsque vous modifiez la distribution ou sa stratégie d’origine.
