(3/3) 누가 저장할 수 있는지 — 백엔드 없이 쓰기 경로에 접근 제한 걸기
📦 브라우저→S3 동시 편집 버전 가드 3부작 — 백엔드 없이 브라우저에서 공용 data.json 을 직접 저장하는 대시보드를 안전하게 만드는 이야기. 여러 명이 동시에 편집할 때 뒷사람이 앞사람 변경을 덮어쓰는 lost update 를 rev 카운터 + 저장 직전 read-before-write 로 잡고(1편), 그 사이 남는 짧은 경쟁 구간을 조건부 쓰기(If-Match/ETag)로 원자적으로 닫고(2편), 마지막으로 권한 없는 사람의 저장을 쓰기 경로 접근 제한으로 막는 것(3편)까지 이어집니다. 전체 3편.
- rev 버전 가드 — 저장 직전 재조회로 덮어쓰기 유실 막기
- 조건부 쓰기(If-Match/ETag) — GET~PUT 경쟁 구간까지 닫기
- 접근 제한 — 누가 저장할 수 있는지(쓰기 경로 잠그기) ← 지금 글
Summary
앞의 두 편에서 만든 버전 가드는 덮어쓰기 유실을 막았어요. rev 로 저장 직전 상태를 비교하고(1편), 조건부 쓰기(If-Match/ETag)로 그 검사를 원자적으로 굳혔죠(2편). 그런데 이건 전부 “서로 믿는 편집자끼리 실수로 겹쳐 저장하는 것” 을 다룬 거예요.
방향이 다른 위협이 하나 남았어요. 애초에 저장할 자격이 없는 사람이 저장하는 것. 읽기 경로가 열려 있으니(누구나 GET data.json), 쓰기 경로도 그대로 열려 있으면 아무나 PUT 해서 내용을 통째로 갈아버릴 수 있어요. 버전 가드는 이걸 못 막아요 — 오히려 “올바른 절차로” 남의 데이터를 덮어쓰게 도와줄 뿐이죠.
그래서 마지막 편은 쓰기 경로에 접근 제한(인가) 을 겁니다. 다만 솔직하게 시작할게요 — 여기서부터는 “순수 클라이언트만으로” 는 안전하게 안 돼요. 최소한의 문지기가 필요하고, 그 문지기를 얼마나 무겁게 둘지는 위협 모델에 따라 달라요.
💡 이 글에서 다루는 것
- 버전 가드가 못 막는 위협 — 권한 없는 쓰기
- 왜 순수 클라이언트로는 인가를 못 하나 (비밀은 브라우저에서 샌다)
- 토대: 읽기/쓰기 경로 분리
- 옵션 A — 네트워크 경계(WAF IP 허용목록 · 버킷 정책
aws:SourceIp)- 옵션 B — 엣지에서 토큰/basic-auth 검사(CloudFront Functions · Lambda@Edge)
- 옵션 C — 로그인 → 임시 자격증명(OIDC → STS), 제대로 된 신원과 감사
- 무엇을 고를까 — 편집자 수·위협별 선택표
- 시리즈 마무리: 세 편이 각각 막는 위협
1. 버전 가드가 못 막는 것
1·2편의 가드는 “내가 읽은 뒤 남이 저장했나?” 만 확인해요. 저장하는 사람이 누구인지는 묻지 않아요. 그러니 이런 일이 가능해요.
- 링크를 아는 외부인이 편집 화면을 열고, 정상 절차대로
rev + 1로 PUT → 가드는 통과, 데이터는 갈림. - 스크립트가 쓰기 엔드포인트로 바로 PUT → 역시 통과.
즉 유실 방지(1·2편) 와 인가(3편) 는 서로 다른 문제예요.
| 편 | 막는 위협 | 질문 |
|---|---|---|
| 1·2편 | 신뢰된 편집자끼리 실수로 덮어씀 | “내가 읽은 뒤 바뀌었나?” |
| 3편 | 권한 없는 사람의 무단 저장 | “너는 저장해도 되는 사람인가?” |
2. 순수 클라이언트로는 인가를 못 한다
가장 먼저 짚고 갈 함정이에요. “브라우저 JS 에 토큰이나 비밀번호를 넣어두고 그걸로 막으면 되지 않나?” — 안 돼요. 정적 페이지의 JS 는 누구나 열어볼 수 있어서, 거기 박은 비밀은 비밀이 아니에요. 브라우저에 상주하는 값으로는 “권한 있는 사람”을 진짜로 구분할 수 없어요.
그래서 인가는 브라우저 바깥의 무언가 가 판정해야 해요. 다행히 “서버를 상시 돌리는 것” 까진 필요 없어요. AWS 가 제공하는 경계(네트워크·엣지·신원)를 빌려서, 쓰기 경로에만 문지기를 세우면 돼요.
⚠️ 이 편의 옵션들은 “완전 무백엔드” 를 조금씩 양보해요. 대신 얻는 건 진짜 인가예요. 위협이 낮으면 적게, 높으면 많이 양보하면 돼요.
3. 토대 — 읽기와 쓰기 경로 분리
접근 제한의 전제는 읽기와 쓰기를 다른 경로로 가르는 것이에요. 사실 1·2편에서 이미 읽기(DATA_URL)와 쓰기(PUT_URL)를 나눠 뒀는데, 그게 여기서 진가를 발휘해요.
읽기(GET) → CloudFront 공개 배포 → S3 (data.json) ← 누구나 볼 수 있음(OK)
쓰기(PUT) → [ 문지기 ] → 업로드 경로 → S3 (data.json) ← 여기만 잠근다
읽기는 지금처럼 공개로 두고, 쓰기 경로에만 아래 문지기 중 하나를 얹어요. 대시보드는 모두가 보되, 저장은 자격 있는 사람만.
4. 옵션 A — 네트워크 경계 (코드가 가장 적다)
가장 단순한 문지기는 “어디서 왔는가” 예요. 사무실·VPN 같은 정해진 네트워크에서만 저장할 수 있게 IP 로 거릅니다. 편집자가 특정 망 안에 있다면 이걸로 충분할 때가 많아요.
S3 버킷 정책으로 허용 IP 밖의 PutObject 를 거부하는 예시예요.
{
"Sid": "DenyWriteOutsideOffice",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::<your-bucket>/report/data.json",
"Condition": {
"NotIpAddress": { "aws:SourceIp": ["203.0.113.0/24"] }
}
}
💡
203.0.113.0/24는 문서용 예시 대역이에요. 본인 사무실·VPN 의 공인 IP 대역으로 바꾸세요. CloudFront 를 앞에 둔 경우엔 S3 가 보는 소스가 CloudFront IP 라, IP 검사는 S3 버킷 정책보다 CloudFront 앞단(WAF) 에서 거는 게 맞아요.
- 장점: 코드 거의 0. 편집자가 고정 네트워크에 있으면 즉효.
- 한계: “그 망에 있는 사람” 까지만 구분해요. 같은 사무실의 다른 사람은 못 걸러요. 재택·이동이 많으면 안 맞아요.
5. 옵션 B — 엣지에서 토큰/로그인 검사
IP 만으로 부족하면, CloudFront 엣지에서 요청을 심사해요. 쓰기 behavior 에만 CloudFront Functions(가벼운 검사)나 Lambda@Edge(무거운 검사)를 붙여, PUT 요청의 Authorization 헤더를 확인하고 아니면 403 으로 되돌립니다.
CloudFront Functions 의 viewer-request 예시예요.
function handler(event) {
var req = event.request;
var method = req.method;
// 쓰기(PUT)만 심사. 읽기(GET)는 그대로 통과.
if (method === "PUT") {
var hdr = req.headers.authorization;
var token = hdr && hdr.value;
// 기대 토큰은 코드에 박지 말고 CloudFront KeyValueStore 등에서 읽어온다.
if (token !== EXPECTED_WRITE_TOKEN) {
return { statusCode: 403, statusDescription: "Forbidden" };
}
}
return req;
}
- 장점: 읽기는 열고 쓰기만 심사. 상시 서버 없이 엣지에서 처리돼요.
- 한계: 토큰을 브라우저가 들고 있으면 결국 페이지를 여는 사람에게 노출돼요(2절 그대로). 그래서 이 방식은 basic-auth 처럼 “약하게 한 겹 더” 수준이에요. 토큰을 사람이 직접 입력하고 브라우저에 남기지 않게 하거나, 아래 옵션 C 로 넘어가야 진짜 신원 기반이 돼요.
🚨 basic-auth·고정 토큰은 “우연한 접근” 은 잘 막지만 “작정한 사람” 은 못 막아요. 공개 인터넷에 노출된 편집 도구라면 옵션 C 를 권장드려요.
6. 옵션 C — 로그인 → 임시 자격증명 (제대로 된 신원)
진짜로 “누가” 저장하는지 확인하려면 로그인 이 필요해요. 서버를 직접 안 세우고도, 관리형 신원 제공자(OIDC/Cognito)로 로그인시키고 그 신원을 STS 임시 자격증명 으로 교환해서, 그 자격증명으로만 특정 객체에 PUT 하게 합니다.
흐름은 이래요.
1) 사용자가 OIDC/Cognito 로 로그인 → id_token 획득
2) STS AssumeRoleWithWebIdentity(id_token) → 임시 자격증명(만료 있음)
3) 그 임시 키로 서명된 PUT /report/data.json
4) 역할 정책이 딱 이 객체의 PutObject 만 허용
역할에 붙이는 정책은 최소 권한 으로 좁혀요. 저장 대상 하나만.
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::<your-bucket>/report/data.json"
}
- 장점:
- 비밀이 브라우저에 상주하지 않아요. 임시 자격증명이라 만료되고, 로그인한 사람에게만 발급돼요.
- 누가 저장했는지 CloudTrail 에 남아요 — 감사가 돼요.
- 사람별로 역할·권한을 다르게 줄 수 있어요(편집자/뷰어 분리).
- 비용: 로그인 붙이는 초기 설정이 셋 중 제일 무거워요. 대신 공개 노출 환경에서도 안심할 수 있는 유일한 옵션이에요.
7. 무엇을 고를까
정답은 위협 모델 이 정해요. 편집자가 어디에 있고, 도구가 어디까지 노출되는지로 가르면 돼요.
| 상황 | 추천 | 이유 |
|---|---|---|
| 편집자가 사무실·VPN 안에만 있음 | A(네트워크 경계) | 코드 0, 그 망 밖은 아예 저장 불가 |
| 반쯤 닫힌 환경, 우연한 접근만 막으면 됨 | A + B(엣지 토큰) | IP 로 1차, 토큰으로 2차. 가벼움 |
| 공개 인터넷 노출 · 사람별 권한/감사 필요 | C(로그인 → STS) | 진짜 신원, 최소 권한, CloudTrail 감사 |
작게 시작해서 위협이 커지면 A → B → C 로 올리면 돼요. 셋을 겹쳐 써도 좋아요(심층 방어).
8. 시리즈 마무리 — 세 편이 각각 막는 것
세 편을 겹쳐 보면, 백엔드 없이 굴리는 브라우저→S3 편집 도구가 마주하는 위협을 층층이 막은 셈이에요.
| 편 | 막는 위협 | 도구 |
|---|---|---|
| 1편 | 실수로 인한 덮어쓰기 유실 | rev + 저장 직전 read-before-write |
| 2편 | GET~PUT 사이 경쟁 구간 | 조건부 쓰기(If-Match/ETag), 412 |
| 3편 | 권한 없는 무단 저장 | 쓰기 경로 접근 제한(네트워크·엣지·신원) |
핵심을 한 줄로 요약하면 이래요.
- 읽기는 열고, 쓰기만 잠근다. 그 위에 유실 방지(rev/ETag)와 인가(접근 제한)를 각각 얹는다.
- 상시 서버는 없어도 된다. 다만 인가에서만큼은 “브라우저 바깥의 문지기”(경계·엣지·신원)가 최소한 필요하다.
- 작게 시작하고 위협에 맞춰 올린다. 사내 소수 편집이면 rev 가드 + IP 경계로도 충분하고, 공개 노출이면 조건부 쓰기 + 로그인까지.
작은 공용 JSON 하나를 여럿이 안전하게 편집하는 도구를, 무거운 백엔드 없이 여기까지 만들어봤어요. 필요 이상으로 크게 짓지 않으면서도 유실·경쟁·무단 저장을 층층이 막는 게 이 시리즈의 목적이었어요.
일단 오늘은 여기까지…..
이 시리즈는 여기서 깔끔하게 마무리할게요. 다음엔 또 다른 “백엔드 없이 버티기” 주제로 찾아올게요.
← 이전 글: (2/3) 조건부 쓰기(If-Match/ETag)로 경쟁 구간까지 닫기 — S3 원자적 버전 가드