Develop & OpenSource

Golang file service

KWAKBUMJUN 2026. 4. 6. 01:18

Github url

https://github.com/kwakbumjun713/golang-fileservice/tree/main/go-fileshare

프로젝트 구조

go-fileshare/
├── main.go            ← 서버 전체 코드 (단일 파일)
├── templates/
│   ├── index.html     ← 메인 페이지 (업로드 + 파일 목록)
│   └── share.html     ← 공유 링크 페이지
├── uploads/           ← 업로드 파일 저장 경로
├── Dockerfile
├── go.mod
└── README.md

의도적으로 외부 의존성 0개로 만들었다. go.modrequire가 없다. net/http, html/template, crypto/rand, mime 등 표준 라이브러리만 사용한다.


핵심 구현 해설

1. 파일 업로드 핸들러

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    // 1. 요청 크기 제한 (50MB)
    r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
    r.ParseMultipartForm(maxUploadSize)

    // 2. 파일 추출
    file, header, _ := r.FormFile("file")

    // 3. 보안 검증 (아래 상세 설명)
    origName := sanitizeFilename(header.Filename)  // 경로 순회 방지
    if !isAllowedExt(origName) { ... }             // 확장자 화이트리스트
    mimeType, _ := detectAndValidateMIME(data, origName)  // MIME 검증

    // 4. 고유 ID로 저장
    id := generateID()  // crypto/rand 기반 16바이트 랜덤
    storeName := id + ext
    os.WriteFile(filepath.Join(uploadDir, storeName), data, 0644)

    // 5. curl vs 브라우저 응답 분기
    if isCurl(r) {
        fmt.Fprintf(w, "Share: /share/%s\n", id)
    } else {
        http.Redirect(w, r, "/", http.StatusSeeOther)
    }
}

핵심 설계 결정: 파일을 원본 이름이 아닌 랜덤 ID + 확장자로 저장한다. 이유:

  • 파일명 충돌 방지
  • 원본 파일명에 포함될 수 있는 특수문자/경로 구분자 무력화
  • URL에서 원본 파일명이 노출되지 않아 프라이버시 보호

2. 보안 검증 — 4계층 방어

파일 업로드는 웹 보안에서 가장 위험한 기능 중 하나다. 4개의 독립적인 검증 레이어를 적용했다.

Layer 1: 파일명 새니타이징 (경로 순회 방지)

func sanitizeFilename(name string) string {
    name = filepath.Base(name)           // 경로 구분자 제거: ../../etc/passwd → passwd
    name = strings.ReplaceAll(name, "..", "")  // .. 제거
    name = strings.ReplaceAll(name, "\x00", "") // null 바이트 제거
    return name
}

공격자가 ../../etc/passwd 같은 파일명을 보내면 filepath.Base()passwd만 남긴다. 이중으로 ..도 제거한다.

Layer 2: 확장자 화이트리스트

var allowedExts = map[string]bool{
    ".txt": true, ".pdf": true, ".png": true, ".jpg": true,
    ".zip": true, ".go": true, ".py": true, // ...
}

블랙리스트가 아닌 화이트리스트를 사용한다. 블랙리스트(.exe, .bat 차단)는 새로운 위험 확장자가 등장하면 뚫린다. 화이트리스트는 허용된 것만 통과시키므로 기본적으로 안전하다.

테스트 결과:

$ curl -F 'file=@evil.exe' localhost:8080/upload
Extension not allowed: .exe

Layer 3: MIME 타입 검증 (Content Sniffing)

func detectAndValidateMIME(data []byte, filename string) (string, error) {
    detected := http.DetectContentType(data)  // 파일 내용 기반 감지
    if blockedMIME[detected] { return "", err }

    extMIME := mime.TypeByExtension(filepath.Ext(filename))  // 확장자 기반
    if blockedMIME[extMIME] { return "", err }

    return detected, nil
}

이중 검증: 파일 내용(매직 바이트)과 확장자 양쪽으로 MIME을 확인한다. .txt로 확장자를 위장한 ELF 바이너리도 http.DetectContentType()application/x-executable로 감지하여 차단한다.

Layer 4: 최종 경로 검증

func isInsideUploadDir(path string) bool {
    absUpload, _ := filepath.Abs(uploadDir)
    absPath, _ := filepath.Abs(path)
    return strings.HasPrefix(absPath, absUpload+string(os.PathSeparator))
}

