ガイドライン

ファイルアップロード

アプリケーションにおいて、ユーザーにファイルのアップロードを許可する(利用目的や保存目的を問わず)機能は、非常に一般的です。一見単純な機能ですが、その実装方法によっては、ファイルアップロードの取り扱いに伴う潜在的なリスクが非常に重大な問題となる可能性があります。

ここで、何が問題なのかを視覚的に理解しやすくするため、簡単な例を見てみましょう。

ユーザーがプロフィール写真をアップロードできるアプリケーションを例に挙げます。

public string UploadProfilePicture(FormFile uploadedFile)
{
    // アップロードされたファイルの保存先パスを生成
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";  

    // ファイルを保存
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // プロフィール写真を更新
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

これは非常に基本的なアップロード機能ですが、同時にパストラバーサル攻撃に対して脆弱な実装となっています。

アプリケーションの具体的な実装状況によっては、攻撃者が別のページやスクリプト(.asp、.aspx、.phpファイルなど)をアップロードし、それを直接呼び出して任意のコードを実行させることが可能になる場合があります。また、既存のファイルを上書きされるリスクもあります。

課題1 - 外部データストアではなくローカルディスクへの保存

クラウドサービスの利用が一般的になり、アプリケーションがコンテナで提供され、高可用性構成が標準となった現在、アップロードされたファイルをアプリケーションのローカルディスクに書き込むという手法は、原則として避けるべきです。

可能な限り、ファイルは中央ストレージ(ブロックストレージやデータベースなど)にアップロードするようにしてください。これにより、この種の問題に起因する一連のセキュリティ脆弱性を回避できます。

課題2 - 拡張子の検証不足

ファイルアップロードの脆弱性が悪用されるケースの多くは、特定の拡張子を持つファイルをアップロードできることに起因しています。そのため、アップロード可能なファイルの拡張子を「許可リスト(ホワイトリスト)」で管理することを強く推奨します。

Nullバイトインジェクションなどの問題を回避するため、必ず使用している言語やフレームワークが提供するメソッドを使用してファイル拡張子を取得するようにしてください。

アップロードされたファイルのコンテンツタイプ(MIMEタイプ)を検証したくなるかもしれませんが、OSによって特定のファイルに使用されるコンテンツタイプが異なる場合があるため、この方法では実装が非常に不安定になります。また、コンテンツタイプは単に拡張子からマッピングされた情報に過ぎないため、ファイルそのものに関する信頼できる情報にはなりません。

課題3 - パストラバーサル対策の欠如

ファイルアップロードにおけるもう一つの一般的な問題は、パストラバーサル攻撃に対して脆弱になりやすい点です。これについては非常に重要なトピックであるため、ここで要約するのではなく、ぜひパストラバーサルに関する詳細なガイドラインをご覧ください。

その他の例

以下に、安全なファイルアップロードと安全でないファイルアップロードの例をいくつか紹介しますので、参考にしてください。

C# - 安全でない例

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // アップロードされたファイルの保存先パスを生成
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";

    // ファイルを保存
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // プロフィール写真を更新
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

C# - 安全な実装

public List<string> AllowedExtensions = new() { ".png", ".jpg", ".gif"};

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // 注:ファイルをローカルディスクに保存しないのが最善の選択肢です。
    var basePath = Path.GetFullPath("./uploads/avatars/");

    // 提供されたファイル名を使用せず、パス・トラバーサルを防止します。ファイル名の競合回避にも必要です。
    var newFileName = GenerateFileName(uploadedFile.FileName);

    // アップロードされたファイルの保存先パスを生成
    var canonicalPath = Path.Combine(basePath, newFileName);

    // ベースフォルダ外に誤って保存されないことを確認
    if(!canonicalPath.StartsWith(basePath))
    {
        return BadRequest("アップロードフォルダ外への保存が試行されました");
    }

    // 許可された拡張子のみが保存されることを確認
    if(!IsFileAllowedExtension(uploadedAllowedExtensions))
    {
        return BadRequest("許可されていない拡張子です");
    }

    // ファイルを保存
    var localFile = File.OpenWrite(canonicalPath);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // プロフィール写真を更新
    UserProfile.UpdateUserProfilePicture(request.User, canonicalPath)

    return path;

public bool GenerateFileName(string originalFileName) {
    return $"{Guid.NewGuid()}{Path.GetExtension(originalFileName)}";
}

public bool IsFileAllowedExtension(string fileName, List<string> extensions) {
    return extensions.Contains(Path.GetExtension(fileName));
}

‍

Java - 安全でない実装

@Controller
public class FileUploadController {

   @RequestMapping(value = "/files/upload", method = RequestMethod.POST)
   @ResponseBody
   public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file, @AuthenticationPrincipal User user) {

       try {

           String uploadPath = "./uploads/avatars/" + principal.getName() + "/" + file.getOriginalFilename();

           File transferFile = new File(uploadPath);
           file.transferTo(transferFile);

       } catch (Exception e) {
           return new ResponseEntity<>("アップロードエラー", HttpStatus.INTERNAL_SERVER_ERROR);
       }

       return new ResponseEntity<>(uploadPath, HttpStatus.CREATED);
   }
}

