公開日 2026-09-29

PHPでアップロードファイルをdocroot外に保存し直リンクを塞ぐ(fail-closed設計)

PHPでアップロードファイルをdocroot外に保存し、URL直打ちでの漏洩を防ぐ最小構成。拡張子なしランダム名、公開領域なら書かないfail-closed、取り出し時の許可リスト検証をテスト付きで示す。

目次

  1. 動作を確認した構成
  2. なぜ docroot の下だと危ないか
  3. 保存クラス
  4. コードのポイント
  5. 直リンクが塞がれることを確認する
  6. テストで振る舞いを固定する
  7. コードのポイント
  8. まとめ

対象読者: PHP でアップロードを受け取り、他人に URL を推測されても直接ダウンロードされたくないファイル(請求書、本人確認書類など)を扱う方。フレームワークのストレージヘルパーに頼らず、素の PHP で保存層を組む場面を想定します。

アップロードしたファイルを公開ディレクトリ(docroot)の下に置くと、保存名を知られただけで Web サーバーがそのまま返してしまいます。認可チェックを通す前にファイルが渡る、という穴です。防ぎ方は、ファイルを docroot の外に保存し、取り出しは必ず PHP のコードを1枚通す形にすること。この記事では LocalFileStorage という保存クラスを題材に、公開領域内への配置を生成時に拒む fail-closed、拡張子を持たないランダム名、取り出し時の許可リスト検証の3点をコードで示します。

誰がダウンロードしてよいか(認証・認可)と、アップロード時の内容検証(サイズ・種別のバリデーション)は扱いません。ここで組むのは「保存したファイルが URL 直打ちで漏れない」ところまで。後述の store() は検証済みのパスを受け取る前提とし、渡すファイルが本当に HTTP アップロード由来かは呼び出し側の責務にします。素の受け取りなら、is_uploaded_file() で確認したうえで move_uploaded_file() が置いたパスを渡してください(copy() 自体はアップロードの真正性を見ません)。アップロード検証の実装は Laravelでファイルアップロードを扱う(Storage + validation) が詳しい。借りるのはサイズ・種別の検証部分だけで、機密ファイルの保存先に同記事の public disk は使いません。

動作を確認した構成

コードを動かして確認したのは、次の構成です。

  • PHP 8.4(テストは PHPUnit 12、直リンクの確認は php -S)
  • Composer プロジェクト(App\ → src/ の PSR-4 オートロード)
  • 保存先ディレクトリ storage/(書き込み可能・docroot 外)と、公開ディレクトリ public/

掲載コードは、この Composer 構成に置いて vendor/autoload.php を読み込む前提です。PHP のローカル環境がまだない場合の作り方は Windows 11で始めるPHPローカル開発環境(WSL2 + Docker + PostgreSQL) にまとめてある。なおリンク先は php:8.5-apache の構成なので、本記事を再現するときは PHP 8.4・Composer・PHPUnit 12 を別途用意してください。

なぜ docroot の下だと危ないか

Web サーバーの公開ディレクトリ(public/ など)に置いたファイルは、パスさえ分かれば PHP を通さずに返されます。public/uploads/invoice_001.pdf のような連番や、元のファイル名をそのまま使う保存だと、URL を1つ変えるだけで別人の書類に届く。拡張子も問題で、.php や .phtml で終わる名前を公開領域に書けると、アップロードされた中身がスクリプトとして実行される余地が残ります。

置き場所を docroot の外へ移すと、この直リンクの経路が閉じる。ただし「docroot の外に置けば必ず届かない」と言い切れるわけではありません。公開ディレクトリだけを配信する構成なら、その外のファイルは URL からは返らない。一方で本番の Apache や nginx では、Alias / location で外のディレクトリを URL に割り当てる、FollowSymLinks で公開領域から張ったシンボリックリンクを辿る、X-Sendfile / X-Accel-Redirect で任意パスを配信する、といった設定次第で docroot の外も公開されえます。前提になるのは、保存先を配信対象(alias・location・シンボリックリンク)に含めない Web サーバー設定です。そのうえで取り出しは、認可を確認したうえで PHP がファイルを読んで返す1枚を通します。

保存クラス

まず、保存層の失敗を表す例外です。src/Storage/StorageException.php に置きます。

