はじめに
Webベースのファイルマネージャーを開発する際、最も重要な要素の一つがセキュリティです。特にファイルシステムへの直接アクセスを伴うアプリケーションでは、パストラバーサル攻撃、不正ファイルアクセス、XSS攻撃など、様々なセキュリティリスクが存在します。
本記事では、Go言語を使用してセキュアなファイルマネージャーを構築するための具体的な手法と実装方法について詳しく解説します。実際に開発したオープンソースプロジェクトのコードを例に、実践的なセキュリティ対策を紹介していきます。
セキュリティ脅威の分析
パストラバーサル攻撃
パストラバーサル攻撃は、../
などの相対パス文字列を使用して、アプリケーションが意図したディレクトリ外のファイルにアクセスする攻撃手法です。Webベースのファイルマネージャーでは、以下のような攻撃が考えられます:
GET /download?path=../../../etc/passwd
GET /preview?file=....//....//etc/hosts
POST /upload?destination=../../root/.ssh/
ファイルアップロード攻撃
悪意のあるファイルのアップロードにより、サーバー上で任意のコードが実行される可能性があります。特にWebサーバーの実行可能ディレクトリにスクリプトファイルをアップロードされた場合、重大なセキュリティリスクとなります。
XSS攻撃
ファイル名やディレクトリ名に悪意のあるJavaScriptコードが含まれている場合、これらをHTMLに表示する際にスクリプトが実行される可能性があります。
セキュアなパス処理の実装
絶対パス検証システム
最も効果的なパストラバーサル攻撃の防止方法は、すべてのファイルパスを絶対パスに変換し、許可されたルートディレクトリ内に含まれることを確認することです。
package main
import (
"fmt"
"path/filepath"
"strings"
)
// セキュアなパス検証関数
func validatePath(rootDir, requestedPath string) (string, error) {
// ルートディレクトリを絶対パスに変換
absRootDir, err := filepath.Abs(rootDir)
if err != nil {
return "", fmt.Errorf("ルートディレクトリの絶対パス取得失敗: %v", err)
}
// リクエストされたパスを結合
fullPath := filepath.Join(absRootDir, requestedPath)
// パスを正規化
cleanPath := filepath.Clean(fullPath)
// 絶対パスに変換
absPath, err := filepath.Abs(cleanPath)
if err != nil {
return "", fmt.Errorf("絶対パス変換失敗: %v", err)
}
// ルートディレクトリ内に含まれるかチェック
if !strings.HasPrefix(absPath, absRootDir) {
return "", fmt.Errorf("不正なパスアクセス: %s", requestedPath)
}
return absPath, nil
}
// 使用例
func exampleUsage() {
rootDir := "/home/filemanager"
// 正常なパス
validPath, err := validatePath(rootDir, "documents/file.txt")
if err != nil {
fmt.Printf("エラー: %v\n", err)
} else {
fmt.Printf("有効なパス: %s\n", validPath)
}
// 攻撃的なパス
invalidPath, err := validatePath(rootDir, "../../../etc/passwd")
if err != nil {
fmt.Printf("攻撃検出: %v\n", err)
} else {
fmt.Printf("パス: %s\n", invalidPath)
}
}
ファイル操作ハンドラーの実装
セキュアなファイル操作を行うためのHTTPハンドラーを実装します。
package main
import (
"fmt"
"html"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
type FileManager struct {
RootDir string
MaxUploadSize int64
}
// ダウンロードハンドラー
func (fm *FileManager) downloadHandler(w http.ResponseWriter, r *http.Request) {
// パスパラメータを取得
requestedPath := r.URL.Query().Get("path")
if requestedPath == "" {
http.Error(w, "パスが指定されていません", http.StatusBadRequest)
return
}
// パス検証
safePath, err := validatePath(fm.RootDir, requestedPath)
if err != nil {
http.Error(w, "不正なパス", http.StatusForbidden)
return
}
// ファイル存在確認
fileInfo, err := os.Stat(safePath)
if err != nil {
http.Error(w, "ファイルが見つかりません", http.StatusNotFound)
return
}
// ディレクトリの場合はエラー
if fileInfo.IsDir() {
http.Error(w, "ディレクトリはダウンロードできません", http.StatusBadRequest)
return
}
// ファイルを開く
file, err := os.Open(safePath)
if err != nil {
http.Error(w, "ファイルオープンエラー", http.StatusInternalServerError)
return
}
defer file.Close()
// レスポンスヘッダーを設定
filename := filepath.Base(safePath)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
// ファイル内容をコピー
_, err = io.Copy(w, file)
if err != nil {
// ログに記録(実際の実装では適切なログライブラリを使用)
fmt.Printf("ファイル送信エラー: %v\n", err)
}
}
// アップロードハンドラー
func (fm *FileManager) uploadHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POSTメソッドのみ許可", http.StatusMethodNotAllowed)
return
}
// ファイルサイズ制限
r.Body = http.MaxBytesReader(w, r.Body, fm.MaxUploadSize)
// マルチパートフォームの解析
err := r.ParseMultipartForm(fm.MaxUploadSize)
if err != nil {
http.Error(w, "ファイルサイズが大きすぎます", http.StatusBadRequest)
return
}
// アップロード先パスの検証
uploadDir := r.FormValue("path")
safePath, err := validatePath(fm.RootDir, uploadDir)
if err != nil {
http.Error(w, "不正なアップロードパス", http.StatusForbidden)
return
}
// ディレクトリ存在確認
if _, err := os.Stat(safePath); os.IsNotExist(err) {
http.Error(w, "アップロード先ディレクトリが存在しません", http.StatusNotFound)
return
}
// アップロードファイルを取得
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "ファイル取得エラー", http.StatusBadRequest)
return
}
defer file.Close()
// ファイル名の検証とサニタイズ
filename := sanitizeFilename(header.Filename)
if filename == "" {
http.Error(w, "無効なファイル名", http.StatusBadRequest)
return
}
// 保存先パスの構築
destPath := filepath.Join(safePath, filename)
// ファイル作成
destFile, err := os.Create(destPath)
if err != nil {
http.Error(w, "ファイル作成エラー", http.StatusInternalServerError)
return
}
defer destFile.Close()
// ファイル内容をコピー
_, err = io.Copy(destFile, file)
if err != nil {
http.Error(w, "ファイル保存エラー", http.StatusInternalServerError)
return
}
// 成功レスポンス
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"success": true, "message": "ファイルアップロード完了"}`)
}
// ファイル名のサニタイズ
func sanitizeFilename(filename string) string {
// 危険な文字を除去
dangerous := []string{"..", "/", "\\", ":", "*", "?", "\"", "<", ">", "|"}
sanitized := filename
for _, char := range dangerous {
sanitized = strings.ReplaceAll(sanitized, char, "_")
}
// 空白のトリム
sanitized = strings.TrimSpace(sanitized)
// 最大長制限
if len(sanitized) > 255 {
sanitized = sanitized[:255]
}
return sanitized
}
XSS攻撃の防止
HTMLテンプレートでファイル情報を表示する際のXSS攻撃防止策を実装します。
package main
import (
"html"
"html/template"
"strings"
)
// XSS防止用のテンプレート関数
func createTemplateFunctions() template.FuncMap {
return template.FuncMap{
"escapeHTML": func(s string) string {
return html.EscapeString(s)
},
"sanitizeAttr": func(s string) string {
// HTML属性値として安全な文字列に変換
return strings.ReplaceAll(html.EscapeString(s), "\"", """)
},
"truncate": func(s string, length int) string {
if len(s) <= length {
return s
}
return s[:length] + "..."
},
}
}
// テンプレートの使用例
const templateHTML = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{.Title | escapeHTML}}</title>
</head>
<body>
<h1>ファイル一覧</h1>
<table>
<thead>
<tr>
<th>ファイル名</th>
<th>サイズ</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .Files}}
<tr>
<td>{{.Name | escapeHTML | truncate 50}}</td>
<td>{{.Size}}</td>
<td>
<a href="/download?path={{.Path | sanitizeAttr}}">ダウンロード</a>
<a href="/preview?file={{.Path | sanitizeAttr}}">プレビュー</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</body>
</html>
`
セキュリティヘッダーの設定
HTTP応答にセキュリティヘッダーを設定し、ブラウザレベルでの攻撃を防止します。
package main
import (
"net/http"
)
// セキュリティミドルウェア
func securityMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// XSSプロテクション
w.Header().Set("X-XSS-Protection", "1; mode=block")
// コンテンツタイプスニッフィング防止
w.Header().Set("X-Content-Type-Options", "nosniff")
// フレーム埋め込み防止
w.Header().Set("X-Frame-Options", "DENY")
// HTTPS強制(必要に応じて)
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// CSP設定
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"object-src 'none'")
// リファラーポリシー
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// サーバー設定
func setupSecureServer() *http.Server {
mux := http.NewServeMux()
// ファイルマネージャーのハンドラーを登録
fm := &FileManager{
RootDir: "/home/filemanager",
MaxUploadSize: 100 * 1024 * 1024, // 100MB
}
mux.HandleFunc("/", fm.indexHandler)
mux.HandleFunc("/download", fm.downloadHandler)
mux.HandleFunc("/upload", fm.uploadHandler)
// セキュリティミドルウェアを適用
secureHandler := securityMiddleware(mux)
return &http.Server{
Addr: ":8086",
Handler: secureHandler,
}
}
ロギングと監査
セキュリティインシデントの検出と調査のため、適切なロギング機能を実装します。
package main
import (
"fmt"
"log"
"net/http"
"time"
)
// セキュリティイベントのログ記録
type SecurityLogger struct {
logger *log.Logger
}
func (sl *SecurityLogger) LogSecurityEvent(eventType, clientIP, message string) {
timestamp := time.Now().Format(time.RFC3339)
sl.logger.Printf("[SECURITY] %s | %s | %s | %s", timestamp, eventType, clientIP, message)
}
// リクエストログミドルウェア
func loggingMiddleware(logger *SecurityLogger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// クライアントIPを取得
clientIP := r.Header.Get("X-Real-IP")
if clientIP == "" {
clientIP = r.Header.Get("X-Forwarded-For")
}
if clientIP == "" {
clientIP = r.RemoteAddr
}
// 不審なパスパターンをチェック
suspiciousPatterns := []string{"../", "..\\", "%2e%2e", "etc/passwd", ".ssh"}
for _, pattern := range suspiciousPatterns {
if strings.Contains(r.URL.Path, pattern) || strings.Contains(r.URL.RawQuery, pattern) {
logger.LogSecurityEvent("SUSPICIOUS_PATH", clientIP,
fmt.Sprintf("疑わしいパスアクセス: %s?%s", r.URL.Path, r.URL.RawQuery))
}
}
// レスポンスライターをラップして状態コードを記録
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
// アクセスログを記録
log.Printf("%s %s %s %d %v",
clientIP, r.Method, r.URL.String(), wrapped.statusCode, duration)
// エラーレスポンスをセキュリティログに記録
if wrapped.statusCode >= 400 {
logger.LogSecurityEvent("HTTP_ERROR", clientIP,
fmt.Sprintf("%s %s -> %d", r.Method, r.URL.String(), wrapped.statusCode))
}
})
}
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
セキュリティテストの実装
開発したセキュリティ機能が正しく動作することを確認するためのテストコードを実装します。
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestPathValidation(t *testing.T) {
tests := []struct {
name string
rootDir string
requestPath string
expectError bool
}{
{
name: "正常なパス",
rootDir: "/home/test",
requestPath: "documents/file.txt",
expectError: false,
},
{
name: "パストラバーサル攻撃",
rootDir: "/home/test",
requestPath: "../../../etc/passwd",
expectError: true,
},
{
name: "相対パス攻撃",
rootDir: "/home/test",
requestPath: "../../root/.ssh/",
expectError: true,
},
{
name: "URLエンコード攻撃",
rootDir: "/home/test",
requestPath: "..%2f..%2f..%2fetc%2fpasswd",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := validatePath(tt.rootDir, tt.requestPath)
if (err != nil) != tt.expectError {
t.Errorf("validatePath() error = %v, expectError %v", err, tt.expectError)
}
})
}
}
func TestDownloadSecurity(t *testing.T) {
fm := &FileManager{
RootDir: "/tmp/test",
MaxUploadSize: 1024 * 1024,
}
// 攻撃的なリクエストをテスト
req := httptest.NewRequest("GET", "/download?path=../../../etc/passwd", nil)
w := httptest.NewRecorder()
fm.downloadHandler(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("期待される状態コード %d, 実際の状態コード %d", http.StatusForbidden, w.Code)
}
}
func TestXSSPrevention(t *testing.T) {
maliciousFilename := "<script>alert('xss')</script>test.txt"
sanitized := sanitizeFilename(maliciousFilename)
if strings.Contains(sanitized, "<script>") {
t.Errorf("XSSスクリプトがサニタイズされていません: %s", sanitized)
}
}
まとめ
Go言語を使用してセキュアなWebベースファイルマネージャーを構築するために重要なポイントを整理します。
- 厳格なパス検証: すべてのファイルパスを絶対パスで検証し、許可されたディレクトリ外へのアクセスを防止
- 入力値のサニタイズ: ファイル名やパスパラメータの適切なエスケープとサニタイズ
- セキュリティヘッダー: ブラウザレベルでの攻撃防止のための適切なHTTPヘッダー設定
- アップロード制限: ファイルサイズとファイルタイプの適切な制限
- ログ記録: セキュリティイベントの適切な記録と監査証跡の確保
- 継続的テスト: セキュリティ機能の動作確認のための自動テスト実装
これらの対策を適切に実装することで、安全性の高いファイルマネージャーを構築できます。セキュリティは一度実装すれば終わりではなく、継続的な見直しと改善が必要であることも忘れてはいけません。
参考リンク
- GitHub Repository: https://github.com/yuis-ice/go-filemanager
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
- Go Security Guidelines: https://golang.org/doc/security
セキュリティに関する注意: 本記事で紹介した内容は一般的なセキュリティ対策の例です。実際の本番環境では、追加的なセキュリティ監査と専門家によるレビューを推奨します。