This lab demonstrates how to create a public S3 bucket and apply IP-based policies to restrict access to specific IP address ranges. This simulates a common real-world scenario where companies expose internal tools or data only to their corporate network, blocking access from the rest of the internet.

IP-based bucket policies are commonly used for:

  • Admin Dashboards: Restrict access to monitoring tools to corporate office IPs
  • CI/CD Artifacts: Allow only build servers (known IPs) to access deployment packages
  • Data Exports: Lock down sensitive reports to specific VPN exit IPs
  • Static Website Assets: Combined with CloudFront, restrict origin access to CDN IPs only
  • Backup Storage: Ensure only on-premise backup systems can write to archive buckets

Phase 1: Creating a Public Bucket

Creating the bucket with a timestamp-based unique name:

$TIMESTAMP = [int][double]::Parse((Get-Date -UFormat %s))
$BUCKET_NAME = "lab-2-ip-lock-$TIMESTAMP"
$env:BUCKET_NAME = $BUCKET_NAME
aws s3 mb "s3://${env:BUCKET_NAME}" --region eu-north-1

Screenshot%202025-11-06%20220626 Disable Block Public Access:

aws s3api put-public-access-block `
    --bucket $env:BUCKET_NAME `
    --public-access-block-configuration "BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false" `
    --region eu-north-1

Create a test file representing sensitive internal data that we'll use to verify access controls:

$testContent = @"
CONFIDENTIAL - INTERNAL USE ONLY
Q4 2025 Financial Projections

Revenue Forecast: $4.2M
Operating Costs: $2.8M
Projected Profit: $1.4M

Access Level: Corporate Office Only
"@

Save the content to a file:

Set-Content -Path "internal-report.txt" -Value $testContent

Upload the file to S3:

aws s3 cp internal-report.txt "s3://${env:BUCKET_NAME}/internal-report.txt" --region eu-north-1

Screenshot%202025-11-06%20221209

Applying a Fully Public Policy

Next, apply a fully public policy to establish our baseline "before" state. This policy grants anyone on the internet read access to the bucket.

Policy Breakdown:

  • Principal: "*" = Anyone (authenticated or not)
  • s3:GetObject = Permission to download files
  • s3:ListBucket = Permission to list files in the bucket
  • No Condition block = No restrictions whatsoever
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::lab-2-ip-lock-1762466733/*"
        },
        {
            "Sid": "PublicReadListBucket",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::lab-2-ip-lock-1762466733"
        }
    ]
}

Screenshot%202025-11-06%20221436

Apply the policy to the bucket:

aws s3api put-bucket-policy --bucket $env:BUCKET_NAME --policy file://public-policy.json --region eu-north-1

Important: The policy has two statements because GetObject and ListBucket operate on different resource types:

  1. GetObject with resource arn:aws:s3:::bucket/* (objects)
  2. ListBucket with resource arn:aws:s3:::bucket (bucket itself)

You cannot combine these into a single statement due to the different resource ARNs. ListBucket operates on the bucket, while GetObject operates on objects.

Verifying Public Access

Test public access by retrieving the file via its public URL:

$PUBLIC_URL = "https://${env:BUCKET_NAME}.s3.eu-north-1.amazonaws.com/internal-report.txt"
curl $PUBLIC_URL

01-public-access-success.png

The bucket is now wide open to the entire internet. Anyone with the URL can read the data. In Phase 2, we'll fix this using an IP-based condition.


Phase 2: Applying the IP-Restricted Policy

First, determine your IP address to create a whitelist. Then, we'll test from a different internet connection to verify that the policy correctly denies access.

To determine your IP address:

$MY_IP = (Invoke-RestMethod -Uri "https://ifconfig.me/ip" -TimeoutSec 10).Trim()
$env:MY_IP = $MY_IP
Write-Host $env:MY_IP
Set-Content -Path "my-ip.txt" -Value $env:MY_IP

02-my-ip-address

Creating an IP-Restricted Policy

Create an IP-restricted policy with a Condition block. This policy says: "Allow access only if the request comes from my specific IP address."

Understanding the Condition Block:

"Condition": {
    "IpAddress": {
        "aws:SourceIp": "203.0.113.42/32"
    }
}
  • IpAddress: Condition operator for IP-based checks
  • aws:SourceIp: AWS condition key containing the requester's IP
  • /32: CIDR notation meaning "exactly this one IP" (no range)

Understanding CIDR Notation:

CIDR (Classless Inter-Domain Routing) notation specifies IP address ranges:

  • /32 = Single IP (203.0.113.42 only)
  • /24 = 256 IPs (203.0.113.0 through 203.0.113.255)
  • /16 = 65,536 IPs (203.0.0.0 through 203.0.255.255)
  • /8 = 16.7 million IPs

For this lab, /32 means we're allowing exactly one IP address.

Complete IP-Restricted Policy

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "IPRestrictedGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::lab-2-ip-lock-1762466733/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "84.***.***.***32"
                }
            }
        },
        {
            "Sid": "IPRestrictedListBucket",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::lab-2-ip-lock-1762466733",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "84.***.***.***32"
                }
            }
        }
    ]
}

Screenshot%202025-11-06%20223623

The policy has two separate statements with identical conditions:

  1. GetObject statement: Allows downloading files (Resource: bucket/*)
  2. ListBucket statement: Allows listing bucket contents (Resource: bucket)

You cannot combine these into one statement because they operate on different resource types.

*Why use `Principal: ""` with IP restrictions?**

This pattern is standard practice for scenarios requiring anonymous access from specific networks:

  • Static website hosting (where you want anonymous access, but only from certain networks)
  • CDN origin protection (allow CloudFront, block everyone else)
  • Shared public resources with geographic restrictions

Applying the Secure Policy

aws s3api put-bucket-policy `
    --bucket $env:BUCKET_NAME `
    --policy file://ip-restricted-policy.json `
    --region eu-north-1

