危険な形式のファイルを無制限にアップロードできる

アップロード形式と保存・処理方法を制限する

説明

アップロード形式と、その後の保存・処理方法を十分に制限しないと、ファイルがサーバーや利用者の環境で実行されたり、危険なコンテンツとして解釈されたりする可能性があります。

想定される影響

JSP、ASP、PHPなどとしてサーバーやアプリケーションが解釈すると、アップロードファイルからコードが実行される可能性があります。ファイルシステムでそのファイルに実行権限を与えていなくても、インタープリターが読み取って実行する場合があります。

対処方法

  1. 元のファイル名やクライアントのMIMEタイプを信頼せず、サーバーで名前を生成し、サイズと実際の内容を検証してください。
  2. Webルート外に保存し、書き込み権限を制限してください。アップロードをサーバーコードとして実行しないよう、Webサーバーや後続の処理を構成します。ディレクトリの検索権限と、ファイルの実行権限は区別してください。
  3. 正規化した保存先をアップロードルートと構成要素単位で比較し、新規ファイルだけをアトミックに作成してください。
  4. 後続処理が必要なら、マルウェア検査と隔離された処理環境を使ってください。

例

変更前

java
@PostMapping("/upload")
public String handleFileUpload(@RequestParam("file") MultipartFile file) throws IOException {
    // 利用者が指定したファイル名で保存
    String fileName = file.getOriginalFilename();
    File destinationFile = new File("/uploads/" + fileName);
    file.transferTo(destinationFile);
    return "uploadSuccess";
}

変更後

UTF-8テキストだけを保存するJava 11以降の抜粋です。アップロードルートと親パスは信頼する管理者だけが変更できるようにし、Springのmultipartリクエストサイズも制限してください。テキスト形式の検証はマルウェア検査ではありません。保存した内容を実行したり、そのままHTMLとして表示したりしないでください。

java
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.UUID;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
public final class SafeUploadController {
    private static final Path UPLOAD_ROOT =
            Path.of("/var/lib/myapp/uploads").toAbsolutePath().normalize();
    private static final long MAX_TEXT_SIZE = 1024 * 1024;

    @PostMapping("/upload")
    public String handleFileUpload(@RequestParam("file") MultipartFile file) throws IOException {
        if (file.isEmpty() || file.getSize() > MAX_TEXT_SIZE) {
            throw new IOException("Invalid file size");
        }

        byte[] content = file.getBytes();
        String text;
        try {
            text = StandardCharsets.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(content))
                    .toString();
        } catch (CharacterCodingException e) {
            throw new IOException("The upload is not valid UTF-8 text", e);
        }
        if (text.codePoints().anyMatch(c -> c == 0
                || (Character.isISOControl(c) && c != '\n' && c != '\r' && c != '\t'))) {
            throw new IOException("Invalid text content");
        }

        String storedName = UUID.randomUUID() + ".txt";
        Path target = UPLOAD_ROOT.resolve(storedName).normalize();
        if (!target.startsWith(UPLOAD_ROOT)) {
            throw new IOException("Invalid upload destination");
        }

        // Webルート外にUPLOAD_ROOTを作成し、アップロードをコードとして実行させないでください。
        Files.write(target, content, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
        return "uploadSuccess";
    }
}

変更前は利用者が指定した名前で保存し、意図しないパスへの書き込みや、後でスクリプトとして解釈される危険があります。変更後はUUIDを基に.txt名を作り、サイズとUTF-8の実際のバイト列を検証します。保存先を確認したうえでCREATE_NEWを使い、新規ファイルだけを保存します。

関連CVE

参考資料