Akkoma v3.12 (2024-03) へのアップデートの注意点

2024-04-01

2024-03-30にFediverse上で致命的な攻撃手段が発見され、これに伴って多くのFediverse実装が緊急リリースを行った。

Akkomaもその対象であり、2024-03-30にv3.12.0およびv3.12.1がリリースされた。

今回はそのセキュリティフィックスのために、従来はオプションだったセキュリティ対策が必須となったため、単純なアップデートではできなくなっている。

焦点となっているのは、メディアとAPの関係による脆弱性であり、単純なアップデートを行うとメディアにアクセスできなくなる。

対応の概要

  • 脆弱性のあるルートを塞ぐ
  • インスタンスの本体ドメインとメディアのドメインを異なるものにする

効率的な手順

メディアドメイン用の追加

メディア用のドメインはバックエンドとしてもともとのAkkomaサーバーを用いる。 そのため、基本的には新たにメディア用のドメインをCNAMEとしてインスタンスのドメインに向けて設定することになる。

social.jlinuxer.orgの場合、新たにメディア用のドメインとしてsoclip.jlinuxer.orgを設定したので、soclip.jlinuxer.org

CNAME social.jlinuxer.org

と設定するということである。

メディア用ドメインのSSL

CNAMEによるエイリアスは「別の名前」であるため、SSLの証明はそのドメイン用に別途取得する必要がある。

Let’s Encryptを使う場合は

certbot ceronly --email [email protected] -d media.example.com

のようにして証明書を取得する。

メディア用ドメインの設定

ここではNginxを使う想定で説明する。

まず、AkkomaのサンプルのNginxは次のようになっている。 これはPleromaのものとは少し異なる。

# default nginx site config for Akkoma
#
# Simple installation instructions:
# 1. Install your TLS certificate, possibly using Let's Encrypt.
# 2. Replace 'example.tld' with your instance's domain wherever it appears.
# 3. Copy this file to /etc/nginx/sites-available/ and then add a symlink to it
#    in /etc/nginx/sites-enabled/ and run 'nginx -s reload' or restart nginx.

proxy_cache_path /tmp/akkoma-media-cache levels=1:2 keys_zone=akkoma_media_cache:10m max_size=10g
                 inactive=720m use_temp_path=off;

# this is explicitly IPv4 since Pleroma.Web.Endpoint binds on IPv4 only
# and `localhost.` resolves to [::0] on some systems: see issue #930
upstream phoenix {
    server 127.0.0.1:4000 max_fails=5 fail_timeout=60s;
}

server {
    server_name    example.tld;

    listen         80;
    listen         [::]:80;

    # Uncomment this if you need to use the 'webroot' method with certbot. Make sure
    # that the directory exists and that it is accessible by the webserver. If you followed
    # the guide, you already ran 'mkdir -p /var/lib/letsencrypt' to create the folder.
    # You may need to load this file with the ssl server block commented out, run certbot
    # to get the certificate, and then uncomment it.
    #
    # location ~ /\.well-known/acme-challenge {
    #     root /var/lib/letsencrypt/;
    # }
    location / {
      return         301 https://$server_name$request_uri;
    }
}

# Enable SSL session caching for improved performance
ssl_session_cache shared:ssl_session_cache:10m;

server {
    server_name example.tld;

    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;  # about 40000 sessions
    ssl_session_tickets off;

    ssl_trusted_certificate   /etc/letsencrypt/live/example.tld/chain.pem;
    ssl_certificate           /etc/letsencrypt/live/example.tld/fullchain.pem;
    ssl_certificate_key       /etc/letsencrypt/live/example.tld/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
    ssl_prefer_server_ciphers off;
    ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1;
    ssl_stapling on;
    ssl_stapling_verify on;

    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml;

    # the nginx default is 1m, not enough for large media uploads
    client_max_body_size 16m;
    ignore_invalid_headers off;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location ~ ^/(media|proxy) {
        return 404;
    }

    location / {
        proxy_pass http://phoenix;
    }
}

# Upload and MediaProxy Subdomain
# (see main domain setup for more details)
server {
    server_name    media.example.tld;

    listen         80;
    listen         [::]:80;

    location / {
      return         301 https://$server_name$request_uri;
    }
}

