DEV Community

Cover image for AWS CDK 100 Drill Exercises #012: S3 Static Web Site — Getting a "Public" Bucket Policy Past Block Public Access

AWS CDK 100 Drill Exercises #012: S3 Static Web Site — Getting a "Public" Bucket Policy Past Block Public Access

Level 100

Introduction

This is the 12th installment of "AWS CDK 100 Drill Exercises." See here for an overview of the series.

This time we build the simplest way to publish a static site on AWS — S3 static website hosting — with no CDN, no WAF, and no custom domain. It looks like a plain, unremarkable pattern, but implementing it surfaces one genuinely interesting question.

"A bucket policy that allows s3:GetObject to Principal: * should count as 'public' — so why does deploying it against a bucket with BlockPublicAccess.BLOCK_ALL enabled succeed?"

The short answer: S3's Block Public Access doesn't decide public vs. private purely based on whether Principal is *. This time we verify that behavior with actual CDK code.

What you'll learn in this article

  • The basics of S3 static website hosting (websiteIndexDocument/websiteErrorDocument)
  • How the aws:SourceIp condition exempts a "public" policy from S3's Block Public Access evaluation
  • Why enforceSSL can't be enabled, because the S3 website endpoint doesn't support HTTPS
  • How to auto-detect the deploy operator's own global IP so the site is viewable immediately after deployment
  • The limits of this pattern — what you can and can't do without CloudFront

📁 Code repository: GitHub


Architecture Overview

Architecture Overview

Trait Benefit
No CloudFront, no WAF, no ACM certificate The fastest, cheapest way to get a static site online. A solid baseline before moving on to cloudfront-s3-static-website, covered next time
An IP allow list that coexists with Block Public Access Lets you actually verify that a Principal: * policy scoped with an aws:SourceIp condition is exempted from S3's "public" determination
Automatic detection of the operator's IP Detects the deploying machine's own IP via curl, so the site is viewable right after deployment with no manual configuration

Data Flow

