Apache Web サーバのセキュリティ設定と管理ガイド

【概要】 Apache Web サーバのセキュリティ設定と管理の手順を示す。パッケージ更新、バージョン情報の隠蔽、OCSP Stapling、HSTS、X-Frame-Options、X-Content-Type-Options、CSP、HTTP から HTTPS へのリダイレクトを解説する。参考情報としてサイトマップ生成プログラムも示す。

Apache Web サーバのメンテナンスとセキュリティ設定

パッケージのアップデートと Apache の更新

サーバを最新の状態に保つために、定期的なパッケージのアップデートを行う。Ubuntu 24.04 LTS では Apache 2.4.58 をベースとするパッケージが提供され、セキュリティ修正がバックポートされる。そのため、apache2 -v が表示するバージョン番号は 2.4.58 のままでも、修正は適用されている。以下の手順でシステム全体のパッケージと Apache を更新する。

端末を開き,次のように操作を実行する(端末を開くには,右クリックメニューが便利である)。

  1. パッケージ情報の更新:
    # パッケージリストの情報を更新
    sudo apt update
  2. システム全体のパッケージをアップグレード:
    -V オプションで更新されるパッケージのバージョンを表示する)
    sudo apt -yV upgrade
  3. Apache 設定ファイルのバックアップ:
    (更新前に設定ファイルをバックアップしておくと、問題発生時に復元できる。ファイル名には実行日時が含まれる)
    cd /etc
    sudo tar -cvpf /var/tmp/apache.$(date +%Y%m%d%H%M%S).tar ./apache2
  4. Apache のバージョン確認:
    apache2 -v
  5. Apache の再起動:
    (更新内容を反映させるために再起動する)
    sudo systemctl restart apache2
  6. 設定変更後の確認:
    再起動後、Web サイトが表示・動作することを確認する。

Apache 2 サーバのバージョン情報を公開しない

サーバのバージョン情報を公開すると、既知の脆弱性を持つバージョンかどうかを外部から判別できる。次の設定により、Apache 2 サーバのバージョン情報を公開しないようにする。

ServerTokens はサーバ全体(server config)でのみ指定できるため、VirtualHost の中には書かない。Ubuntu では /etc/apache2/conf-available/security.conf が用意され、既定で有効になっているので、このファイルを編集する。

  1. 設定ファイル /etc/apache2/conf-available/security.conf を編集し、次の2行の値を変更する(同名の行が既にある場合は書き換える)。
    • ServerTokens Prod: HTTP レスポンスヘッダーの ServerApache とだけ表示し、バージョン番号や OS 情報を出さない。
    • ServerSignature Off: Apache が生成するエラーページ(例:404 Not Found)のサーバ情報を非表示にする。
    ServerTokens Prod
    ServerSignature Off
    
  2. 設定ファイルの構文テストを実行する。エラーメッセージが出ないことを確認する。
    sudo apache2ctl configtest
    
  3. Apache を再起動して設定を反映させる。
    sudo systemctl restart apache2
    
  4. 確認方法: curl -I https://yourdomain.com コマンドやブラウザの開発者ツール(ネットワークタブ)でレスポンスヘッダーを確認し、Server ヘッダーが Apache のみになっていること、エラーページにサーバ情報が表示されないことを確認する。

Apache2 で OCSP Stapling を有効にする

OCSP (Online Certificate Status Protocol) Stapling は、サーバが認証局から取得した証明書の失効情報(OCSP レスポンス)を TLS ハンドシェイク時にクライアントへ渡す仕組みである。クライアントが認証局へ問い合わせる必要がなくなる。

Let's Encrypt は 2025 年 8 月 6 日に OCSP レスポンダを停止し、失効情報の配布を CRL に移行した。Let's Encrypt の証明書には OCSP の URL が含まれないため、この設定を書いても Stapling は動作しない。以下は、OCSP を提供している認証局の証明書を使う場合の設定である。

