설명
사용자 입력으로 만든 파일 경로가 의도한 디렉터리를 벗어나면 다른 위치의 파일을 읽거나 쓸 수 있습니다. ../ 같은 상위 이동과 링크를 통한 우회가 대표적이며, 압축 해제에서도 같은 문제가 발생합니다.
잠재적 영향
- 설정, 소스 코드, 자격 증명 같은 민감 정보가 노출될 수 있습니다.
- 업로드·압축 해제에서 파일을 덮어써 데이터 손상이나 서비스 중단을 일으킬 수 있습니다.
- 실행에 사용되는 파일을 수정할 수 있다면 코드 실행으로 이어질 수 있습니다.
해결 방법
- 고정된 루트 아래에서만 파일을 열도록 하세요.
filepath.Clean이나 문자열 접두사 검사는 실제 파일 접근의 경계를 보장하지 않습니다. - 파일명만 필요하면 단일 구성 요소만 허용하고, 상대 경로가 필요하면
filepath.Rel로 상위 탈출을 거부하세요. - 심볼릭 링크와 검사 후 교체에 대응하는 디렉터리 기준 열기 방식을 사용하세요. Go 1.24 이상에서는
os.OpenRoot를 검토할 수 있습니다.EvalSymlinks만 호출한 뒤 다시 여는 방식에는 경쟁 조건이 남습니다. - 파일별 접근 권한, 필요한 확장자·크기·깊이 제한을 적용하고 검증 실패 시 중단하세요.
예시
변경 후는 문자열 수준의 경로 범위 검사 예입니다. /var/app/data 트리는 신뢰할 수 있는 관리자가 통제하고, 외부로 향하는 링크나 공격자가 바꿀 수 있는 경로가 없어야 합니다. 그런 전제를 보장할 수 없다면 링크 우회를 막는 열기 방식으로 바꾸세요. 인증과 파일별 권한 검사는 생략했습니다.
변경 전
go
package main
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
)
func download(c *gin.Context) {
// BAD: 사용자 입력을 경로에 직접 사용
// 예: ?file=../../etc/passwd
file := c.Query("file")
baseDir := "/var/app/data"
// Join만으로는 우회 가능: Join은 Clean을 수행하며 상위로 탈출할 수 있음
fullPath := filepath.Join(baseDir, file)
if _, err := os.Stat(fullPath); err != nil {
c.String(http.StatusNotFound, "not found")
return
}
http.ServeFile(c.Writer, c.Request, fullPath)
}
func main() {
r := gin.Default()
r.GET("/download", download)
r.Run()
}
변경 후
go
package main
import (
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/gin-gonic/gin"
)
func isUnderBase(base, target string) bool {
// 경로 정규화 후 구분자도 함께 비교하여 base 하위 여부 확인
b := filepath.Clean(base)
t := filepath.Clean(target)
sep := string(os.PathSeparator)
if runtime.GOOS == "windows" {
b = strings.ToLower(b)
t = strings.ToLower(t)
}
return strings.HasPrefix(t+sep, b+sep)
}
func safeDownload(c *gin.Context) {
file := c.Query("file")
baseDir := "/var/app/data" // 고정 루트 디렉터리
// 1) 절대경로, 상위 이동을 포함하더라도 baseDir 내부만 허용
target := filepath.Join(baseDir, file)
if !isUnderBase(baseDir, target) {
c.String(http.StatusBadRequest, "invalid path")
return
}
// 2) 필요 시 확장자 화이트리스트 예시
// if !strings.HasSuffix(strings.ToLower(target), ".txt") { ... }
// 3) 파일 제공
if _, err := os.Stat(target); err != nil {
c.String(http.StatusNotFound, "not found")
return
}
http.ServeFile(c.Writer, c.Request, target)
}
func main() {
r := gin.Default()
r.GET("/download", safeDownload)
r.Run()
}
설명:
- 변경 전:
filepath.Join은 경로를 정규화하지만 루트 밖으로 나가는 결과까지 거부하지는 않습니다. 쿼리의 파일 값으로 만든 경로를ServeFile에 전달하므로 요청 URL 경로에 대한 검사만으로 보호되지 않습니다. - 변경 후: 구분자를 포함한 접두사 비교로 문자열 경계 밖의 경로를 거부합니다. 심볼릭 링크, 검사 후 경로 교체, 파일별 권한, 디렉터리 내용 공개까지 해결하는 예는 아닙니다.