2026年6月29日

2026年6月29日

WordPressのアップロード先をwebroot外に移してファイル実行を防ぐ設定と注意点

WordPressのアップロード先(uploads)をwebroot(ドキュメントルート)の外に移すと、アップロードされたファイルにURLで直接アクセスされたり、紛れ込んだPHPファイルが実行されたりする経路を減らせます。ただし、画像などのメディアはブラウザから表示できる必要があるため、単に場所を移すだけでは成り立ちません。この記事では、upload_dir フィルターでの保存先の変更、Webサーバーの設定でメディアだけを配信してPHPを動かさない方法、会員向けファイルなどをPHP経由で権限を確認して渡す方法を、それぞれの注意点とあわせて説明します。

先に整理:目的によって方法が変わる

「webroot外に移す」で守りたいものは、主に次の2つです。

  1. アップロードされたファイルをPHPとして実行させないこと
  2. 一部のファイル(請求書、会員限定の資料、フォームの添付ファイルなど)を、URLを知っている人に直接ダウンロードさせないこと

1が目的なら、uploadsの場所はそのままでも、Webサーバーの設定でuploads配下のPHPを動かさないようにするのが最も手軽で、多くのサイトではこれで足ります。場所まで移すのは、その上でさらに「PHPを処理する設定の範囲からアップロード先を切り離したい」場合です。2が目的なら、公開メディアとは別に非公開の保存先を作り、PHPで権限を確認してから渡す仕組みが必要です。

どちらの作業も、メディアのURLやバックアップの範囲に影響します。作業前にファイルとデータベースのバックアップを取り、ステージング環境で試してから本番に反映してください。

UPLOADS定数とupload_dirフィルターの違い

WordPressのアップロード先を変える方法は2つあります。

wp-config.php で定義する UPLOADS 定数は、WordPressのインストール先(ABSPATH)からの相対パスで指定します。URLもそのパスから組み立てられるため、webrootの外を指すようには作られていません。アップロード先のフォルダ名を変える用途向けです。

upload_dir フィルターは、wp_upload_dir() が返す配列(path、url、subdir、basedir、baseurl、error)を書き換えられます。保存先のディレクトリ(basedir と path)と、表示に使うURL(baseurl と url)を別々に指定できるため、webroot外に保存しつつ、URLは従来どおり /wp-content/uploads/ のままにする、という設定が可能です。以降はこちらを使います。

方法1:メディアをwebroot外に置き、Webサーバーで配信する

WordPress側:保存先だけを変える

テーマの更新で消えないよう、mu-plugin(wp-content/mu-plugins/ に置くPHPファイル)として追加します。

<?php
/**
 * Plugin Name: Uploads Outside Webroot
 * Description: メディアの保存先をwebroot外に変更する(URLは変更しない)
 */

add_filter( 'upload_dir', function ( $dirs ) {
	$base = '/srv/wp-uploads/example.com'; // webroot外の保存先(環境に合わせて変更)

	$dirs['basedir'] = $base;
	$dirs['path']    = $base . $dirs['subdir'];
	// url と baseurl は変更しない(/wp-content/uploads/ のまま)

	return $dirs;
} );

既存のメディアは、データベースに「uploadsからの相対パス」(例:2026/10/sample.jpg)で記録されています。そのため、既存のフォルダ構成を保ったまま新しい保存先へコピーすれば、データベースを書き換えずに参照先が切り替わります。コピーには rsync -a などを使い、所有者と権限がWebサーバーやPHPの実行ユーザーで書き込める設定になっているかを確認します。PHPの open_basedir を使っている環境では、新しい保存先をその許可範囲に加える必要があります。元の wp-content/uploads に残ったファイルは、表示の確認が終わるまで残しておき、問題がないと分かってから退避します。

Webサーバー側:URLを保存先に対応させ、PHPを動かさない

URLは /wp-content/uploads/ のままなので、Webサーバーでそのパスを新しい保存先に対応付けます。nginxの例です。

location ^~ /wp-content/uploads/ {
    alias /srv/wp-uploads/example.com/;

    # 万一PHPなどが置かれても実行・配信しない
    location ~* \.(php|phtml|phar|pl|py|cgi|sh)$ {
        deny all;
    }
}

^~ を付けると、このパスにはPHPを処理する正規表現のlocation(\.php$ でFastCGIに渡す設定)が適用されなくなります。その上で、内側のlocationで実行可能な拡張子へのアクセスを拒否しています。

Apacheの場合は、仮想ホストの設定で次のように書きます。

Alias /wp-content/uploads /srv/wp-uploads/example.com

<Directory /srv/wp-uploads/example.com>
    Options -Indexes -ExecCGI
    AllowOverride None
    Require all granted

    <FilesMatch "\.(php|phtml|phar|pl|py|cgi|sh)$">
        Require all denied
    </FilesMatch>
</Directory>

AllowOverride None にしておくと、アップロード先に .htaccess が置かれても設定を上書きされません。PHP-FPMを SetHandler で呼び出している構成では、その指定がこのディレクトリに及ばないか(FilesMatch が仮想ホスト全体にかかっていないか)も確認してください。

設定を変えたら、構文チェック(nginx -t や apachectl configtest)をしてから再読み込みします。

確認方法

  • 新しく画像をアップロードし、ファイルが /srv/wp-uploads/example.com/年/月/ に作られ、記事上で表示されるか
  • 既存の画像が表示されるか、サムネイルの各サイズが表示されるか
  • テスト用の無害なPHPファイル(<?php echo 'test'; だけのもの)を保存先に置き、/wp-content/uploads/test.php にアクセスして403になり、ソースも実行結果も表示されないか(確認後は削除)