SSLStaplingCache はサーバ全体(server config)でのみ指定できるため、/etc/apache2/mods-available/ssl.conf のような VirtualHost の外に書く。SSLUseStaplingVirtualHost の中に書ける。

  1. SSL モジュールを有効にする。
    sudo a2enmod ssl
    
  2. VirtualHost の外(/etc/apache2/mods-available/ssl.conf など)に、次の1行を追加する。
    SSLStaplingCache shmcb:logs/stapling-cache(150000)
    

    shmcb は共有メモリキャッシュ、logs/stapling-cache はキャッシュのファイル名、(150000) はキャッシュサイズ(バイト単位)である。

  3. SSL/TLS 設定を行っている VirtualHost セクション(例: /etc/apache2/sites-available/your-site-ssl.conf<VirtualHost *:443>)内に、次の1行を追加する。
    SSLUseStapling on
    
  4. 設定ファイルの構文テストを実行する。
    sudo apache2ctl configtest
    
  5. Apache を再起動する。
    sudo systemctl restart apache2
    
  6. OCSP Stapling を確認する。「mydomain.com」のところは,自分のドメイン名に変更して実行し、OCSP Response Status: successful と表示されることを確認する。
    openssl s_client -connect mydomain.com:443 -status < /dev/null | grep -A 10 'OCSP Response Data'
    

    OCSP response: no response sent と表示される場合は、証明書に OCSP の URL が含まれていないか、設定が反映されていない。

Apache2 で HTTP Strict Transport Security (HSTS) を設定

HTTP Strict Transport Security (HSTS) は、一度 HTTPS でアクセスしたサイトへは以降必ず HTTPS でアクセスするようブラウザに指示する仕組みである。これにより、HTTP へダウングレードさせる攻撃(SSL ストリッピング)を防ぐ。

この設定は、SSL/TLS を使用するバーチャルホストの設定ファイル(例: /etc/apache2/sites-available/your-site-ssl.conf<VirtualHost *:443>)に記述する。

  1. レスポンスヘッダーを操作するために headers モジュールを有効にする。このモジュールは apache2 パッケージに含まれる。
    sudo a2enmod headers
    
  2. SSL/TLS 設定を行っている VirtualHost セクション内に、次の1行を追加する。以下の例では、サブドメインを含めて約1年間 HTTPS を使用するよう指示する。preload は HSTS プリロードリストへの登録申請を可能にするが、リストからの削除には時間がかかるため、サブドメインを含めた全ページを HTTPS で提供できることを確認してから付ける。
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    
  3. 設定ファイルの構文テストを実行する。
    sudo apache2ctl configtest
    
  4. Apache を再起動する。
    sudo systemctl restart apache2
    
  5. 確認方法: curl -I https://yourdomain.com コマンドやブラウザの開発者ツールでレスポンスヘッダーを確認し、Strict-Transport-Security ヘッダーが設定されていることを確認する。

HTTP 接続から HTTPS への自動リダイレクト設定

HTTP (80番ポート) へのアクセスを HTTPS (443番ポート) へリダイレクトする。HSTS はブラウザが一度 HTTPS でアクセスした後に働くため、初回アクセスに対応するにはこのリダイレクトが必要である。

