0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go言語でセキュアなファイルマネージャーを構築する

Posted at

はじめに

Webベースのファイルマネージャーを開発する際、最も重要な要素の一つがセキュリティです。特にファイルシステムへの直接アクセスを伴うアプリケーションでは、パストラバーサル攻撃、不正ファイルアクセス、XSS攻撃など、様々なセキュリティリスクが存在します。

本記事では、Go言語を使用してセキュアなファイルマネージャーを構築するための具体的な手法と実装方法について詳しく解説します。実際に開発したオープンソースプロジェクトのコードを例に、実践的なセキュリティ対策を紹介していきます。

Security Architecture

セキュリティ脅威の分析

パストラバーサル攻撃

パストラバーサル攻撃は、../などの相対パス文字列を使用して、アプリケーションが意図したディレクトリ外のファイルにアクセスする攻撃手法です。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), "\"", "&quot;")
        },
        "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ベースファイルマネージャーを構築するために重要なポイントを整理します。

  1. 厳格なパス検証: すべてのファイルパスを絶対パスで検証し、許可されたディレクトリ外へのアクセスを防止
  2. 入力値のサニタイズ: ファイル名やパスパラメータの適切なエスケープとサニタイズ
  3. セキュリティヘッダー: ブラウザレベルでの攻撃防止のための適切なHTTPヘッダー設定
  4. アップロード制限: ファイルサイズとファイルタイプの適切な制限
  5. ログ記録: セキュリティイベントの適切な記録と監査証跡の確保
  6. 継続的テスト: セキュリティ機能の動作確認のための自動テスト実装

これらの対策を適切に実装することで、安全性の高いファイルマネージャーを構築できます。セキュリティは一度実装すれば終わりではなく、継続的な見直しと改善が必要であることも忘れてはいけません。

参考リンク


セキュリティに関する注意: 本記事で紹介した内容は一般的なセキュリティ対策の例です。実際の本番環境では、追加的なセキュリティ監査と専門家によるレビューを推奨します。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?