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

【概要】 この記事では、Apache Webサーバの管理とセキュリティ強化のための実践的な手順を解説します。主に、パッケージのアップデート手順や、各種セキュリティヘッダー(HSTS, CSPなど)の設定方法について、目的、実装手順、確認方法を体系的に説明します。これらの設定により、Web サーバのセキュリティレベルを向上させ,様々な攻撃からの保護を実現することができます。加えて、Webサイト管理に有用なサイトマップ生成プログラムについても紹介します。

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

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

サーバを安全かつ最新の状態に保つために、定期的なパッケージのアップデートが重要です。以下の手順でシステム全体のパッケージとApacheを更新します。

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

  1. パッケージ情報の更新:
    sudo apt -y 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 パッケージの更新:
    sudo apt -y upgrade apache2
  5. Apache のバージョン確認:
    apache2 -v
  6. Apache の再起動:
    (設定変更を反映させるために再起動します)
    sudo systemctl restart apache2
  7. 設定変更後の確認:
    再起動後、Webサイトが正常に表示・機能するかを必ず確認してください。

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

サーバのバージョン情報を公開すると,攻撃者に脆弱性を突くヒントを与えてしまう可能性があります。そのため,次の設定により,Apache 2 サーバのバージョン情報を公開しないようにします。

設定は、Apacheのメイン設定ファイル (`/etc/apache2/apache2.conf`) や、セキュリティ関連の設定をまとめたファイル (例: `/etc/apache2/conf-available/security.conf` を作成し `sudo a2enconf security` で有効化) に記述することを推奨します。

  1. 設定ファイルに,次の2行を追加します。
    • ServerTokens Prod: HTTPレスポンスヘッダーに"Apache"とだけ表示し、詳細なバージョン番号や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 は、SSL/TLS証明書の失効情報を確認する際の応答速度とプライバシーを向上させる技術です。サーバが認証局に代わって証明書の有効性情報(OCSPレスポンス)をクライアント(ブラウザ)に提供します。