この設定は、HTTP を使用するバーチャルホストの設定ファイル(例: /etc/apache2/sites-available/your-site.conf<VirtualHost *:80>)に記述する。

  1. rewrite モジュールを有効にする。
    sudo a2enmod rewrite
    
  2. HTTP 設定を行っている VirtualHost セクション内に、次の3行を追加する。
    RewriteEngine On
    RewriteCond %{HTTPS} off
    RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
    

    RewriteEngine On: Rewrite 機能を有効にする。

    RewriteCond %{HTTPS} off: HTTPS 接続でない場合に適用する。

    RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]: 上記条件を満たす場合、パスを保持したままスキームを HTTPS に変更して 301 リダイレクトする。

    Let's Encrypt の証明書更新で HTTP-01 チャレンジを使う場合、/.well-known/acme-challenge/ 以下は HTTP のままアクセスできる必要がある。certbot はリダイレクト後も動作するが、独自のリダイレクト設定を書く場合は次の1行を RewriteCond に加えて除外する。

    RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
    
  3. 設定ファイルの構文テストを実行する。
    sudo apache2ctl configtest
    
  4. Apache を再起動して設定を反映させる。
    sudo systemctl restart apache2
    
  5. 確認方法: ブラウザでサイトの HTTP(例: http://yourdomain.com)にアクセスし、HTTPS(例: https://yourdomain.com)にリダイレクトされることを確認する。curl -I http://yourdomain.com コマンドでも 301Location ヘッダーを確認できる。

X-Frame-Options ヘッダーの設定

X-Frame-Options ヘッダーは、他のサイトの <frame>, <iframe>, <object> 要素内に自サイトのページが表示されることを制御する。これにより、ユーザに意図しない操作をさせるクリックジャッキング攻撃を防ぐ。現在の仕様では CSP の frame-ancestors が同じ機能を持ち、両方を設定した場合はブラウザが frame-ancestors を優先する。frame-ancestors に対応しない古いブラウザ向けに、両方を設定してもよい。

この設定は、Apache のメイン設定ファイル、/etc/apache2/conf-available/security.conf、またはバーチャルホストの設定ファイルに記述できる。

  1. headers モジュールを有効にする。
    sudo a2enmod headers
    
  2. 設定ファイル(例: /etc/apache2/conf-available/security.confVirtualHost セクション内)に、次の1行を追加する。SAMEORIGIN は同一オリジンのフレーム内でのみ表示を許可する。表示を禁止する場合は DENY を指定する。
    Header always set X-Frame-Options "SAMEORIGIN"
    
  3. 設定ファイルの構文テストを実行する。
    sudo apache2ctl configtest
    
  4. Apache を再起動する。
    sudo systemctl restart apache2
    
  5. 確認方法: curl -I https://yourdomain.com コマンドやブラウザの開発者ツールでレスポンスヘッダーを確認し、X-Frame-Options ヘッダーが設定されていることを確認する。

X-Content-Type-Options ヘッダーの設定

このヘッダーは、ブラウザが Content-Type ヘッダーで指定された MIME タイプを無視して内容からタイプを推測する動作(MIME スニッフィング)を抑制する。これにより、画像としてアップロードされたファイルがスクリプトとして解釈されるといったリスクを減らす。

この設定は、Apache のメイン設定ファイル、/etc/apache2/conf-available/security.conf、またはバーチャルホストの設定ファイルに記述できる。

  1. headers モジュールを有効にする。
    sudo a2enmod headers
    
  2. 設定ファイル(例: /etc/apache2/conf-available/security.confVirtualHost セクション内)に、次の1行を追加する。nosniff を指定することで MIME スニッフィングを抑止する。
    Header always set X-Content-Type-Options "nosniff"
    
  3. 設定ファイルの構文テストを実行する。
    sudo apache2ctl configtest
    
  4. Apache を再起動する。
    sudo systemctl restart apache2
    
  5. 確認方法: curl -I https://yourdomain.com コマンドやブラウザの開発者ツールでレスポンスヘッダーを確認し、X-Content-Type-Options ヘッダーが nosniff になっていることを確認する。

Content-Security-Policy (CSP) ヘッダーの設定

Content-Security-Policy (CSP) は、Web ページが読み込むリソース(スクリプト、スタイルシート、画像など)の取得元を制限する仕組みである。これにより、クロスサイトスクリプティング (XSS) やデータインジェクションの影響を減らせる。かつて使われた X-XSS-Protection ヘッダーは主要ブラウザでサポートが終了しており、CSP が代替となる。

設定を誤るとサイトの表示や機能が動作しなくなるため、設定後の動作確認が必要である。この設定は、SSL/TLS を使用するバーチャルホストの設定ファイル(例: /etc/apache2/sites-available/your-site-ssl.conf<VirtualHost *:443>)に記述する。

  1. headers モジュールを有効にする。
    sudo a2enmod headers
    
  2. SSL/TLS 設定を行っている VirtualHost セクション内に、CSP ディレクティブを追加する。以下は一例であり、サイトの構成に合わせて調整する。
    • default-src 'self': 既定では、自分自身のオリジン(ドメイン)からのリソースのみを許可する。
    • script-src 'self' https://trusted-cdn.com: スクリプトは自身のオリジンと、指定した CDN からのみ許可する。
    • style-src 'self' https://fonts.googleapis.com: スタイルシートは自身のオリジンと Google Fonts からのみ許可する。
    • img-src 'self' data:: 画像は自身のオリジンと data: スキームからのみ許可する。
    • object-src 'none': <object>, <embed> といったプラグイン要素の読み込みを禁止する。
    • frame-ancestors 'none': このページがフレーム内に埋め込まれることを禁止する。
    # Content-Security-Policy ヘッダーの設定例(環境に合わせて調整する)
    Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' https://fonts.googleapis.com; img-src 'self' data:; object-src 'none'; frame-ancestors 'none';"
    

    https://trusted-cdn.comhttps://fonts.googleapis.com の部分は、サイトが実際に使用している外部リソースのオリジンに書き換える。使用していない場合は削除する。インラインスクリプト (<script>...</script>) やインラインスタイル (style="...") を使用している場合は 'unsafe-inline' の指定が必要になるが、これを付けると XSS への効果が失われる。外部ファイル化するか、nonce または hash を使う方式を検討する。

    最初は Header always set Content-Security-Policy-Report-Only "..." のように Content-Security-Policy-Report-Only ヘッダーを使うと、ポリシー違反をブラウザの開発者コンソールに報告させながら、実際のブロックは行わずに調整できる。問題がないことを確認してから Content-Security-Policy に変更する。

  3. 設定ファイルの構文テストを実行する。
    sudo apache2ctl configtest
    
  4. Apache を再起動する。
    sudo systemctl restart apache2
    
  5. 確認方法: ブラウザの開発者ツール(ネットワークタブとコンソール)でレスポンスヘッダーとエラーメッセージを確認する。サイトの表示や機能が損なわれていないか、必要なリソースがブロックされていないかを確認する。外部の評価ツール(MDN HTTP Observatory: https://developer.mozilla.org/en-US/observatory)も利用できる。

補足: サイトマップ sitemap.xml の作成プログラム

Web サイト管理に使う例として、Python によるサイトマップ生成プログラムを示す。

次のプログラムを a.py のような名前で保存し,「python3 a.py https://www.kkaneko.jp/index.html」のように実行することにより,サイトマップ sitemap.xml を作成することができる.

実行には requests と beautifulsoup4 が必要である。Ubuntu 24.04 LTS の Python 3.12 は外部管理環境 (externally-managed-environment) であり、pip install をシステム全体に対して実行するとエラーになる。apt でインストールする。

sudo apt update
sudo apt install python3-requests python3-bs4

仮想環境を使う場合は次のように操作する。

python3 -m venv ~/venv
source ~/venv/bin/activate
pip install requests beautifulsoup4
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
import sys
import re
import xml.etree.ElementTree as ET
import xml.dom.minidom

def is_valid_url(url, target_domain):
    """ URLが指定されたドメイン内で有効かチェック """
    parsed_url = urlparse(url)
    return parsed_url.netloc and parsed_url.scheme and parsed_url.netloc == target_domain

def report_broken_link(url, source_url, broken_urls):
    """ 到達できないリンクを報告 """
    print(f"切れたリンク: {url}, 元のページ: {source_url}")
    broken_urls.add((url, source_url))

def remove_hash_fragment(url):
    # URLからハッシュフラグメント (#以降) を削除する
    return re.sub(r"#.*", "", url)

def remove_query_parameter(url):
    # URLからクエリパラメータ (?以降) を削除する。同一コンテンツへの重複URLを避けるためである
    return url.split('?')[0]

def get_file_size(url, session):
    """ URLのファイルサイズを取得 """
    try:
        response = session.head(url, timeout=5)
        response.raise_for_status()
        return int(response.headers.get('content-length', 0))
    except requests.RequestException as e:
        print(f"ファイルサイズ取得エラー: {url}, エラー: {e}")
        return 0

def get_all_links(page_url, target_domain, session, visited_urls, broken_urls):
    """ HTML ファイル内のリンクを取得 """
    urls = set()
    # サイトマップに含めないファイルタイプは巡回対象外とする
    excluded_extensions = ('.zip', '.ZIP', '.accdb', '.csv', '.txt', '.jpg', '.JPG', '.png', '.MOV', '.mp4', '.MP4', '.gz', '.z', '.blend', '.ply', '.wav', '.xls', 'xlsx', '.MTS', '.iso', '.tif', '.tiff', '.bmp', '.mp3', '.avi', '.obj', '.cgi', '.spam', '.sh', '.tgz', '.ova', '.IFO', '.VOB', '.BUP', '.pcd', '.gif', '.xml', '.fbx', '.m4a', '.odp', '.mht', '.ttf', '.rb', '.tex')
    allowed_extensions = ('.html', '.pdf', '.ppt', '.pptx', '.doc', '.docx')

    parsed_page_url = urlparse(page_url)
    # 除外リストにある拡張子で終わるURLはスキップ
    if any(parsed_page_url.path.lower().endswith(ext) for ext in excluded_extensions):
        print(f"除外された拡張子: {page_url}")
        return urls
    # 許可リストにない拡張子のURLもスキップ(拡張子なしのディレクトリは許可)
    if '.' in parsed_page_url.path.split('/')[-1] and not any(parsed_page_url.path.lower().endswith(ext) for ext in allowed_extensions):
        print(f"許可されない拡張子: {page_url}")
        return urls

    # ファイルサイズのチェック (10MBを超えるファイルはスキップ)
    file_size = get_file_size(page_url, session)
    if file_size > 10000000:
        print(f"大きすぎるファイル (サイズ: {file_size}): {page_url}")
        return urls

    try:
        response = session.get(page_url, timeout=10)
        response.raise_for_status()

        content_type = response.headers.get('content-type', '').lower()
        if 'text/html' not in content_type:
            # PDF等の許可された拡張子以外はスキップ
            if not any(page_url.lower().endswith(ext) for ext in allowed_extensions if ext != '.html'):
                print(f"HTMLでないコンテンツタイプ: {content_type}, URL: {page_url}")
                return urls

        if response.history:
            # リダイレクト先がターゲットドメイン外なら処理しない
            if not is_valid_url(response.url, target_domain):
                print(f"外部へのリダイレクト: {page_url} -> {response.url}")
                return urls
            print(f"リダイレクト検出: 元 {page_url}, 先 {response.url}")

        # HTMLの場合のみパースしてリンクを抽出
        if 'text/html' in content_type:
            page_soup = BeautifulSoup(response.content, "html.parser")
            for a_tag in page_soup.find_all("a"):
                href = a_tag.get("href")
                if href:
                    # URLの正規化(相対パスを絶対パスに、フラグメントとクエリを除去)
                    full_url = urljoin(page_url, href)
                    cleaned_url = remove_query_parameter(remove_hash_fragment(full_url))

                    if is_valid_url(cleaned_url, target_domain) and cleaned_url not in visited_urls:
                        parsed_cleaned_url = urlparse(cleaned_url)
                        # 許可された拡張子、または拡張子なし(ディレクトリ等)を対象とする
                        if any(parsed_cleaned_url.path.lower().endswith(ext) for ext in allowed_extensions) or '.' not in parsed_cleaned_url.path.split('/')[-1]:
                            try:
                                # リンク先が存在するか確認してから追加
                                link_response = session.head(cleaned_url, timeout=5)
                                link_response.raise_for_status()
                                final_link_url = remove_query_parameter(remove_hash_fragment(link_response.url))
                                if is_valid_url(final_link_url, target_domain) and final_link_url not in visited_urls:
                                    print(f"追加候補: {final_link_url} (元リンク: {href})")
                                    urls.add(final_link_url)
                            except requests.RequestException as link_e:
                                print(f"リンク先アクセスエラー: {cleaned_url}, 元ページ: {page_url}, エラー: {link_e}")
                                report_broken_link(cleaned_url, page_url, broken_urls)

    except (requests.RequestException, Exception) as e:
        print(f"ページ取得/処理エラー: {page_url}, エラー詳細: {e}")
        report_broken_link(page_url, "Crawl Start", broken_urls)
    return urls


def crawl_website(start_url, target_domain, session, visited_urls, broken_urls):
    """ ウェブサイトを幅優先で巡回 """
    to_visit = [start_url]

    while to_visit:
        current_url = to_visit.pop(0)
        current_url = remove_query_parameter(remove_hash_fragment(current_url))

        if current_url in visited_urls or not is_valid_url(current_url, target_domain):
            continue

        print(f"巡回中: {current_url}")
        visited_urls.add(current_url)

        try:
            links = get_all_links(current_url, target_domain, session, visited_urls, broken_urls)
            for link in links:
                cleaned_link = remove_query_parameter(remove_hash_fragment(link))
                if cleaned_link not in visited_urls and cleaned_link not in to_visit:
                    to_visit.append(cleaned_link)
        except Exception as e:
            print(f"巡回エラー ({current_url}): {e}")
            report_broken_link(current_url, "Crawl Loop", broken_urls)


def create_sitemap(links):
    """ サイトマップをXML形式で作成 """
    urlset = ET.Element("urlset", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9")
    # HTMLページと拡張子なしのURLをサイトマップに追加
    html_links = [link for link in links if urlparse(link).path.lower().endswith('.html') or '.' not in urlparse(link).path.split('/')[-1]]

    for link in sorted(html_links):
        url = ET.SubElement(urlset, "url")
        ET.SubElement(url, "loc").text = link
        ET.SubElement(url, "changefreq").text = "monthly"
    return ET.ElementTree(urlset)

def save_sitemap(sitemap, filename):
    """ サイトマップを整形してファイルに保存 """
    xml_str = ET.tostring(sitemap.getroot(), encoding='utf-8')
    dom = xml.dom.minidom.parseString(xml_str)
    pretty_xml_str = dom.toprettyxml(indent="  ", encoding='utf-8')

    with open(filename, "wb") as file:
        file.write(pretty_xml_str)

# --- メインの実行部分 ---
if __name__ == "__main__":
    if len(sys.argv) > 1:
        start_url = sys.argv[1]
        start_url = remove_query_parameter(remove_hash_fragment(start_url))
        parsed_start_url = urlparse(start_url)
        target_domain = parsed_start_url.netloc

        if not target_domain:
            print(f"エラー: 無効な開始URLである。ドメイン名が含まれていない: {start_url}")
            sys.exit(1)

        # スキームがなければ https を仮定する
        if not parsed_start_url.scheme:
            start_url = "https://" + start_url
            print(f"スキームを追加した: {start_url}")

        session = requests.Session()
        session.headers.update({'User-Agent': 'My Sitemap Generator Bot'})

        visited_urls, broken_urls = set(), set()

        print(f"巡回開始: {start_url}, 対象ドメイン: {target_domain}")
        crawl_website(start_url, target_domain, session, visited_urls, broken_urls)

        sitemap_xml = create_sitemap(visited_urls)
        save_sitemap(sitemap_xml, "sitemap.xml")

        print("\n--- 処理完了 ---")
        print(f"巡回したURL数: {len(visited_urls)}")
        print(f"サイトマップ (sitemap.xml) に含まれるURL数: {len(sitemap_xml.getroot().findall('{http://www.sitemaps.org/schemas/sitemap/0.9}url'))}")
        print("サイトマップが作成され、sitemap.xml として保存された。")

        if broken_urls:
            print("\n--- 到達できなかったリンク ---")
            for url, source in sorted(list(broken_urls)):
                print(f"リンク: {url} (参照元: {source})")
        else:
            print("\n到達できないリンクは見つからなかった。")

    else:
        print("使い方: python3 a.py <開始URL>")
        print("例: python3 a.py https://www.example.com")

生成された sitemap.xml ファイルは、Web サイトのルートディレクトリに置く。配置後、robots.txt ファイルに Sitemap: [サイトマップのURL] という行を追加すると、検索エンジンにサイトマップの場所を知らせることができる。Google Search Console などのツールにサイトマップを登録する方法もある。