<?php
declare(strict_types=1);

namespace App\Storage;

use RuntimeException;

final class StorageException extends RuntimeException
{
}

本体の LocalFileStorage を src/Storage/LocalFileStorage.php に置きます。保存先(docroot 外)と公開ディレクトリの2つを受け取り、store() で保存し、path() で取り出す形です。

<?php
declare(strict_types=1);

namespace App\Storage;

final class LocalFileStorage
{
    private readonly string $baseDir;

    public function __construct(string $baseDir, string $publicDir)
    {
        $base = realpath($baseDir);
        $public = realpath($publicDir);

        if ($base === false || !is_dir($base)) {
            throw new StorageException('Storage base directory does not exist.');
        }
        if ($public === false || !is_dir($public)) {
            throw new StorageException('Public directory does not exist.');
        }

        $publicWithSep = $public . DIRECTORY_SEPARATOR;
        if ($base === $public || str_starts_with($base . DIRECTORY_SEPARATOR, $publicWithSep)) {
            throw new StorageException('Refusing to use a base directory inside the public directory.');
        }

        $this->baseDir = $base;
    }

    public function store(string $sourcePath): string
    {
        $id = bin2hex(random_bytes(16));
        $dest = $this->baseDir . DIRECTORY_SEPARATOR . $id;

        if (!copy($sourcePath, $dest)) {
            throw new StorageException('Failed to store the uploaded file.');
        }

        return $id;
    }

    public function path(string $id): string
    {
        if (preg_match('/\A[0-9a-f]{32}\z/', $id) !== 1) {
            throw new StorageException('Invalid storage id.');
        }

        $path = $this->baseDir . DIRECTORY_SEPARATOR . $id;
        if (!is_file($path)) {
            throw new StorageException('Stored file not found.');
        }

        return $path;
    }
}

コードのポイント

① 保存名は拡張子なしのランダム hex にする

        $id = bin2hex(random_bytes(16));

random_bytes(16) の16バイトを16進にした32文字を保存名にします。元のファイル名も拡張子も使いません。連番や元の名前を残さないので、他の保存名を列挙する足がかりが消える。拡張子を付けないのは、仮に置き場所を間違えても .php として実行される名前を作らないためです。元のファイル名は、DB の別カラムなどに表示用として持ち、ファイルシステム上の名前とは切り離しておきます。

128ビットのランダム名が消すのは連番・元名からの列挙です。推測は難しくなるものの、これは認可の代わりにはならない。名前がログ・URL・メールなどから漏れれば、その名前での取り出し要求自体は届くので、取り出しルート側の認可と併せて守ります。

② コンストラクタで公開領域への配置を弾く(fail-closed)

        $publicWithSep = $public . DIRECTORY_SEPARATOR;
        if ($base === $public || str_starts_with($base . DIRECTORY_SEPARATOR, $publicWithSep)) {
            throw new StorageException('Refusing to use a base directory inside the public directory.');
        }

コンストラクタで、保存先 $baseDir を realpath() で正規化し、公開ディレクトリと同一かその配下なら例外にします。設定ミスで保存先が public/ の下を指していたら、そもそもインスタンスを作らせない。生成時に倒すので、store()(書き込み)だけでなく path()(読み出し)も同じ設定ミスで閉じます。危ないときに黙って続けず、確信が持てないなら止めるのが fail-closed の考え方です。str_starts_with() の比較で両方の末尾に区切り文字を付けているのは、public と public_data のような接頭辞違いを配下と誤判定しないためです。

もっとも、この判定が見るのは realpath() 解決後のパス関係だけ。公開ディレクトリの内側に張ったシンボリックリンクが保存先の実体(公開領域の外)を指す配置では、realpath() が実体側へ解決するため、この判定は通ってしまう。その実体が URL から届くかどうかは Web サーバーの設定次第なので、後述の「直リンクが塞がれることを確認する」で別途塞ぎます。

③ 取り出しは許可リストの正規表現で検証する

        if (preg_match('/\A[0-9a-f]{32}\z/', $id) !== 1) {
            throw new StorageException('Invalid storage id.');
        }

