Golang file service
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.mod에 require가 없다. 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 연동 |