Lab práctico · Semana 6: Redes en AWS (II): DNS, borde de red y protección
Sitio estático privado en S3 servido por CloudFront con OAC
Qué vas a construir
Un sitio web estático en un bucket S3 privado (Block Public Access activado) que solo se puede leer a través de una distribución de CloudFront con origin access control (OAC). Además:
- Una CloudFront Function que añade cabeceras de seguridad a todas las respuestas.
- Pruebas de caché (
Miss→Hit), de TTL y de invalidación. - La comprobación de que el bucket no es accesible directamente.
Lo despliegas con CloudFormation (una pila que podrás reutilizar en el lab 12 para colgarle AWS WAF).
flowchart LR
u["Tu navegador / curl"]
cf["CloudFront<br/>d123.cloudfront.net<br/>CloudFront Function (viewer response)"]
s3[("Bucket S3 privado<br/>eu-south-2")]
u -- "HTTPS" --> cf
cf -- "OAC: peticiones firmadas SigV4" --> s3
u -. "acceso directo: 403" .-> s3
Antes de empezar
- Usuario de IAM Identity Center o IAM con MFA (nunca root), eu-south-2, AWS CloudShell.
- CloudFront es un servicio global: la distribución no «vive» en eu-south-2, pero la pila de CloudFormation y el bucket sí.
export AWS_REGION=eu-south-2
Paso 1: la plantilla
Crea lab11.yaml en CloudShell (nano lab11.yaml, pega, Ctrl+O, Ctrl+X):
AWSTemplateFormatVersion: "2010-09-09"
Description: Lab 11 - sitio estatico privado en S3 servido por CloudFront con OAC
Parameters:
WebAclArn:
Type: String
Default: ""
Description: ARN de una web ACL de AWS WAF (us-east-1, ambito CLOUDFRONT). Vacio = sin WAF (lab 12)
Conditions:
ConWaf: !Not [!Equals [!Ref WebAclArn, ""]]
Resources:
Bucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
Tags: [{ Key: lab, Value: lab11 }]
Oac:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub "${AWS::StackName}-oac"
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
CabecerasSeguridad:
Type: AWS::CloudFront::Function
Properties:
Name: !Sub "${AWS::StackName}-cabeceras"
AutoPublish: true
FunctionConfig:
Comment: Anade cabeceras de seguridad
Runtime: cloudfront-js-2.0
FunctionCode: |
function handler(event) {
var response = event.response;
var headers = response.headers;
headers['strict-transport-security'] = { value: 'max-age=63072000; includeSubdomains' };
headers['x-content-type-options'] = { value: 'nosniff' };
headers['x-frame-options'] = { value: 'DENY' };
headers['x-lab'] = { value: 'lab11' };
return response;
}
Distribucion:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
Comment: lab11
DefaultRootObject: index.html
HttpVersion: http2and3
PriceClass: PriceClass_100
WebACLId: !If [ConWaf, !Ref WebAclArn, !Ref "AWS::NoValue"]
Origins:
- Id: origen-s3
DomainName: !GetAtt Bucket.RegionalDomainName
OriginAccessControlId: !GetAtt Oac.Id
S3OriginConfig:
OriginAccessIdentity: ""
DefaultCacheBehavior:
TargetOriginId: origen-s3
ViewerProtocolPolicy: redirect-to-https
AllowedMethods: [GET, HEAD]
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
Compress: true
FunctionAssociations:
- EventType: viewer-response
FunctionARN: !GetAtt CabecerasSeguridad.FunctionMetadata.FunctionARN
PoliticaBucket:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref Bucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AllowCloudFrontServicePrincipalReadOnly
Effect: Allow
Principal: { Service: cloudfront.amazonaws.com }
Action: s3:GetObject
Resource: !Sub "${Bucket.Arn}/*"
Condition:
StringEquals:
AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribucion}"
Outputs:
BucketName: { Value: !Ref Bucket }
DistributionId: { Value: !Ref Distribucion }
DomainName: { Value: !GetAtt Distribucion.DomainName }
Qué hace cada pieza:
- Bucket con Block Public Access completo: nadie puede hacerlo público por error.
- OAC con
SigningBehavior: always: CloudFront firma todas sus peticiones a S3 (y van por HTTPS). - Distribución con
redirect-to-https, soloGET/HEAD, la política de caché gestionada CachingOptimized (ID658327ea-f89d-4fab-a63d-7e88639e58f6: TTL mínimo 1 s, por defecto 24 h, máximo 365 días),PriceClass_100(las ubicaciones más baratas) y la función en viewer response. - Bucket policy que solo permite
s3:GetObjectal servicio CloudFront cuando la petición viene de esta distribución (AWS:SourceArn). - El parámetro
WebAclArnvacío: en el lab 12 lo rellenarás para asociar AWS WAF sin tocar la plantilla.
Paso 2: desplegar
aws cloudformation deploy --stack-name lab11-cdn --template-file lab11.yaml --tags lab=lab11
La distribución tarda unos minutos en desplegarse por todo el mundo. Al terminar, guarda las salidas:
out () { aws cloudformation describe-stacks --stack-name lab11-cdn \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text; }
BUCKET=$(out BucketName); DIST=$(out DistributionId); CF=$(out DomainName)
echo "export AWS_REGION=eu-south-2 BUCKET=$BUCKET DIST=$DIST CF=$CF" > ~/lab11.env
cat ~/lab11.env
Paso 3: subir el contenido
Sube una página con caché larga (la de la política) y un fichero con caché corta fijada por el origen con Cache-Control:
cat > index.html <<'EOF'
<!doctype html>
<html lang="es"><head><meta charset="utf-8"><title>Lab 11</title></head>
<body><h1>Version 1</h1><p>Servido por CloudFront desde un bucket privado.</p></body></html>
EOF
echo "hora del origen: $(date -u +%H:%M:%S)" > hora.txt
aws s3 cp index.html s3://$BUCKET/index.html --content-type text/html
aws s3 cp hora.txt s3://$BUCKET/hora.txt --content-type text/plain --cache-control "max-age=10"
Paso 4: probar CloudFront y la caché
curl -s https://$CF/
curl -sI https://$CF/ | grep -iE "^(http|x-cache|age|x-lab|strict-transport|x-frame)"
curl -sI https://$CF/ | grep -iE "^(x-cache|age)"
- La primera petición suele devolver
x-cache: Miss from cloudfront(la ubicación del borde fue al origen) y las siguientesHit from cloudfront, con una cabeceraageque crece. - Aparecen las cabeceras
strict-transport-security,x-frame-optionsyx-lab: lab11: las ha añadido la CloudFront Function en el borde, sin tocar S3. - Prueba
curl -sI http://$CF/: devuelve 301 hacia HTTPS (viewer protocol policy).
Paso 5: comprobar que el bucket es privado
curl -sI https://$BUCKET.s3.eu-south-2.amazonaws.com/index.html | head -1
403 Forbidden: el bucket no acepta peticiones anónimas; solo CloudFront, firmando con OAC, puede leerlo. Esto es lo que el examen llama restrict access to Amazon S3 content to CloudFront only.
Paso 6: TTL del origen y contenido obsoleto
El fichero hora.txt tiene Cache-Control: max-age=10; como está entre el TTL mínimo (1 s) y el máximo de la política, CloudFront lo respeta:
curl -s https://$CF/hora.txt
echo "hora del origen: $(date -u +%H:%M:%S)" > hora.txt
aws s3 cp hora.txt s3://$BUCKET/hora.txt --content-type text/plain --cache-control "max-age=10"
sleep 15
curl -s https://$CF/hora.txt
Tras 15 segundos ves la hora nueva.
Ahora cambia index.html, que usa el TTL por defecto de la política (24 horas, porque no lleva Cache-Control):
sed -i 's/Version 1/Version 2/' index.html
aws s3 cp index.html s3://$BUCKET/index.html --content-type text/html
curl -s https://$CF/ | grep -o "Version [0-9]"
Sigue diciendo Version 1: CloudFront sirve la copia cacheada hasta que caduque.
Paso 7: invalidación
INV=$(aws cloudfront create-invalidation --distribution-id $DIST --paths "/index.html" "/" \
--query Invalidation.Id --output text)
aws cloudfront wait invalidation-completed --distribution-id $DIST --id $INV
curl -s https://$CF/ | grep -o "Version [0-9]"
Ahora sí: Version 2. Has usado 2 de las 1.000 rutas de invalidación gratuitas al mes.
Paso 8 (opcional, en la consola): restricción geográfica y comportamientos
En la consola de CloudFront, abre la distribución lab11:
- Security → CloudFront geographic restrictions → Edit → Block list y añade España. Espera a que se despliegue y prueba con
curl -sI https://$CF/: recibirás 403. Vuelve a dejarlo en No restrictions. - En Behaviors, observa el comportamiento por defecto (
*), su cache policy y la función asociada. Piensa qué comportamiento añadirías para/api/*si tuvieras un ALB (origen personalizado,CachingDisabled, todos los métodos). - En Origins, fíjate en que el origen S3 usa Origin access control y no «Public».
Comprueba que funciona
-
https://$CF/muestra la página yhttp://redirige a HTTPS. - Ves
Missy despuésHitenx-cache, y las cabeceras añadidas por la CloudFront Function. - La URL directa del bucket devuelve 403.
-
hora.txtse refresca a los 10 s;index.htmlsolo tras la invalidación.
Limpieza
El bucket debe estar vacío para que CloudFormation pueda borrarlo:
source ~/lab11.env
aws s3 rm s3://$BUCKET --recursive
aws cloudformation delete-stack --stack-name lab11-cdn
aws cloudformation wait stack-delete-complete --stack-name lab11-cdn
rm -f ~/lab11.env index.html hora.txt
El borrado tarda unos minutos (CloudFormation desactiva la distribución antes de eliminarla). Comprueba que no queda ninguna distribución del lab:
aws cloudfront list-distributions --query "DistributionList.Items[?Comment=='lab11'].Id"
Preguntas para pensar como arquitecto
1. ¿Por qué no has usado el «alojamiento de sitios web estáticos» de S3 como origen?
Porque el website endpoint de S3 se configura en CloudFront como origen personalizado y no admite OAC ni OAI: el bucket tendría que ser público. Con el endpoint REST del bucket + OAC, el bucket es privado y solo CloudFront lo lee. (Lo que pierdes son funciones del website endpoint como las redirecciones de S3; se sustituyen con DefaultRootObject, páginas de error personalizadas o una CloudFront Function.)
2. Los objetos del bucket deben cifrarse con SSE-KMS con una clave gestionada por el cliente. ¿Qué cambiarías?
Seguir con OAC (OAI no admite SSE-KMS) y añadir a la política de la clave KMS una sentencia que permita kms:Decrypt al principal cloudfront.amazonaws.com con la condición AWS:SourceArn de la distribución.
3. Quieres usar www.tudominio.com con HTTPS. ¿Qué pasos y en qué región?
Pedir un certificado público en ACM en us-east-1 con validación DNS, añadir www.tudominio.com como alternate domain name de la distribución con ese certificado (SNI), y crear en Route 53 un registro alias A/AAAA hacia la distribución. Lo tienes descrito como opción de pago en el lab 12.
4. Solo los clientes de pago deben poder descargar un instalador, y el enlace debe caducar a la hora. ¿Qué añadirías?
Un trusted key group en el comportamiento que sirve /descargas/* y que la aplicación genere signed URLs de CloudFront con caducidad de una hora (un solo fichero → signed URL; muchos ficheros sin cambiar URL → signed cookies).
5. ¿Qué harías para que la CloudFront Function consultara una tabla de DynamoDB antes de responder?
No se puede: CloudFront Functions no tiene acceso a la red ni al AWS SDK. Habría que usar Lambda@Edge (en origin request para no ejecutarla en cada acierto de caché), con su mayor coste y latencia, o precalcular los datos en CloudFront KeyValueStore si caben en ese modelo.