path() は、受け取った $id が「16進32文字ちょうど」でなければ、ファイルを探す前に例外にします。これは basename() より強い検証です。basename() はパスの末尾を返す変換であって検証ではなく、basename('../../etc/passwd') は区切りを落として passwd を返すだけで、その結果が許可された名前かは判定しません。一方この正規表現は許可リストなので、../ を含む文字列・絶対パス・.php 付き・大文字・長さ違い・ヌルバイト混じりを、すべて弾きます。ここで通るのは store() が発行する形式(16進32文字)と一致するものだけです。実際に発行済みかどうかまでは照合しないので、その保証が要るなら取り出しルートで DB のレコードや所有者情報と突き合わせます。

直リンクが塞がれることを確認する

保存したファイルが URL 直打ちで取れないことを、php -S で確かめる。公開ディレクトリ public/ を起点にサーバーを起動し、保存先 storage/ はその外に置きます。取り出し用のルートとして public/index.php を作成する。

<?php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use App\Storage\LocalFileStorage;
use App\Storage\StorageException;

$storage = new LocalFileStorage(__DIR__ . '/../storage', __DIR__);

$id = (string) ($_GET['id'] ?? '');

try {
    $path = $storage->path($id);
} catch (StorageException $e) {
    http_response_code(404);
    header('Content-Type: text/plain');
    echo "not found\n";
    return;
}

header('Content-Type: application/octet-stream');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
header('Content-Length: ' . filesize($path));
readfile($path);

storage/ に1つ保存し(保存名は store() が返した hex)、対照として public/note.txt を直接置いてから、公開ディレクトリを起点にサーバーを起動します。

php -S 0.0.0.0:8080 -t public

保存名をそのまま URL に打っても、storage/ は公開ディレクトリの外なので届きません。public/ に直接置いたファイルとの差が出る。

GET /13bf9ea4bcfb3099fb5c48d4b9a37e28              -> 404
GET /../storage/13bf9ea4bcfb3099fb5c48d4b9a37e28   -> 404
GET /note.txt                                      -> 200

取り出しルート(この例では認可を省略)経由なら、正規の id は返り、不正な id は許可リストで弾かれて 404 になります。

GET /index.php?id=13bf9ea4bcfb3099fb5c48d4b9a37e28   -> 200  (中身が返る)
GET /index.php?id=../../etc/passwd                   -> 404

同じ 404 でも、止めている主体が違う。/../storage/... が 404 なのは php -S がリクエストパスを正規化して docroot の外へのアクセスを弾いたためで、LocalFileStorage のコードは通っていません。一方 index.php?id=../../etc/passwd の 404 は、path() の許可リスト検証が弾いた結果です。前者は Web サーバー(SAPI)側、後者はアプリ側の防御であり、直リンクを止める前者がどう振る舞うかは配信構成によって変わる。

保存名を知っていても直リンクでは取れず、取り出しは PHP のルートを1枚必ず通ります。この記事のルートは認可を省いていますが、実際にはここで「このユーザーがこのファイルを取ってよいか」を確認します。レスポンスに付けた Cache-Control: no-store は共有端末やキャッシュに機密ファイルを残さないため、X-Content-Type-Options: nosniff は MIME 推測による誤実行を抑えるためです。大きなファイルを返すなら readfile() の代わりに逐次配信へ替えます(→ PHPで大容量ファイルを安全にストリーミング配信する)。

テストで振る舞いを固定する

保存層の性質を PHPUnit で固定します。tests/LocalFileStorageTest.php です。一時ディレクトリに private/(保存先)と public/ を作り、実際のファイル操作で確かめます。

<?php
declare(strict_types=1);

namespace Tests;