この設定は、SSL/TLSを使用するバーチャルホストの設定ファイル(例: `/etc/apache2/sites-available/your-site-ssl.conf` 内の `` ディレクティブ)に記述します。

  1. SSLモジュールが有効であることを確認します。(通常は有効になっています)
    sudo a2enmod ssl
    
  2. SSL/TLS設定を行っている VirtualHost セクション内に,次の2行を追加します。
    SSLUseStapling on
    SSLStaplingCache shmcb:/tmp/stapling_cache(128000)
    
  3. 設定ファイルの構文テストを実行します。
    sudo apache2ctl configtest
    
  4. Apache を再起動します。
    sudo systemctl restart apache2
    
  5. OCSP Stapling の確認を行います。「mydomain.com」のところは,自分のドメイン名に変更して実行し,`OCSP Response Data:` セクションが表示され、`OCSP Response Status: successful` となっていることを確認します。
    openssl s_client -connect mydomain.com:443 -status | grep -A 10 'OCSP Response Data'
    

    (出力が長い場合は `| grep -A 10 'OCSP Response Data'` で関連部分を抽出できます)

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` 内の `` ディレクティブ)に記述します。

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

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

X-Frame-Optionsヘッダーは,他のサイトの `<frame>`, `<iframe>`, `<object>` 要素内に自サイトのページが表示されることを制御する機能です。これにより、ユーザーを騙して意図しない操作を行わせるクリックジャッキング攻撃を防止します。

この設定は、Apacheのメイン設定ファイル、セキュリティ関連の設定ファイル、またはバーチャルホストの設定ファイルに記述できます。サイト全体で一貫したポリシーを適用する場合が多いです。

  1. (`headers` モジュールが有効になっていることを確認してください。HSTS設定で有効にしていれば不要です)
  2. 設定ファイル(例: `/etc/apache2/conf-available/security.conf` や VirtualHost セクション内)に,次の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のメイン設定ファイル、セキュリティ関連の設定ファイル、またはバーチャルホストの設定ファイルに記述できます。

  1. (`headers` モジュールが有効になっていることを確認してください)
  2. 設定ファイル(例: `/etc/apache2/conf-available/security.conf` や VirtualHost セクション内)に,次の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) 攻撃やデータインジェクション攻撃などのリスクを大幅に軽減できます。

CSPの設定は非常に強力ですが、設定を間違えるとサイトの表示や機能が損なわれる可能性があります。慎重に設定し、十分にテストしてください。この設定は、SSL/TLSを使用するバーチャルホストの設定ファイル(例: `/etc/apache2/sites-available/your-site-ssl.conf` 内の `` ディレクティブ)に記述することが一般的です。

  1. (`headers` モジュールが有効になっていることを確認してください)
  2. SSL/TLS設定を行っている VirtualHost セクション内に,CSPディレクティブを追加します。以下の設定はあくまで一例であり、あなたのWebサイトの構成に合わせて適切に調整する必要があります。

設定

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

Webサイト管理に役立つツールとして、Pythonによるサイトマップ生成プログラムを紹介します。

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

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 check_image_links(page_soup, page_url, target_domain, session, broken_images):
    """ ウェブページ内の画像リンクをチェック """
    for img_tag in page_soup.find_all("img"):
        img_src = img_tag.get("src")
        if img_src:
            full_img_url = urljoin(page_url, img_src)
            if is_valid_url(full_img_url, target_domain) and not is_url_accessible(full_img_url, session):
                report_broken_link(full_img_url, page_url, broken_images)

def is_url_accessible(url, session):
    """ URLがアクセス可能かチェック """
    try:
        response = session.head(url)
        return response.status_code == 200
    except requests.RequestException:
        return False

def report_broken_link(url, source_url, broken_links):
    """ 壊れたリンクを報告 """
    print(f"切れたリンク: {url}, 元のページ: {source_url}")
    broken_links.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: # HEADリクエストが失敗する場合も考慮
        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 # エラー時は 0 を返す

def get_all_links(page_url, target_domain, session, visited_urls, broken_images):
    """ HTML ファイル内のリンクを取得 """
    urls = set()
    # HTML, PDF, Office文書以外のファイルタイプはサイトマップに含めないため、巡回対象外とします。
    # 必要に応じて対象/除外リストを調整してください。
    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がtext/htmlでない場合はスキップ
        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: # リダイレクトが発生した場合
            # リダイレクト先のURLがターゲットドメイン外なら処理しない
            if not is_valid_url(response.url, target_domain):
                 print(f"外部へのリダイレクト: {page_url} -> {response.url}")
                 return urls
            print(f"リダイレクト検出: 元 {page_url}, 先 {response.url}")
            # リダイレクト先を処理対象に追加 (ただし、既に訪問済みでないかチェック)
            final_url = remove_query_parameter(remove_hash_fragment(response.url))
            if final_url != page_url and final_url not in visited_urls:
                 # crawl_website内で追加されるのでここでは追加しない
                 pass # 必要であればここで追加処理

        # HTMLの場合のみパースしてリンクを抽出
        if 'text/html' in content_type:
            page_soup = BeautifulSoup(response.content, "html.parser")
            # 画像リンク切れチェック (オプション)
            # check_image_links(page_soup, page_url, target_domain, session, broken_images)
            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))

                    # 有効な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:
                                   # リンク先が存在するか確認してから追加 (HEADリクエスト)
                                   link_response = session.head(cleaned_url, timeout=5)
                                   link_response.raise_for_status()
                                   # リダイレクトがあれば最終的なURLを使用
                                   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) # broken_urls を使用

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


def crawl_website(start_url, target_domain, session, visited_urls, broken_urls, broken_images):
    """ ウェブサイトを再帰的に巡回 (深さ優先探索) """
    to_visit = [start_url] # これから訪れるURLのリスト

    while to_visit:
        current_url = to_visit.pop(0) # FIFOで幅優先探索に変更も可能 (pop()なら深さ優先)
        # 正規化してからチェック
        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)

        # 現在のURLからリンクを取得
        try:
             links = get_all_links(current_url, target_domain, session, visited_urls, broken_images) # 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ページのみをサイトマップに追加 (オプション: 必要なら他のタイプも)
    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): # HTMLリンクのみソートして追加
        url = ET.SubElement(urlset, "url")
        ET.SubElement(url, "loc").text = link
        ET.SubElement(url, "changefreq").text = "monthly" # または weekly/daily など
        # lastmod も追加可能 (例: ET.SubElement(url, "lastmod").text = "YYYY-MM-DD")
    return ET.ElementTree(urlset)

def save_sitemap(sitemap, filename):
    """ サイトマップを整形してファイルに保存 """
    xml_str = ET.tostring(sitemap.getroot(), encoding='utf-8')
    # minidomを使って整形
    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]
        # 開始URLを正規化
        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 を仮定 (httpも可)
        if not parsed_start_url.scheme:
            start_url = "https://" + start_url
            print(f"スキームを追加しました: {start_url}")

        session = requests.Session()
        # User-Agentを設定 (偽装が必要な場合)
        session.headers.update({'User-Agent': 'My Sitemap Generator Bot'})

        visited_urls, broken_urls, broken_images = set(), set(), set()

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

        # サイトマップの作成と保存 (HTMLページのみ)
        sitemap_xml = create_sitemap(visited_urls)
        save_sitemap(sitemap_xml, "sitemap.xml")

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

        # 壊れたリンクの報告
        if broken_urls:
            print("\n--- 検出された壊れた可能性のあるリンク ---")
            for url, source in sorted(list(broken_urls)):
                print(f"リンク: {url} (参照元: {source})")
        else:
            print("\n壊れたリンクは見つかりませんでした。")

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