Phase 3: Testing the IP Restriction

Test from your whitelisted IP first. Since the request originates from your whitelisted IP, it should succeed:

$PUBLIC_URL = "https://${env:BUCKET_NAME}.s3.eu-north-1.amazonaws.com/internal-report.txt"
curl $PUBLIC_URL

04-allowed-ip-success

Now test from a different internet connection (e.g., mobile hotspot or VPN). This request should be denied:

05-blocked-ip-denied

Access from the non-whitelisted IP is successfully blocked. The IP-based policy is working as intended.


Phase 4: Cleanup

Remove the bucket and all its contents:

aws s3 rb "s3://${env:BUCKET_NAME}" --region eu-north-1 --force

06-cleanup-complete


Lessons Learned

Core Concepts

1. Condition Blocks:

The Condition block is your most flexible security tool. You can restrict access by:

  • IP address (aws:SourceIp)
  • Time of day (aws:CurrentTime)
  • MFA status (aws:MultiFactorAuthPresent)
  • User agent (aws:UserAgent)
  • Encryption status (s3:x-amz-server-side-encryption)
  • And dozens more

2. CIDR Notation:

  • /32 = Single IP (203.0.113.42)
  • /24 = 256 IPs (203.0.113.0-255)
  • /16 = 65,536 IPs
  • /8 = 16.7 million IPs

3. GetObject vs ListBucket:

These are different permissions operating on different resource types:

  • s3:GetObject → Resource: arn:aws:s3:::bucket/* (objects)
  • s3:ListBucket → Resource: arn:aws:s3:::bucket (bucket itself)

*4. Principal: "" with Conditions:**

This pattern is legitimate for specific use cases:

  • Static website hosting (public, but geographically restricted)
  • CDN origin protection (CloudFront only)
  • Shared public data with network restrictions

Additional Resources