use App\Storage\LocalFileStorage;
use App\Storage\StorageException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class LocalFileStorageTest extends TestCase
{
    private string $root;

    private string $baseDir;

    private string $publicDir;

    private LocalFileStorage $storage;

    protected function setUp(): void
    {
        $this->root = sys_get_temp_dir() . '/storage-demo-' . bin2hex(random_bytes(4));
        $this->baseDir = $this->root . '/private';
        $this->publicDir = $this->root . '/public';
        mkdir($this->baseDir, 0777, true);
        mkdir($this->publicDir, 0777, true);

        $this->storage = new LocalFileStorage($this->baseDir, $this->publicDir);
    }

    protected function tearDown(): void
    {
        array_map('unlink', glob($this->baseDir . '/*') ?: []);
        @rmdir($this->baseDir);
        @rmdir($this->publicDir);
        @rmdir($this->root);
    }

    private function sourceFile(string $content): string
    {
        $tmp = $this->root . '/upload-' . bin2hex(random_bytes(4));
        file_put_contents($tmp, $content);
        return $tmp;
    }

    public function testStoreReturns32HexId(): void
    {
        $id = $this->storage->store($this->sourceFile('secret'));

        self::assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $id);
    }

    public function testStoredFileLivesOutsidePublicWithoutExtension(): void
    {
        $id = $this->storage->store($this->sourceFile('secret'));
        $path = $this->storage->path($id);

        self::assertFileExists($path);
        self::assertStringStartsWith(realpath($this->baseDir), $path);
        self::assertStringNotContainsString(realpath($this->publicDir), $path);
        self::assertSame($id, basename($path));
    }

    public function testContentRoundTrips(): void
    {
        $id = $this->storage->store($this->sourceFile('the secret bytes'));

        self::assertSame('the secret bytes', file_get_contents($this->storage->path($id)));
    }

    /**
     * @return array<string, array{string}>
     */
    public static function maliciousIds(): array
    {
        return [
            'traversal'         => ['../../etc/passwd'],
            'encoded-traversal' => ['..%2f..%2fpasswd'],
            'absolute'          => ['/etc/passwd'],
            'with-extension'    => ['aabbccddeeff00112233445566778899.php'],
            'uppercase'         => ['AABBCCDDEEFF00112233445566778899'],
            'too-short'         => ['aabbccdd'],
            'null-byte'         => ["aabbccddeeff00112233445566778899\0.png"],
            'empty'             => [''],
        ];
    }

    #[DataProvider('maliciousIds')]
    public function testPathRejectsMaliciousIds(string $id): void
    {
        $this->expectException(StorageException::class);
        $this->storage->path($id);
    }

    public function testConstructorFailsClosedWhenBaseInsidePublic(): void
    {
        $inside = $this->publicDir . '/uploads';
        mkdir($inside, 0777, true);

        $this->expectException(StorageException::class);
        new LocalFileStorage($inside, $this->publicDir);
    }
}

コードのポイント

① 弾きたい id を1つずつ並べて固定する

        return [
            'traversal'         => ['../../etc/passwd'],
            'encoded-traversal' => ['..%2f..%2fpasswd'],
            'absolute'          => ['/etc/passwd'],

maliciousIds() に、取り出しで弾きたい形を並べます。パストラバーサル・絶対パス・拡張子付き・大文字・長さ違い・ヌルバイト・空文字を、それぞれ path() が例外にすることを確認する。「これらを弾く」を実際に投げて固定しておくと、後で検証を緩めたときにテストが落ちます。testConstructorFailsClosedWhenBaseInsidePublic() のほうは、保存先を public/ の下にした構成でコンストラクタが例外になること(fail-closed)を押さえるものです。生成時に倒れるので、store() も path() も走りません。

#[DataProvider] はテストへデータを渡す属性で、PHPUnit 12 では @dataProvider アノテーションではなくこの形を使う。次のコマンドでテストを実行します。

docker compose exec app ./vendor/bin/phpunit

実行結果は次のとおりです。

PHPUnit 12.5.31 by Sebastian Bergmann and contributors.

............                                                      12 / 12 (100%)

Time: 00:00.1xx, Memory: 14.00 MB

OK (12 tests, 15 assertions)

まとめ

アップロードファイルを直リンクから守る3点を、コードで示しました。

  • 保存先は docroot の外に置く。公開領域内への配置はコンストラクタで弾き、設定ミスでは生成時に止まる(fail-closed)
  • 保存名は拡張子なしのランダム hex にし、元のファイル名や連番を残さない
  • 取り出しは basename() ではなく許可リストの正規表現(16進32文字ちょうど)で検証し、store() が発行する形式だけを通す

このクラスが守るのは「保存名を知られても直リンクで取れない」ところまで。ただしそれが成り立つのは、保存先を alias・location・シンボリックリンクで配信対象に含めない Web サーバー設定のもとです。誰が取ってよいかの認可と、返し方(大きなファイルの逐次配信)は、取り出しルートの側で組み合わせます。