ガイドライン

ファイルのアップロード

アプリケーションは、ある時点で、ユーザがアプリケーション内のどこかにファイルをアップロードできるようにする必 要が生じることがよくあります(使用するため、あるいは単に保存するため)。単純なことのように見えますが、この機能をどのように実装するかは、ファイルアップロードがどのように扱われるか に関連する潜在的なリスクのために、非常に重要です。 

この簡単な例を見て、私たちが言いたいことをより視覚的に理解してほしい。 

例えば、ユーザーがプロフィール写真をアップロードできるアプリケーションだとしよう:

public string UploadProfilePicture(FormFile uploadedFile)
{
    // Generate path to save the uploaded file at
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";  

    // Save the file
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

これは非常に基本的なアップロード機能であり、パストラバーサルに対して脆弱である。 

アプリケーションの正確な実装に依存しますが、攻撃者は別のページ/スクリプト(.asp、.aspx、.php ファイルを考えて下さい)をアップロードすることが可能です。また、既存のファイルを上書きすることも可能です。 

問題1 - 外部データストアではなくローカルディスクに保存する

クラウドサービスの利用が一般的になるにつれ、アプリケーションはコンテナで提供され、高可用性セットアップが標準となり、アップロードされたファイルをアプリケーションのローカルディスクに書き込む習慣は、基本的に何としても避けるべきである。 

ファイルは、可能な限り中央のストレージ(ブロックストレージ、またはデータベース)にアップロードすべきである。この場合、セキュリティの脆弱性のクラス全体を回避することができる。 

問題2 - エクステンションが検証されない 

ファイルアップロードの脆弱性が悪用される多くの場合、特定の拡張子を持つファイルをアップロードする能力に依存している。そのため、アップロード可能なファイルの拡張子の「許可リスト」を利用することを強くお勧めします。 

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

アップロードのコンテントタイプを検証することも魅力的かもしれませんが、特定のファイルに使用されるコンテントタイプがオペレーティングシステム間で異なる可能性があることを考えると、そうすることは非常にもろくなります。また、コンテンツタイプは純粋に拡張子からのマッピングであるため、実際にはファイル自体について何も教えてくれません。 

問題3 - パスのトラバーサルを防止できない

ファイルアップロードのもう一つの一般的な問題は、パストラバーサルに対する脆弱性です。これはそれ自体で完結することなので、ここで要約するよりも、パストラバーサルに関するガイドラインの全文をご覧ください。

その他の例

以下に、安全なファイルアップロードと安全でないファイルアップロードの例を示します。 

C# - 安全ではない

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // Generate path to save the uploaded file at
    var path = $"./uploads/avatars/{request.User.Id}/{uploadedFile.FileName}";

    // Save the file
    var localFile = File.OpenWrite(path);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    UserProfile.UpdateUserProfilePicture(request.User, path)

    return path;
}

C# - セキュア

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

public string UploadProfilePicture(IFormFile uploadedFile)
{
    // NOTE: The best option is to avoid saving files to the local disk.
    var basePath = Path.GetFullPath("./uploads/avatars/");

    // Prevent path traversal by not utilizing the provided file name. Also needed to avoid filename conflicts.
    var newFileName = GenerateFileName(uploadedFile.FileName);

    // Generate path to save the uploaded file at
    var canonicalPath = Path.Combine(basePath, newFileName);

    // Ensure that we did not accidentally save to a folder outside of the base folder
    if(!canonicalPath.StartsWith(basePath))
    {
        return BadRequest("Attempted to save file outside of upload folder");
    }

    // Ensure only allowed extensions are saved
    if(!IsFileAllowedExtension(uploadedAllowedExtensions))
    {
        return BadRequest("Extension is not allowed");
    }

    // Save the file
    var localFile = File.OpenWrite(canonicalPath);
    localFile.Write(uploadedFile.ReadToEnd());
    localFile.Flush();
    localFile.Close();

    // Update the profile picture
    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<>("Upload error", 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();
           // Make sure that the extension is an allowed type
            if(!IsAllowedExtension(file.getOriginalFilename()) {
                return new ResponseEntity<>("Extension not allowed", HttpStatus.FORBIDDEN);
            }

            // Make sure that the file is not saved outside of the upload root
           if(!uploadPath.toString().startsWith(baseFolder.toString()))            {
                return new ResponseEntity<>("Files are not allowed to be saved outside of the base folder.", HttpStatus.FORBIDDEN);
           }

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

        } catch (Exception e) {
            return new ResponseEntity<>("Upload error", 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 - Secure

@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 "This extension is not allowed"

# 保存先のファイルがベース外にないことを確認
if not savedFilePath.startsWith(baseFolder):
return "Attempted to save file outside of base folder"

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")