파일 저장 직전에 절대 경로가 uploads/ 디렉토리 내부인지 최종 확인한다. 앞선 레이어를 모두 우회하더라도 이 검증에서 걸린다.

3. 공유 링크 시스템

업로드 → ID 생성 (crypto/rand 16바이트 = 32자 hex)
       → /share/{id} 경로로 공유 링크 생성
       → /download/{id} 경로로 다운로드

예시:
  공유: http://localhost:8080/share/2c869ba666847b460d52919a64351cc1
  다운: http://localhost:8080/download/2c869ba666847b460d52919a64351cc1

crypto/rand를 사용하므로 ID는 예측 불가능하다. 링크를 아는 사람만 파일에 접근할 수 있다 (Unguessable URL 패턴).

4. curl / 브라우저 양쪽 지원

동일한 엔드포인트(/upload, /share/{id})가 User-Agent를 보고 응답 형식을 분기한다:

클라이언트 업로드 응답 공유 링크 응답
curl 텍스트 (Upload OK\nShare: ...) 텍스트 (파일 정보)
브라우저 리다이렉트 → 메인 페이지 HTML 공유 페이지
# curl 업로드
$ curl -F 'file=@report.pdf' localhost:8080/upload
Upload OK
File: report.pdf
Size: 12345 bytes
Share: localhost:8080/share/abc123...

# curl 다운로드
$ curl -OJ localhost:8080/download/abc123...

5. 보안 헤더 미들웨어

func securityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Content-Type-Options", "nosniff")    // MIME 스니핑 방지
        w.Header().Set("X-Frame-Options", "DENY")              // 클릭재킹 방지
        w.Header().Set("X-XSS-Protection", "1; mode=block")    // XSS 필터
        next.ServeHTTP(w, r)
    })
}

다운로드 시에는 추가로:

  • Content-Disposition: attachment — 브라우저에서 실행 대신 다운로드 강제
  • Content-Type: application/octet-stream — MIME 스니핑으로 인한 실행 방지

배운 것

Go 표준 라이브러리의 충분함

프레임워크(Gin, Echo 등) 없이 net/http만으로 완전한 웹 서비스를 만들 수 있었다. 라우팅, 멀티파트 파싱, 파일 서빙, 템플릿 렌더링이 모두 표준 라이브러리에 포함되어 있다. 외부 의존성 0개로 빌드 → 단일 바이너리 → Docker 이미지 크기 최소화로 이어진다.

파일 업로드 보안은 겹겹이

단일 검증만으로는 부족하다는 것을 체감했다:

  • 확장자만 검증? → 확장자 위장으로 우회
  • MIME만 검증? → Content-Type 헤더 조작으로 우회
  • 파일명만 새니타이징? → 경로 순회 변종으로 우회

4계층을 겹쳐야 비로소 안전해진다. 이것이 "심층 방어(Defense in Depth)"의 실체다.

HTTP 멀티파트의 동작 원리

r.ParseMultipartForm()이 내부적으로 Content-Type: multipart/form-data의 boundary를 파싱하고, 각 파트를 메모리 또는 임시 파일에 버퍼링하는 과정을 추적할 수 있었다. MaxBytesReader로 요청 크기를 제한하면 파싱 전에 차단되어 DoS 방어가 된다.


실행 방법

# 로컬 실행
git clone <repo-url> && cd go-fileshare
go build -o fileshare . && ./fileshare

# Docker
docker build -t go-fileshare .
docker run -p 8080:8080 go-fileshare

접속: http://localhost:8080


개선 가능 영역

현재 구현에서 의도적으로 생략한 부분:

항목 현재 개선 방향
저장소 인메모리 맵 (재시작 시 유실) SQLite 또는 BoltDB 영속화
인증 없음 (누구나 업로드) API 키 또는 간단한 비밀번호
공유 링크 만료 없음 (영구) TTL 설정 + 자동 삭제
HTTPS 없음 Let's Encrypt + Caddy 리버스 프록시
바이러스 스캔 없음 ClamAV 연동

참고 자료

'Develop & OpenSource' 카테고리의 다른 글

EDR 환경을 직접 구축 후 실습  (0) 2026.03.30
codeql 원리와 사용법 분석  (0) 2026.03.30