보안 점검하듯 하루 잡고 훑었다. 큰 건 하나, 작은 건 여러 개.
멀티유저 구조를 붙인 지는 꽤 됐는데, PATCH /channels/:id/schedule와 DELETE /channels/:id에는 소유권 체크가 빠져 있었다. x-user-id 헤더로 로그인한 사람은 알 수 있는데, 그 사람이 진짜 이 채널 주인인지는 안 봤다는 뜻이다.
async updateSchedule(id: string, dto: UpdateScheduleDto, userId?: string) {
const exists = await this.repo.findSchedulerConfig(id);
if (!exists) throw new NotFoundException('채널을 찾을 수 없습니다.');
if (userId && exists.userId !== userId) {
throw new ForbiddenException('채널 소유자가 아닙니다.');
}
...
}
userId가 없을 때는 검증을 생략한다. scheduler-worker 같은 내부 서비스가 x-user-id 없이 API를 호출하는 경로가 있어서, 이걸 강제하면 내부 호출까지 막힌다.
DELETE /channels/:id — x-user-id 헤더가 있으면 채널 소유자(Channel.userId) 일치 여부 검증 → 불일치 시 403
PATCH /channels/:id/schedule — 동일
읽기 API(GET /channels)는 원래도 userId 기준으로 필터링돼서 남의 채널이 보이진 않았다. 근데 PATCH/DELETE 같은 쓰기 API는 URL의 :id만 맞으면 통과됐다. 조회는 막혀 있는데 변경은 뚫려 있는 전형적인 패턴이다.
JSON.parse(record.body) as SQSMessage처럼 타입 단언만 하던 걸 Zod 스키마 검증으로 바꿨다.
const SQSMessageSchema = z.object({
jobId: z.string().min(1),
channelId: z.string().min(1),
topic: z.string().min(1),
});
const { jobId, channelId, topic } = SQSMessageSchema.parse(JSON.parse(record.body));
as SQSMessage는 컴파일 타임에만 존재하는 가짜 보장이다. 실제 메시지에 필드가 비어있거나 타입이 다르면 그대로 통과해서 나중에 엉뚱한 곳에서 터진다. script, tts, upload 워커에 전부 적용했다.
SQS는 최소 한 번 전달을 보장하지, 정확히 한 번은 아니다. 같은 메시지가 재처리되면 같은 영상이 YouTube에 두 번 올라갈 수 있었다.
const job = await prisma.job.findUnique({
where: { id: jobId },
select: { youtubeVideoId: true, scriptContent: true },
});
if (job.youtubeVideoId) {
log.info({ youtubeVideoId: job.youtubeVideoId }, '이미 업로드됨 — 재처리 스킵');
continue;
}
youtubeVideoId가 이미 채워져 있으면 이미 업로드된 job이라는 뜻이니 그냥 스킵한다. 기존엔 이 조회와 scriptContent 조회가 각각 따로 나갔는데, 이번에 하나로 합쳤다.
SQS, SSM, EventBridge 권한이 Resource: "*"로 열려 있었다. 실제 리소스 ARN 패턴으로 좁혔다.
# SQS: arn:aws:sqs:...:prod-* 로 범위 한정
# SSM: arn:aws:ssm:...:parameter/shorts.prod.* 로 범위 한정
# EventBridge: rule/* 로 한정 (채널별 동적 규칙 생성 필요성 주석 명시)
EventBridge만 rule/*로 남겨뒀다. API가 채널마다 EventBridge 규칙을 동적으로 만드는 구조라 규칙 이름을 미리 다 알 수 없다. 대신 왜 와일드카드가 필요한지 주석으로 명시해뒀다 — 나중에 "이거 왜 좁혀놨지" 하고 다시 벌리는 실수를 막으려고.
버킷에 SSE-AES256 암호화를 붙였다.
resource "aws_s3_bucket_server_side_encryption_configuration" "bucket" {
bucket = aws_s3_bucket.bucket.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
영상 원본과 렌더 결과물이 저장되는 버킷이라 기본 암호화 정도는 당연히 켜져 있었어야 했는데, 그동안 놓치고 있었다.
이 다섯 개를 묶어서 .claude/rules/security.md에 SQS 검증·IAM 패턴·S3 암호화·업로드 멱등성 규칙으로 정리해뒀다. 다음에 새 워커나 엔드포인트 만들 때 이 체크리스트를 다시 안 놓치기 위해서다.