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

⏱ 60-90 minDificultad: mediaTask statements: 3.41.22.1

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, solo GET/HEAD, la política de caché gestionada CachingOptimized (ID 658327ea-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:GetObject al servicio CloudFront cuando la petición viene de esta distribución (AWS:SourceArn).
  • El parámetro WebAclArn vací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 siguientes Hit from cloudfront, con una cabecera age que crece.
  • Aparecen las cabeceras strict-transport-security, x-frame-options y x-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:

  1. 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.
  2. 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).
  3. 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 y http:// redirige a HTTPS.
  • Ves Miss y después Hit en x-cache, y las cabeceras añadidas por la CloudFront Function.
  • La URL directa del bucket devuelve 403.
  • hora.txt se refresca a los 10 s; index.html solo 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.


Volver al módulo