Bucket, którego nigdy nie należało otwierać

Poproszono nas o upublicznienie bucketu S3, żeby roboty mogły pobrać paczkę certyfikatów głównych tożsamości. Prośba przyszła z już przygotowaną polityką — klasyczną polityką bucketu z publicznym odczytem. Wyglądało na dziesięć minut pracy. Nie było.

Haczyk: „upublicznij to”

Właściciel obciążenia napisał: paczka musi być dostępna dla robotów, a na produkcji zwykła domena brzegowa jej nie dostarcza. Proponowana poprawka była o jedną linię dalej: dołączyć politykę bucketu dającą s3:GetObject dla Principal: "*", żeby każdy mógł pobrać plik bezpośrednio z S3.

Był dobry powód, dla którego to wydawało się słuszne: paczka jest z natury publiczna — to zestaw publicznych certyfikatów głównych CA, nic tajnego. Liczy się integralność, a nie tajność. Więc „publiczny” brzmiało nieszkodliwie.

Ale „publiczny bucket” i „publiczna treść” to różne rzeczy, a ta poprawka przesunęłaby niewłaściwą dźwignię.

Pierwszy zwrot: brzeg, a nie bucket

Prośba tak naprawdę dotyczyła dostępnego publicznego URL-a. To zadanie brzegu — CDN przed bucketem. A z brzegiem dostajesz publiczną dostępność bez upubliczniania bucketu w ogóle.

Właściwym mechanizmem jest Origin Access Control (OAC): CDN uwierzytelnia się w S3 własnymi sekretami, ograniczonymi do jednej dystrybucji, a bucket zostaje w pełni zamknięty — wszystkie cztery ustawienia Block-Public-Access nadal włączone.

Zamiast polityki z Principal: "*" polityka bucketu mówiła więc coś odwrotnego:

Principal = { Service = "cloudfront.amazonaws.com" }   # not "*"
Action    = "s3:GetObject"
Resource  = <the bundle object only>
Condition = { StringEquals = { "AWS:SourceArn" = <this one distribution> } }

Publiczne dla internetu, niewidoczne dla wszystkiego poza jednym uwierzytelnionym brzegiem. Bucket nigdy nie został „otwarty”.

Zwrot, który kosztował ponowne wdrożenie: domyślna flaga

Pierwsze wdrożenie poszło. Potem prawdziwy test — pobranie paczki przez nowy endpoint:

HTTP/2 403
x-cache: Error from cloudfront
server: AmazonS3

403 z x-cache: Error from cloudfront — błąd przyszedł z S3, nie z CloudFronta. Brzeg docierał do originu i dostawał odmowę. Skonfigurowaliśmy OAC i politykę... a nie zadziałało.

Króliczą norą był moduł Terraforma. Używaliśmy społecznościowego modułu CloudFront, a w v4 tego modułu create_origin_access_control domyślnie ma false. Nikt celowo nie przełącza flagi na „bez OAC”, ale wartość domyślna zrobiła dokładnie to: dystrybucja powstała z nieuwierzytelnionym originem S3 — bez żadnego OAC. Każde pobranie trafiało w prywatny bucket bez poświadczeń i było odprawiane.

Sygnał był wprost w wyjściu API:

OriginAccessControlId: ""    # empty — the origin was naked

Naprawiła to jedna linia — jawne włączenie create_origin_access_control = true — i odpowiedź zmieniła się na 200.

Pułapka regionu: certyfikaty żyją tylko w jednym miejscu

Następnie chcieliśmy przyjaznej nazwy hosta zamiast surowej domeny CloudFronta. To wymaga certyfikatu ACM, który właściciel już miał — wildcard obejmujący każdą subdomenę.

Oto część, która łapie każdego: CloudFront przyjmuje tylko certyfikaty z ACM w us-east-1. Nasz certyfikat wildcard istniał w innym regionie (gdzie obsługuje load balancery). Dla CloudFronta równie dobrze mógłby nie istnieć. Regionalny certyfikat w „złym” regionie jest niewidoczny dla dystrybucji.

Nie walczyliśmy z tym — wystawiliśmy osobny certyfikat dla tej konkretnej nazwy hosta w us-east-1 i zostawiliśmy regionalny wildcard w spokoju. Dwa certyfikaty, różne regiony, różne zadania, bez konfliktu.

Fałszywy alarm: „DNS się nie rozwiązuje”

Po tym wszystkim ostatnia kontrola: rozwiązać nazwę hosta.

curl: (6) Could not resolve host: ...

Kolejna ślepa uliczka? Nie — lokalna maszyna była za VPN, a jej DNS serwował nieaktualny cache „to nie istnieje” sprzed utworzenia rekordu. Publiczne resolwery odpowiadały natychmiast, a endpoint serwował 200 każdemu, kto nie siedział za tym jednym nieaktualnym cache. Prawdziwie wyglądająca awaria z przyziemną przyczyną:

public DNS (any)  -> CloudFront IPs    -> 200  ✓
local VPN resolver -> "No answer"      -> curl error  ✗   (just caching)

Rozwiązanie

Paczka jest dostarczana przez HTTPS z porządnego brzegu. Bucket jest prywatny — nigdzie nie ma Principal:"*". Dystrybucja używa osobnego certyfikatu z us-east-1 przy TLSv1.2_2021. Zarówno domyślna domena CloudFronta, jak i przyjazna nazwa hosta zwracają 200 z pełną zawartością paczki.

Wskazówki dla czytelnika