server {
    server_name media.example.tld;

    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    ssl_trusted_certificate   /etc/letsencrypt/live/media.example.tld/chain.pem;
    ssl_certificate           /etc/letsencrypt/live/media.example.tld/fullchain.pem;
    ssl_certificate_key       /etc/letsencrypt/live/media.example.tld/privkey.pem;
    # .. copy all other the ssl_* and gzip_* stuff from main domain

    # the nginx default is 1m, not enough for large media uploads
    client_max_body_size 16m;
    ignore_invalid_headers off;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location ~ ^/(media|proxy) {
        proxy_cache        akkoma_media_cache;
        slice              1m;
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_cache_valid  200 206 301 304 1h;
        proxy_cache_lock   on;
        proxy_ignore_client_abort on;
        proxy_buffering    on;
        chunked_transfer_encoding on;
        proxy_pass         http://phoenix;
    }

    location / {
        return 404;
    }
}

この設定はすでにメディアを別のドメインにする前提の構成となっている(が、最適でもない)。 なので、まずは以前の設定を用いた前提で説明しよう。 以前のサンプルは次のようになっていた。

# default nginx site config for Akkoma
#
# Simple installation instructions:
# 1. Install your TLS certificate, possibly using Let's Encrypt.
# 2. Replace 'example.tld' with your instance's domain wherever it appears.
# 3. Copy this file to /etc/nginx/sites-available/ and then add a symlink to it
#    in /etc/nginx/sites-enabled/ and run 'nginx -s reload' or restart nginx.

proxy_cache_path /tmp/akkoma-media-cache levels=1:2 keys_zone=akkoma_media_cache:10m max_size=10g
                 inactive=720m use_temp_path=off;

# this is explicitly IPv4 since Pleroma.Web.Endpoint binds on IPv4 only
# and `localhost.` resolves to [::0] on some systems: see issue #930
upstream phoenix {
    server 127.0.0.1:4000 max_fails=5 fail_timeout=60s;
}

server {
    server_name    example.tld;

    listen         80;
    listen         [::]:80;

    # Uncomment this if you need to use the 'webroot' method with certbot. Make sure
    # that the directory exists and that it is accessible by the webserver. If you followed
    # the guide, you already ran 'mkdir -p /var/lib/letsencrypt' to create the folder.
    # You may need to load this file with the ssl server block commented out, run certbot
    # to get the certificate, and then uncomment it.
    #
    # location ~ /\.well-known/acme-challenge {
    #     root /var/lib/letsencrypt/;
    # }
    location / {
      return         301 https://$server_name$request_uri;
    }
}

# Enable SSL session caching for improved performance
ssl_session_cache shared:ssl_session_cache:10m;

server {
    server_name example.tld;

    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;  # about 40000 sessions
    ssl_session_tickets off;

    ssl_trusted_certificate   /etc/letsencrypt/live/example.tld/chain.pem;
    ssl_certificate           /etc/letsencrypt/live/example.tld/fullchain.pem;
    ssl_certificate_key       /etc/letsencrypt/live/example.tld/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
    ssl_prefer_server_ciphers off;
    ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1;
    ssl_stapling on;
    ssl_stapling_verify on;

    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml;

    # the nginx default is 1m, not enough for large media uploads
    client_max_body_size 16m;
    ignore_invalid_headers off;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location / {
        proxy_pass http://phoenix;
    }

    location ~ ^/(media|proxy) {
        proxy_cache        akkoma_media_cache;
        slice              1m;
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_cache_valid  200 206 301 304 1h;
        proxy_cache_lock   on;
        proxy_ignore_client_abort on;
        proxy_buffering    on;
        chunked_transfer_encoding on;
        proxy_pass         http://phoenix;
    }
}

これをまずメディア用にserverセクションを丸々コピーする。 コピペして、server_nameと証明書のパスを書き換えるだけだ。

server {
    server_name    media.example.tld;
    listen         80;
    listen         [::]:80;

    location / {
      return         301 https://$server_name$request_uri;
    }
}