方法2:非公開ファイルをPHP経由で配信する

会員向けの資料などは、URLを持たない保存先に置き、ダウンロードのたびにPHPで権限を確認してから渡します。

保存:その処理の間だけ保存先を切り替える

公開メディアと混ざらないよう、フォームなどからの保存処理の間だけ upload_dir フィルターを追加します。

function myprefix_private_dir( $dirs ) {
	$base = '/srv/wp-private/example.com';
	$dirs['basedir'] = $base;
	$dirs['path']    = $base;
	$dirs['subdir']  = '';
	$dirs['baseurl'] = '';
	$dirs['url']     = '';
	return $dirs;
}

function myprefix_save_private_file( array $file ) {
	require_once ABSPATH . 'wp-admin/includes/file.php';

	add_filter( 'upload_dir', 'myprefix_private_dir' );
	$result = wp_handle_upload(
		$file,
		array(
			'test_form' => false,
			'mimes'     => array( 'pdf' => 'application/pdf' ), // 許可する形式を絞る
		)
	);
	remove_filter( 'upload_dir', 'myprefix_private_dir' );

	return $result; // 失敗時は 'error' キーを含む配列
}

この関数を呼ぶ側では、nonceの確認(check_admin_referer() など)と current_user_can() による権限確認を先に行います。保存したファイル名はメディアライブラリには載らないため、どのユーザーのファイルかを独自に記録しておきます。

配信:権限を確認してから渡す

add_action( 'admin_post_myprefix_download', 'myprefix_download' );

function myprefix_download() {
	if ( ! current_user_can( 'read' ) ) { // 実際の権限・所有者の判定に置き換える
		wp_die( 'この操作は許可されていません。', '', array( 'response' => 403 ) );
	}
	check_admin_referer( 'myprefix_download' );

	$base = '/srv/wp-private/example.com';
	$name = isset( $_GET['file'] ) ? sanitize_file_name( wp_unslash( $_GET['file'] ) ) : '';
	$path = realpath( $base . '/' . $name );

	if ( ! $name || ! $path || 0 !== strpos( $path, $base . '/' ) || ! is_file( $path ) ) {
		wp_die( 'ファイルが見つかりません。', '', array( 'response' => 404 ) );
	}

	$type = wp_check_filetype( $path );
	nocache_headers();
	header( 'Content-Type: ' . ( $type['type'] ? $type['type'] : 'application/octet-stream' ) );
	header( 'Content-Disposition: attachment; filename="' . basename( $path ) . '"' );
	header( 'X-Content-Type-Options: nosniff' );

	// nginx の場合(下の internal 設定とセット)
	header( 'X-Accel-Redirect: /protected-files/' . rawurlencode( basename( $path ) ) );
	exit;
}

ダウンロード用のリンクは wp_nonce_url( admin_url( 'admin-post.php?action=myprefix_download&file=sample.pdf' ), 'myprefix_download' ) のように作ります。realpath() と先頭一致の確認は、../ を使って保存先の外のファイルを指定される攻撃を防ぐためのものです。

X-Accel-Redirect は、PHPは権限の確認だけを行い、ファイルの送信はnginxに任せる仕組みです。大きなファイルでもPHPのメモリや実行時間を使いません。nginx側には外から直接は開けない internal なlocationを用意します。

location /protected-files/ {
    internal;
    alias /srv/wp-private/example.com/;
}

Apacheでは、mod_xsendfileを導入して XSendFile On と XSendFilePath /srv/wp-private/example.com を設定し、PHPから X-Sendfile ヘッダーでフルパスを渡すのが同じ考え方です。どちらも使えない環境では readfile() で送ることもできますが、大きなファイルではタイムアウトに注意してください。

つまずきやすい点

  • バックアップから漏れる:多くのバックアッププラグインは wp-content 配下を対象にしています。webroot外に移したディレクトリは、バックアップの対象に加えないと保存されません。設定後に復元テストまで行うと安心です。
  • 一部のプラグインが動かない:wp_upload_dir() を使わずに WP_CONTENT_DIR . '/uploads' のようなパスを直接組み立てているプラグインは、古い場所を見に行きます。画像最適化、バックアップ、移行系のプラグインは特に動作を確認してください。
  • CDNやオフロードとの関係:メディアを外部ストレージに置くプラグインを使っている場合は、そちらの仕組みと保存先の変更が競合しないかを確認します。
  • 共用サーバーでは難しいことが多い:webroot外への書き込みやWebサーバー設定の変更ができない環境では、方法1は使えません。その場合はuploads配下でPHPの実行を止める設定を優先します。

設定の項目名やサーバーの標準構成は、利用しているサーバー会社やOSのバージョンによって異なります(2026年10月現在)。サーバー会社のドキュメントとあわせて確認してください。

まとめ

  • 目的が「アップロードされたPHPを実行させない」ことなら、まずuploads配下でPHPを動かさない設定を行い、さらに分離したい場合に保存先の移動を検討します。
  • UPLOADS 定数ではwebroot外を指定できないため、upload_dir フィルターで保存先(basedir・path)だけを変え、URLはWebサーバーの alias で対応させます。
  • 非公開ファイルはURLのない保存先に置き、nonceと権限を確認したうえで X-Accel-Redirect や X-Sendfile で配信します。
  • バックアップの対象範囲、プラグインの互換性、既存メディアの表示を確認し、ステージングで試してから本番に反映します。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