Java - 安全な実装

@Controller
public class FileUploadController {

    @RequestMapping(value = "/files/upload", method = RequestMethod.POST)
    @ResponseBody
    public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file, @AuthenticationPrincipal User user) {

        try {
            String baseFolder = Paths.get("./uploads/avatars/").normalize();
            String uploadPath = Paths.get(baseFolder.toString() +
GenerateFileName(file.getOriginalFilename())).normalize();
           // 拡張子が許可されたタイプであることを確認
            if(!IsAllowedExtension(file.getOriginalFilename()) {
                return new ResponseEntity<>("許可されていない拡張子です", HttpStatus.FORBIDDEN);
            }

           // ファイルがアップロードルート外に保存されないことを確認
           if(!uploadPath.toString().startsWith(baseFolder.toString()))            {
                return new ResponseEntity<>("ベースフォルダ外への保存は許可されていません。", HttpStatus.FORBIDDEN);
           }

            File transferFile = new File(uploadPath.toString());
            file.transferTo(uploadPath.toString());

        } catch (Exception e) {
            return new ResponseEntity<>("アップロードエラー", HttpStatus.INTERNAL_SERVER_ERROR);
        }

        return new ResponseEntity<>(uploadPath, HttpStatus.CREATED);
    }

    private string GenerateFileName(String fileName) {
        return UUID.randomUUID().toString() + "." + FilenameUtils.getExtension(fileName);
    }

    private boolean IsAllowedExtension(String fileName) {
        String[] allowedExtensions = {"jpg", "png", "gif"};
        String extension = FilenameUtils.getExtension(filename);
        return allowedExtensions.contains(extension);
    }
}

Python - Flask - 安全でない実装

@app.route('/files/upload', methods=['POST'])
def upload_file():

    file = request.files['file']

    savedFilePath = os.path.join("./uploads/avatars/", file.filename)
    file.save(savedFilePath)

    return savedFilePath

Python - Flask - 安全な実装

@app.route('/files/upload', methods=['POST'])
def upload_file():

    file = request.files['file']
    baseFolder = os.path.normpath("./uploads/avatars/")
    savedFilePath = os.path.normpath(os.path.join(baseFolder, generate_file_name(file.filename)))

    # 拡張子が許可されたセットに含まれていることを確認
    if not is_extension_allowed(file.filename):
        return "この拡張子は許可されていません"

    # 保存先がベースフォルダ外ではないことを確認
    if not savedFilePath.startsWith(baseFolder):
        return "ベースフォルダ外への保存が試行されました"

    file.save(savedFilePath)

    return savedFilePath

def generate_file_name(filename):
    return str(uuid.uuid4()) + os.path.splitext(filename)[1]

def is_extension_allowed(filename):
    return os.path.splitext(filename)[1] in (".png", ".jpg", ".gif")
タグライン

実践的なセキュアコーディング

OWASP Top 10のようなソフトウェアの脆弱性を理解し軽減するためのセキュアコーディングガイドラインを確認しましょう。また、実践的なアプリシミュレーションを通じて学べるトレーニングミッションにもぜひ挑戦してください。

その他のガイドライン
デモを予約する