Dans la première partie, nous avons chargé le kit de développement logiciel Amazon Web Services dans ColdFusion et créé une usine capable de générer des clients de service. C'était beaucoup de travail pour accomplir quelque chose que ColdFusion fait déjà très bien.
ColdFusion peut téléverser des fichiers vers Amazon Simple Storage Service à l'aide de ses fonctions de fichiers natives. Si le fichier est déjà arrivé sur le serveur ColdFusion, ces fonctions peuvent suffire.
Notre objectif est d'empêcher le fichier d'atteindre ColdFusion en premier lieu. Un téléversement traditionnel traverse notre infrastructure deux fois :
Browser
|
v
ColdFusion
|
v
Amazon S3Le navigateur envoie le fichier complet à ColdFusion. ColdFusion le stocke en mémoire ou dans un espace temporaire, le valide, puis l'envoie à Amazon. Pendant ce processus, une requête ColdFusion reste occupée à déplacer des octets d'une connexion réseau à une autre. C'est une organisation tout à fait raisonnable si notre plan d'infrastructure à long terme est de faire semblant que ColdFusion est un boyau d'arrosage.
Un téléversement direct change le trajet :
Browser ---- request permission ----> ColdFusion
Browser -------- file data ---------> Amazon S3
Browser ---- report completion -----> ColdFusion
ColdFusion ----- verify object -----> Amazon S3ColdFusion authentifie toujours l'utilisateur et décide si le téléversement est autorisé. Il choisit le bucket et la clé d'objet, limite le type de contenu et crée une requête présignée à courte durée de vie. Le navigateur envoie ensuite le fichier directement à Amazon à l'aide de cette requête. Le navigateur ne reçoit jamais nos identifiants Amazon. Le bucket ne devient pas public. L'adresse présignée autorise temporairement une seule opération sur une seule clé d'objet, en utilisant les autorisations de l'identité qui l'a créée.
Amazon documente ce modèle dans son guide sur le téléversement d'objets avec des adresses présignées.
Il y a trois règles importantes :
- Le serveur choisit la clé d'objet.
- La requête présignée expire rapidement.
- Le serveur vérifie l'objet obtenu avant de lui faire confiance.
Un nom de fichier fourni par un navigateur est une information d'affichage. Ce n'est pas un chemin de stockage. Si quelqu'un téléverse ../../quarterly-report.pdf, nous ne devrions pas récompenser sa créativité en l'incorporant à une clé d'objet. Nous allons générer notre propre identifiant et placer d'abord l'objet sous un préfixe de quarantaine :
quarantine/3fc42cf5-7e13-4db5-94f4-cdd96a20cb58Après vérification, Amazon le copiera vers :
objects/3fc42cf5-7e13-4db5-94f4-cdd96a20cb58Cette copie est importante parce qu'une requête présignée peut être réutilisée jusqu'à son expiration. Si nous acceptions la clé de quarantaine comme objet permanent, quelqu'un détenant l'adresse pourrait remplacer le fichier après que nous l'ayons vérifié. À la place, nous vérifions l'objet de quarantaine, copions cette version exacte vers sa clé finale et supprimons l'objet temporaire. La réutilisation de la requête présignée peut recréer l'objet de quarantaine, mais elle ne peut pas modifier la copie acceptée. Une règle de cycle de vie du bucket peut supprimer les objets de quarantaine abandonnés après une journée. L'ingénierie de sécurité consiste souvent à se demander : « Et s'ils le faisaient deux fois ? » puis à être mécontent de la réponse.
Le navigateur enverra une requête interorigine. Notre application pourrait être exécutée à :
https://app.example.comalors que le téléversement se fera vers une adresse semblable à :
https://example-private-uploads.s3.ca-central-1.amazonaws.comLe bucket a donc besoin d'une configuration de partage des ressources entre origines. Ajoutez cette configuration par l'entremise de la console Amazon Simple Storage Service ou de votre gestionnaire d'infrastructure [removed]
[
{
"AllowedHeaders": [ "content-type", "x-amz-meta-upload-id" ],
"AllowedMethods": [ "PUT" ],
"AllowedOrigins": [ "https://app.example.com" ],
"ExposeHeaders": [ "ETag" ],
"MaxAgeSeconds": 300
}
]Remplacez l'origine d'exemple par l'origine exacte qui sert votre application. N'utilisez pas * à moins que chaque site Web sur Internet soit réellement censé téléverser par l'entremise de ces requêtes.
La configuration interorigine n'accorde pas de permission au bucket. Les politiques du bucket et l'autorisation de requête présignée s'appliquent toujours. Elle indique seulement au navigateur que le JavaScript provenant de l'origine nommée peut effectuer la requête autorisée. Amazon explique la distinction dans sa documentation sur le partage des ressources entre origines.
L'identité Amazon utilisée par ColdFusion a besoin des autorisations pour téléverser, inspecter, copier et supprimer des objets dans les deux préfixes :
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::example-private-uploads/quarantine/*"
]
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::example-private-uploads/objects/*"
]
}
]
}Remplacez le nom du bucket et resserrez davantage la politique si votre application a une structure d'objet plus précise. Le bucket devrait conserver ses paramètres block-public-access. Nous accordons une permission temporaire à un seul objet, sans rouvrir le débat sur l'accès public ni inviter 2012 à revenir dans nos vies.
La partie un peut déjà créer un client Amazon Simple Storage Service. Ajoutez cette méthode pour qu'elle puisse créer un presigner en utilisant la même région et le même fournisseur d'identifiants :
public any function createS3Presigner(
string region_name = variables.default_region
) {
var region = createObject( "java", "software.amazon.awssdk.regions.Region" ).of( trim( arguments.region_name ) );
return createObject( "java", "software.amazon.awssdk.services.s3.presigner.S3Presigner" )
.builder()
.region( region )
.credentialsProvider( variables.credentials_provider )
.build();
}Le presigner ne contacte pas Amazon lorsqu'il crée une requête signée. Il utilise nos identifiants localement pour calculer une signature contenant l'opération, le bucket, la clé, l'expiration et tous les en-têtes de requête signés. Créez models/direct_s3_upload_service.cfc :
component output="false" {
public any function init(
required any client_factory,
required string bucket_name,
numeric maximum_bytes = 10485760,
numeric signature_minutes = 5
) {
variables.bucket_name = trim( arguments.bucket_name );
variables.maximum_bytes = fix( arguments.maximum_bytes );
variables.signature_minutes = fix( arguments.signature_minutes );
variables.allowed_content_types = {
"image/jpeg": true,
"image/png": true
};
if ( !len( variables.bucket_name ) ) {
throw( type = "DirectS3Upload.InvalidConfiguration", message = "Un compartiment de téléversement est requis." );
}
if ( variables.maximum_bytes < 1 ) {
throw( type = "DirectS3Upload.InvalidConfiguration", message = "La taille maximale de téléversement doit être supérieure à zéro." );
}
if ( variables.signature_minutes < 1 || variables.signature_minutes > 15 ) {
throw( type = "DirectS3Upload.InvalidConfiguration", message = "La durée de validité de la signature doit être comprise entre une et quinze minutes." );
}
variables.s3_client = arguments.client_factory.createClient( "s3" );
variables.presigner = arguments.client_factory.createS3Presigner();
return this;
}
public struct function createUpload(
required string user_id,
required string content_type,
required numeric content_length
) {
var normalized_user_id = trim( arguments.user_id );
var normalized_content_type = lCase( trim( arguments.content_type ) );
var normalized_content_length = fix( arguments.content_length );
if ( !len( normalized_user_id ) ) {
throw( type = "DirectS3Upload.InvalidUser", message = "Un utilisateur authentifié est requis." );
}
if ( !structKeyExists( variables.allowed_content_types, normalized_content_type ) ) {
throw( type = "DirectS3Upload.InvalidContentType", message = "Seules les images JPEG et PNG sont acceptées." );
}
if ( normalized_content_length < 1 || normalized_content_length > variables.maximum_bytes ) {
throw( type = "DirectS3Upload.InvalidSize", message = "Le fichier est vide ou dépasse la limite de téléversement." );
}
var upload_id = lCase( createUUID() );
var staging_key = "quarantine/" & upload_id;
var final_key = "objects/" & upload_id;
var metadata = createObject( "java", "java.util.HashMap" ).init();
metadata.put( javaCast( "string", "upload-id" ), javaCast( "string", upload_id ) );
var object_request = createObject( "java", "software.amazon.awssdk.services.s3.model.PutObjectRequest" )
.builder()
.bucket( variables.bucket_name )
.key( staging_key )
.contentType( normalized_content_type )
.metadata( metadata )
.build();
var duration = createObject( "java", "java.time.Duration" ).ofMinutes( javaCast( "long", variables.signature_minutes ) );
var presign_request = createObject( "java", "software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest" )
.builder()
.signatureDuration( duration )
.putObjectRequest( object_request )
.build();
var signed_request = variables.presigner.presignPutObject( presign_request );
return {
intent: {
upload_id: upload_id,
user_id: normalized_user_id,
staging_key: staging_key,
final_key: final_key,
content_type: normalized_content_type,
content_length: normalized_content_length,
expires_at: dateAdd( "n", variables.signature_minutes, now() )
},
browser: {
upload_id: upload_id,
method: "PUT",
url: signed_request.url().toExternalForm(),
headers: {
"Content-Type": normalized_content_type,
"x-amz-meta-upload-id": upload_id
}
}
};
}
public struct function verifyAndFinalize( required struct intent ) {
var head_request = createObject( "java", "software.amazon.awssdk.services.s3.model.HeadObjectRequest" )
.builder()
.bucket( variables.bucket_name )
.key( arguments.intent.staging_key )
.build();
try {
var head_response = variables.s3_client.headObject( head_request );
} catch ( any error ) {
throw(
type = "DirectS3Upload.ObjectNotFound",
message = "L'objet téléversé n'a pas pu être vérifié.",
detail = error.message
);
}
var actual_content_length = val( head_response.contentLength() );
var actual_content_type = lCase( toString( head_response.contentType() ?: "" ) );
var actual_upload_id = "";
if ( head_response.metadata().containsKey( "upload-id" ) ) {
actual_upload_id = toString( head_response.metadata().get( "upload-id" ) );
}
var violations = [];
if ( actual_upload_id != arguments.intent.upload_id ) {
arrayAppend( violations, "identifiant de téléversement" );
}
if ( actual_content_length != arguments.intent.content_length ) {
arrayAppend( violations, "longueur du contenu" );
}
if ( actual_content_length < 1 || actual_content_length > variables.maximum_bytes ) {
arrayAppend( violations, "limite de téléversement" );
}
if ( actual_content_type != arguments.intent.content_type ) {
arrayAppend( violations, "type de contenu" );
}
if ( arrayLen( violations ) ) {
deleteQuietly( arguments.intent.staging_key );
throw(
type = "DirectS3Upload.VerificationFailed",
message = "L'objet téléversé ne correspondait pas au téléversement autorisé.",
detail = arrayToList( violations )
);
}
var copy_request = createObject( "java", "software.amazon.awssdk.services.s3.model.CopyObjectRequest" )
.builder()
.copySource( variables.bucket_name & "/" & arguments.intent.staging_key )
.copySourceIfMatch( head_response.eTag() )
.destinationBucket( variables.bucket_name )
.destinationKey( arguments.intent.final_key )
.build();
try {
var copy_response = variables.s3_client.copyObject( copy_request );
} catch ( any error ) {
throw(
type = "DirectS3Upload.FinalizationFailed",
message = "L'objet téléversé n'a pas pu être finalisé.",
detail = error.message
);
}
deleteQuietly( arguments.intent.staging_key );
return {
upload_id: arguments.intent.upload_id,
object_key: arguments.intent.final_key,
content_type: actual_content_type,
content_length: actual_content_length,
entity_tag: toString( copy_response.copyObjectResult().eTag() ?: "" )
};
}
public void function discardUpload(
required string staging_key
) {
deleteQuietly( arguments.staging_key );
}
public void function shutdown() {
try {
variables.presigner.close();
} catch ( any ignored ) {}
try {
variables.s3_client.close();
} catch ( any ignored ) {}
}
private void function deleteQuietly(
required string object_key
) {
try {
var delete_request = createObject( "java", "software.amazon.awssdk.services.s3.model.DeleteObjectRequest" )
.builder()
.bucket( variables.bucket_name )
.key( arguments.object_key )
.build();
variables.s3_client.deleteObject( delete_request );
} catch ( any error ) {
writeLog(
type = "warning",
text = "Impossible de supprimer l'objet de téléversement temporaire : " & arguments.object_key
);
}
}
}Le service accepte uniquement les types de contenu JPEG et PNG et limite la taille de fichier déclarée à dix mégaoctets. Ajustez ces deux règles pour votre application.
Le navigateur contrôle le type de contenu déclaré, donc cette valeur ne prouve pas le format réel du fichier. Il s'agit d'une contrainte d'autorisation. Dans la partie trois, Amazon Rekognition examinera l'image elle-même avant que nous l'acceptions. Le type de contenu et l'identifiant de téléversement font partie de la requête signée. Le navigateur doit envoyer ces en-têtes exacts. Modifier l'un ou l'autre invalide la signature.
Nous ne signons délibérément pas la longueur du contenu. Les navigateurs contrôlent cet en-tête et ne permettent pas à JavaScript de le définir directement. Nous vérifions la taille déclarée avant la signature et la comparons ensuite à la taille réelle de l'objet.
Le serveur associe aussi l'identifiant de téléversement comme métadonnée de l'objet. Comme cet en-tête est signé, le navigateur ne peut pas substituer un autre identifiant sans invalider la requête.
L'etag de HeadObject est transmis à copySourceIfMatch(). Amazon ne copiera l'objet de quarantaine que s'il correspond toujours à l'objet que nous avons inspecté. Cela comble l'écart entre la vérification et la finalisation. Sans cette condition, notre code pourrait inspecter un objet tandis que quelqu'un le remplace, puis copier le remplacement.
Les conditions de compétition entre le moment de la vérification et le moment de l'utilisation sont l'endroit où du code parfaitement raisonnable déraille après la tombée de la nuit.
Créez le service au démarrage de l'application :
public boolean function onApplicationStart() {
application.aws_client_factory = new models.aws_client_factory(
default_region = application.configuration.aws_region
);
application.direct_s3_upload_service = new models.direct_s3_upload_service(
client_factory = application.aws_client_factory,
bucket_name = application.configuration.upload_bucket,
maximum_bytes = 10485760,
signature_minutes = 5
);
return true;
}
public void function onApplicationEnd( required struct applicationScope ) {
if ( structKeyExists( arguments.applicationScope, "direct_s3_upload_service" ) ) {
arguments.applicationScope.direct_s3_upload_service.shutdown();
}
}Le client du service et le signateur préalable sont créés une seule fois et réutilisés. Nous les fermons lorsque l'application se termine. Notre premier point de terminaison crée la requête de téléversement. Cet exemple utilise la session ColdFusion pour stocker le téléversement en attente. Une base de données ou un cache partagé est un meilleur choix lorsque les requêtes peuvent atteindre plusieurs nœuds ColdFusion.
Créez uploads/create.cfm :
<cfscript>
// Exécutez vos vérifications normales d'authentification et de falsification de requêtes intersites avant ce code.
if ( !structKeyExists( session, "user" ) ) {
throw( type = "DirectS3Upload.Unauthorized", message = "Authentication is required." );
}
payload = deserializeJSON( toString( getHttpRequestData().content ) );
plan = application.direct_s3_upload_service.createUpload(
user_id = session.user.id,
content_type = payload.content_type,
content_length = payload.content_length
);
lock scope="session" type="exclusive" timeout="5" {
if ( !structKeyExists( session, "pending_s3_uploads" ) ) {
session.pending_s3_uploads = {};
}
session.pending_s3_uploads[ plan.intent.upload_id ] = plan.intent;
}
cfcontent( type = "application/json; charset=utf-8", reset = true );
writeOutput( serializeJSON( plan.browser ) );
</cfscript>Seule la partie navigateur du plan est renvoyée. Le nom du bucket, la clé finale de l'objet, l'utilisateur attendu et l'état de vérification restent sur le serveur. Utilisez l'authentification et la protection contre la falsification de requêtes intersites déjà présentes dans votre application sur les deux points de terminaison de téléversement. Une requête pré-signée ne devrait pas être accessible simplement parce que quelqu'un a découvert l'adresse qui en crée une. Le point de terminaison de finalisation récupère l'intention de téléversement appartenant au serveur et vérifie l'objet. Créez uploads/complete.cfm :
<cfscript>
// Exécutez vos vérifications normales d'authentification et de falsification de requêtes intersites avant ce code.
if ( !structKeyExists( session, "user" ) ) {
throw( type = "DirectS3Upload.Unauthorized", message = "Authentication is required." );
}
payload = deserializeJSON( toString( getHttpRequestData().content ) );
upload_id = lCase( trim( payload.upload_id ?: "" ) );
intent = {};
lock scope="session" type="readonly" timeout="5" {
if ( structKeyExists( session, "pending_s3_uploads" ) && structKeyExists( session.pending_s3_uploads, upload_id ) ) {
intent = duplicate( session.pending_s3_uploads[ upload_id ] );
}
}
if ( structIsEmpty( intent ) || intent.user_id != session.user.id ) {
throw( type = "DirectS3Upload.InvalidIntent", message = "The upload request is not valid." );
}
if ( dateCompare( now(), intent.expires_at, "s" ) > 0 ) {
application.direct_s3_upload_service.discardUpload( intent.staging_key );
throw( type = "DirectS3Upload.Expired", message = "The upload request has expired." );
}
result = application.direct_s3_upload_service.verifyAndFinalize( intent );
lock scope="session" type="exclusive" timeout="5" {
structDelete( session.pending_s3_uploads, upload_id );
}
// Enregistrez result.object_key avec l'enregistrement de l'application ici.
cfcontent( type = "application/json; charset=utf-8", reset = true );
writeOutput( serializeJSON( {
success: true,
upload: result
} ) );
</cfscript>Le commentaire immédiatement avant la réponse est l'endroit où la persistance propre à l'application doit aller. N'enregistrez pas le nom de fichier du navigateur ni la clé d'objet proposée comme si cela prouvait quoi que ce soit. Enregistrez la clé finale renvoyée par le service de vérification.
Le navigateur a besoin de trois requêtes - créer l'autorisation, téléverser le fichier et signaler l'achèvement.
<script>
async function uploadFileDirectly(file, requestToken) {
const createResponse = await fetch("/uploads/create.cfm", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-Request-Token": requestToken
},
body: JSON.stringify({
content_type: file.type,
content_length: file.size
})
});
if (!createResponse.ok) {
throw new Error("L'application a refusé le téléversement.");
}
const plan = await createResponse.json();
const uploadResponse = await fetch(plan.url, {
method: plan.method,
headers: plan.headers,
body: file
});
if (!uploadResponse.ok) {
throw new Error("Amazon n'a pas accepté le téléversement.");
}
const completeResponse = await fetch("/uploads/complete.cfm", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-Request-Token": requestToken
},
body: JSON.stringify({
upload_id: plan.upload_id
})
});
if (!completeResponse.ok) {
throw new Error("L'application n'a pas pu vérifier le téléversement.");
}
return completeResponse.json();
}
</script>X-Request-Token représente le mécanisme de vérification de requête que votre application utilise déjà. Les deux points de terminaison ColdFusion modifient l'état du serveur et devraient recevoir la même protection que toute autre soumission de formulaire authentifiée.
N'enregistrez pas l'adresse présignée. Toute personne qui la détient peut effectuer l'opération signée jusqu'à son expiration. Amazon traite les adresses présignées comme [redacted] et précise qu'elles peuvent être utilisées plus d'une fois pendant leur période de validité. La documentation de sécurité des adresses présignées d'Amazon décrit les restrictions disponibles.
Nous devrions également configurer une règle de cycle de vie qui supprime les objets sous quarantine/ après une courte période. Les navigateurs se ferment, les ordinateurs portables se mettent en veille, les connexions sans fil disparaissent, et il arrive parfois que des utilisateurs commencent un téléversement avant de s'éloigner pour profiter d'une vie plus riche. Ce n'est pas chaque objet abandonné qui indique une attaque. Chaque objet abandonné finit tout de même par devenir une facture de stockage.
Nous avons maintenant un flux de téléversement direct complet :
- ColdFusion authentifie et autorise l'utilisateur.
- ColdFusion génère la clé de l'objet.
- ColdFusion émet une autorisation de téléversement à courte durée de vie.
- Le navigateur envoie le fichier directement à Amazon.
- ColdFusion vérifie l'objet réel.
- Amazon copie l'objet vérifié vers sa clé permanente.
- ColdFusion supprime l'objet de quarantaine.
- L'application stocke uniquement le résultat finalisé.
Les données du fichier ne transitent jamais par ColdFusion. Notre serveur gère les décisions tandis qu'Amazon gère les octets, ce qui constitue une répartition des tâches plus saine pour tout le monde. Dans la troisième partie, nous insérerons Amazon Rekognition entre la vérification et la finalisation. Rekognition peut inspecter l'objet mis en quarantaine directement à partir d'Amazon Simple Storage Service, donc ColdFusion n'aura toujours pas besoin de télécharger l'image.
Nous nous sommes maintenant retirés avec succès du chemin des données. Historiquement, c'est à ce moment-là que le logiciel commence à beaucoup mieux fonctionner.