Browser
  │  HTTP (the S3 website endpoint doesn't support HTTPS)
  ▼
S3 bucket website endpoint (<bucket>.s3-website-<region>.amazonaws.com)
  │  Evaluated by bucket policy: source IP must match allowedIps/allowedIpv6s
  ▼
index.html / error.html
Enter fullscreen mode Exit fullscreen mode

Every request is anonymous. The website endpoint doesn't support SigV4 authentication, so the only access control available is the bucket policy's aws:SourceIp condition.


Implementation Highlights

1. websiteIndexDocument alone doesn't finish the job — the tug-of-war with Block Public Access

Just enabling S3 static website hosting is only a few lines of CDK code.

new s3.Bucket(this, 'WebsiteBucket', {
  websiteIndexDocument: 'index.html',
  websiteErrorDocument: 'error.html',
});
Enter fullscreen mode Exit fullscreen mode

But that alone leaves nobody able to view the site. New S3 buckets have public access blocked by default (at the account level), and this reference implementation also sets blockPublicAccess: BLOCK_ALL on the bucket explicitly (via the repo's shared bucket helper) — so every anonymous s3:GetObject gets denied. Simply turning Block Public Access off, though, risks unintentionally opening the bucket to the entire world.

Instead, the stack keeps blockPublicAccess: BLOCK_ALL in place and adds only a bucket policy scoped down by aws:SourceIp.

websiteBucket.addToResourcePolicy(new iam.PolicyStatement({
  effect: iam.Effect.ALLOW,
  principals: [new iam.AnyPrincipal()],
  actions: ['s3:GetObject'],
  resources: [websiteBucket.arnForObjects('*')],
  conditions: {
    IpAddress: {
      'aws:SourceIp': allowedIps.map(ip => `${ip}/32`),
    },
  },
}));
Enter fullscreen mode Exit fullscreen mode

At first glance, this policy — which includes Principal: * — looks like it should get the PutBucketPolicy call itself rejected by Block Public Access's BlockPublicPolicy: true. In practice, though, the deployment succeeds.

Why: when S3's Block Public Access decides "is this policy public?", it looks at more than just whether Principal is * — it also inspects the Condition clause. To be judged non-public, a statement has to be scoped to a fixed value of one of a specific set of keys: aws:SourceIp, aws:SourceArn, aws:SourceVpc, aws:SourceVpce, aws:SourceOwner, aws:SourceAccount, aws:PrincipalOrgID, s3:DataAccessPointArn, s3:DataAccessPointAccount, or aws:userid. A Principal: * policy whose reachable callers are pinned down by an aws:SourceIp CIDR condition therefore isn't treated as "public." (One caveat: an aws:SourceIp range broader than /8 for IPv4 — e.g. 0.0.0.0/1 — is still considered public. The /32 host addresses this stack uses are well within bounds.)

This behavior also comes up frequently in the context of SCPs and IAM Access Analyzer, but actually deploying an IP-restricted bucket policy against a Block-Public-Access-enabled bucket in CDK and confirming it firsthand deepens the understanding considerably.

⚠️ Note: if you omit both allowedIps and allowedIpv6s, no bucket policy is added at all. blockPublicAccess: BLOCK_ALL still applies, so the result is a site that's "unreachable by anyone," not "publicly open." This stack has no "allow everyone if unspecified" fallback.

2. enforceSSL: false — a setting that looks wrong at first glance

Every other bucket in this repository follows a baseline policy of enforceSSL: true, rejecting HTTP connections. WebsiteBucket is the one exception, set to enforceSSL: false.

const bucket = new s3.Bucket(scope, id, {
  enforceSSL: false, // the website endpoint only supports HTTP
  websiteIndexDocument: 'index.html',
  websiteErrorDocument: 'error.html',
  // ...
});
Enter fullscreen mode Exit fullscreen mode

The S3 static website hosting endpoint (*.s3-website-<region>.amazonaws.com) doesn't support HTTPS at all, to begin with. Set enforceSSL: true here, and the bucket policy would reject the very (plaintext HTTP) requests the website endpoint actually receives, breaking the site entirely.

In other words, "I want this over HTTPS" isn't something this bucket can solve on its own — it's solved by putting CloudFront in front of it. That's exactly the bridge to cloudfront-s3-static-website, covered in the next article.

3. Auto-detecting the deploy operator's IP so the site is instantly viewable

bin/s3-static-web-site.ts fetches the deploying machine's own global IP via curl and feeds it straight into the bucket policy's allow list.

const allowedIps = parseIpListEnv(process.env.ALLOWED_IPS) ?? [getMyGlobalIp()];
const allowedIpv6s = parseIpListEnv(process.env.ALLOWED_IPV6S) ?? (() => {
  const myIpv6 = getMyGlobalIpv6();
  return myIpv6 ? [myIpv6] : undefined;
})();
Enter fullscreen mode Exit fullscreen mode

IPv6 detection (getMyGlobalIpv6()) returns undefined quietly when it can't be determined (e.g. in a devcontainer/CI environment with no IPv6 connectivity), while IPv4 detection (getMyGlobalIp()) throws an exception on failure. That means deploying from a fully offline machine fails unless you explicitly set the ALLOWED_IPS environment variable. The same override applies when the machine you're deploying from differs from the machine whose browser will view the site (e.g. deploying from a devcontainer but browsing from the host PC) — use ALLOWED_IPS/ALLOWED_IPV6S explicitly in that case too.

ALLOWED_IPS=203.0.113.10,203.0.113.20 \
ALLOWED_IPV6S=2001:db8::1 \
npm run stage:deploy:all
Enter fullscreen mode Exit fullscreen mode

Deploy & Verify

export PROJECT=your-project
export ENV=dev

npm run bootstrap   # first time only
npm run stage:deploy:all
Enter fullscreen mode Exit fullscreen mode
curl http://<output WebsiteBucketUrl>/
Enter fullscreen mode Exit fullscreen mode

Access from an IP not on the allow list, and the S3 website endpoint returns an HTTP 403 (Access Denied) page.


Cost Estimate

💰 Rough monthly estimate (Tokyo region, low traffic)

Service Usage Rough monthly cost
S3 storage A few MB of static content Under $0.01
S3 requests Low-volume GET Under $0.01
S3 server access logs Small volume Under $0.01

Total: under $0.05/month

With no CloudFront, no WAF, and no compute in the picture at all, the cost is essentially just S3 storage and request charges.


Summary

What we learned from this pattern:

  1. S3's Block Public Access evaluation doesn't decide public/private purely by whether Principal is *. Statements scoped by a specific set of condition keys that includes aws:SourceIp are exempted from that "public" determination
  2. The S3 static website hosting endpoint is HTTP-only. Enabling enforceSSL breaks the site entirely — going HTTPS requires an additional layer such as CloudFront
  3. The "fallback when unspecified" behavior isn't consistent across helpers. IPv6 detection quietly returns undefined on failure, while IPv4 detection throws. When using helper functions, design your error handling around this asymmetry rather than assuming both behave the same
  4. An IP allow list is not a substitute for authentication. aws:SourceIp is only evaluated per request, so while it's useful as a restriction for demos or personal use, it's insufficient as access control tied to a specific individual

Next time, we'll evolve this same workspace into CloudFront + WAF + OAC to add HTTPS and a private origin, in cloudfront-s3-static-website.


References


Let's keep learning practical AWS CDK patterns through the 100 drill exercises!
If you found this helpful, please ⭐ the repository!

📌 You can see the entire code in my GitHub repository.

Top comments (0)