server {
    server_name example.tld;

    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;  # about 40000 sessions
    ssl_session_tickets off;

    ssl_trusted_certificate   /etc/letsencrypt/live/media.example.tld/chain.pem;
    ssl_certificate           /etc/letsencrypt/live/media.example.tld/fullchain.pem;
    ssl_certificate_key       /etc/letsencrypt/live/media.example.tld/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
    ssl_prefer_server_ciphers off;
    ssl_ecdh_curve X25519:prime256v1:secp384r1:secp521r1;
    ssl_stapling on;
    ssl_stapling_verify on;

    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml;

    # the nginx default is 1m, not enough for large media uploads
    client_max_body_size 16m;
    ignore_invalid_headers off;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location / {
        proxy_pass http://phoenix;
    }

    location ~ ^/(media|proxy) {
        proxy_cache        akkoma_media_cache;
        slice              1m;
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_cache_valid  200 206 301 304 1h;
        proxy_cache_lock   on;
        proxy_ignore_client_abort on;
        proxy_buffering    on;
        chunked_transfer_encoding on;
        proxy_pass         http://phoenix;
    }
}

このHTTPSのserverセクションの/404を返すようにし、proxyを塞ぐ。

before:

    location / {
        proxy_pass http://phoenix;
    }

    location ~ ^/(media|proxy) {
        proxy_cache        akkoma_media_cache;
        slice              1m;
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_cache_valid  200 206 301 304 1h;
        proxy_cache_lock   on;
        proxy_ignore_client_abort on;
        proxy_buffering    on;
        chunked_transfer_encoding on;
        proxy_pass         http://phoenix;
    }

after:

    location / {
        return 404;
    }

    location ~ ^/media {
        proxy_cache        akkoma_media_cache;
        slice              1m;
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_cache_valid  200 206 301 304 1h;
        proxy_cache_lock   on;
        proxy_ignore_client_abort on;
        proxy_buffering    on;
        chunked_transfer_encoding on;
        proxy_pass         http://phoenix;
    }

続いてインスタンス側のメディアセクションをリダイレクトするように変える。

before:

    location ~ ^/(media|proxy) {
        proxy_cache        akkoma_media_cache;
        slice              1m;
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_cache_valid  200 206 301 304 1h;
        proxy_cache_lock   on;
        proxy_ignore_client_abort on;
        proxy_buffering    on;
        chunked_transfer_encoding on;
        proxy_pass         http://phoenix;
    }

after:

    location ~ ^/(media|proxy) {
      return 301 https://media.example.tld$request_uri;
    }

これは他のインスタンスから参照されているメディアを壊さないための処理であり、後のよきときに変更する。

ここまで設定したらNginxをリロードする。 これでメディアアクセスは見えるようになるはずだが、ここで終わりではない。

Akkomaの設定

Admin-FEの場合

Settings > Upload > Pleroma.Upload > Base URL にメディア用ドメインのメディアのパスを入れる。 media/は重要。

https://media.example.tld/media/

Settings > Media Proxy > Media Proxy > Base URL にメディア用ドメインを入れる。

https://media.example.tld

Settings > Metadata > Rich media > Eanbledをオフにする。

prod.secret.exsの場合

:pleroma, Pleroma.Uploadの設定のbase_urlにメディア用ドメインのメディアのパスを入れる。 media/は重要。

config :pleroma, Pleroma.Upload,
  #...
  base_url: "https://media.example.tld/media/",
  #...

:pleroma, :media_proxybase_urlを入れる。

config :pleroma, :media_proxy,
  base_url: "https://media.example.tld",
  #...

:pleroma, :rich_mediaenabledfalseにする。

config :pleroma, :rich_media,
  enabled: true,
  #...

インスタンスドメインを閉じる

上記では301に設定したが、404にしたほうが安全。

    location ~ ^/(media|proxy) {
      return 404;
    }

ただし、すでに稼働しているドメインでこの変更を行った場合、既存の投稿や設定(例えばアイコン)にある画像のアドレスはインスタンスドメインのものになっているため、それらが全てリンク切れになる。

この問題は、データベースのすべてのメディアリンクを修正すればそのインスタンスの中では解決する。 しかし、外部のインスタンスの場合は外部インスタンスが既存の投稿のコピーを保持しているため、外部インスタンスから見た投稿はリンク切れのままになる。

ここらへんはちゃんと判断が必要。

ついでにひとこと

手順と手間を考えると、これを機にS3互換APIを持つストレージに以降するという手もある。