MTA-STSとTLS-RPTの設定を確認する|主要30ドメインを調べたら導入は2件だけだった

Security

SPF・DKIM・DMARCで「誰が送ったメールか」は検証できるようになりました。ただ、これらが守っているのは送信者の身元であって、メールが運ばれる経路は別の問題です。サーバ間のSMTPは今でも日和見TLS(opportunistic TLS)が基本で、STARTTLSが使えなければ平文で送る、という動きが既定のままです。

ここを埋めるのがMTA-STS(RFC 8461)とTLS-RPT(RFC 8460)です。この記事では、RFCの要件を一次資料で確認したうえで、主要30ドメインの導入状況を実際に調べ、設定を検査するスクリプトを書きます。例によって、実際に手を動かして踏んだ落とし穴も残しておきます。

MTA-STSが解決する問題

STARTTLSはネゴシエーションで始まります。送信側MTAが「TLSを使えるか」を確認し、受信側が対応していれば暗号化に切り替わる。この仕組みの弱点は、経路上に能動的な攻撃者がいる場合、STARTTLSのケイパビリティ広告を握りつぶされると通信が平文にフォールバックしてしまうことです(いわゆるSTARTTLS剥がし)。DNS応答の改ざんで偽のMXへ誘導される可能性もあります。

MTA-STSは、受信側のドメインが「うちのMXへはTLS必須で、証明書はこのホスト名で検証すること」というポリシーを公開し、対応した送信側MTAがそれを守る仕組みです。方向を取り違えやすいのですが、ポリシーを公開するのは受信側で、守られるのは自分のドメイン宛てに届くメールです。

同じ問題をDNSSEC前提で解くのがDANE(TLSA)ですが、RFC 8461の冒頭にあるとおり、MTA-STSは認証局(CA)ベースの証明書検証を使うことでDNSSECなしで運用できる設計になっています。DNSSECの導入が進んでいない環境でも使えることが存在意義です。

TLS-RPT(RFC 8460)はその観測手段です。送信側MTAが「あなたのドメインへの配送でTLSに失敗した」というレポートを日次で送ってくれる仕組みで、_smtp._tlsのTXTレコードに受け取り先を書いておきます。DMARCレポート(rua)のTLS版と考えると分かりやすいです。

主要30ドメインの導入状況を実測する

仕組みの前に、まず現実を見ます。2026年8月14日に、DNS over HTTPS(dns.google)で_mta-sts.<ドメイン>_smtp._tls.<ドメイン>のTXTレコードを30ドメインについて確認しました。

結果は、MTA-STSを設定していたのは30ドメイン中2件だけでした。TLS-RPTも同じ2件です。

  • 設定あり(2件):google.com、microsoft.com(いずれもMTA-STSとTLS-RPTの両方)
  • 海外系10ドメイン中2件:apple.com、amazon.com、cloudflare.com、github.com、salesforce.com、zoom.us、slack.com、okta.comは未設定
  • 日本企業系20ドメイン中0件:yahoo.co.jp、rakuten.co.jp、nifty.com、biglobe.ne.jp、ocn.ne.jp、so-net.ne.jp、sakura.ad.jp、cybozu.com、freee.co.jp、moneyforward.com、mercari.com、line.me、ntt.com、softbank.jp、kddi.com、jal.co.jp、ana.co.jp、mufg.jp、smbc.co.jp、japanpost.jp すべて未設定

通信キャリアも銀行も航空会社も含めて、日本企業系はゼロでした。DNS応答の内訳はNXDOMAINが21件、NOERRORだが回答なしが7件です。この2種類の「無い」の違いが、後述するスクリプトのバグにつながります。

導入していた2件のポリシーが対照的だったので載せておきます。

# google.com
version: STSv1
mode: enforce
mx: smtp.google.com
mx: aspmx.l.google.com
mx: *.aspmx.l.google.com
max_age: 86400

# microsoft.com
version: STSv1
mode: enforce
mx: *.mail.protection.outlook.com
max_age: 604800

Googleはmxを3行並べてmax_ageは1日、Microsoftはワイルドカード1行でmax_ageは1週間。どちらもmodeはenforceです。

導入率が低いから意味がない、とはなりません。MTA-STSは送信側が対応していて初めて効く仕組みなので、自ドメインでポリシーを公開する価値は「対応済みの送信側からどれだけメールを受けるか」で決まります。ここは推測ではなく、導入後にTLS-RPTのレポートで確かめるのが確実です。

仕組みと要件(RFC 8461で確認)

1. TXTレコード

_mta-sts.<ドメイン>にTXTレコードを置きます。フィールドは2つだけです。

_mta-sts.example.jp. IN TXT "v=STSv1; id=20260814T000000;"
  • v(必須)… 現在はSTSv1のみ
  • id(必須)… ポリシーの版を識別する短い文字列。ポリシーファイルを更新したら必ずこの値も変えます。送信側はidの変化でポリシーの更新を検知します。値の大小や順序に意味はありません

2. ポリシーファイル

ポリシー本体はDNSではなくHTTPSで配ります。場所は固定で、https://mta-sts.<ドメイン>/.well-known/mta-sts.txtです。ホスト名は「ポリシードメインの前にmta-stsを付ける」と決まっています。

  • version(必須)… STSv1
  • mode(必須)… enforce/testing/noneのいずれか。testingは違反をレポートするだけで配送は継続、enforceは違反時に配送しません
  • max_age(必須)… ポリシーをキャッシュしてよい秒数。上限は31557600(約1年)で、RFCは「数週間以上」を想定しています
  • mx(modeがnone以外では1件以上必須)… 許可するMXのホスト名。ワイルドカードは左端のラベルにだけ使えます。国際化ドメインはPunycodeのA-labelで書きます

media typeはtext/plainで配信します。RFC 8461 3.2は、送信側がこれを確認すべき(SHOULD)としています。Webサーバが利用者に任意パスのコンテンツ設置を許している環境で、HTMLや画像をポリシーとして誤読しないための防御です。

3. 取得時のルール(見落としやすい)

ポリシーホストの証明書はmta-sts.<ドメイン>というホスト名(DNS-ID)に対して有効で、送信側が信頼するルートCAにチェーンし、期限内である必要があります。自己署名は使えません。

さらにRFC 8461 3.3には見落としやすい規定が2つあります。HTTP 3xxリダイレクトを追跡してはならず(MUST NOT)、HTTPキャッシュも使ってはならない(MUST NOT)。「とりあえずwwwへリダイレクトで逃がす」構成は仕様違反になります。

設定を検査するスクリプト

ここまでの要件を機械的に確認するスクリプトを書きました。Python標準ライブラリだけで動きます。DNSはDoH(dns.google)で引くため、社内リゾルバの設定に影響されません。

#!/usr/bin/env python3
"""mtastscheck.py - MTA-STS / TLS-RPT の設定を確認する

使い方:
    ./mtastscheck.py example.jp [example2.jp ...]

DNSはDoH(dns.google)で引くため、ローカルのリゾルバ設定に影響されません。
"""
import sys, json, ssl, urllib.request, urllib.error

TIMEOUT = 10
UA = 'mtastscheck/1.0'


def doh(name, rtype='TXT'):
    """DNS over HTTPS で問い合わせる。戻り値は (status, [レコード文字列])"""
    url = 'https://dns.google/resolve?name=%s&type=%s' % (name, rtype)
    req = urllib.request.Request(url, headers={'User-Agent': UA})
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            j = json.load(r)
    except Exception as e:
        return ('ERR:%s' % type(e).__name__, [])
    # NXDOMAIN(3) でも NOERROR(0) でも Answer が無いことがある。
    # j['Answer'] と直接書くと KeyError で落ちる
    answers = [a.get('data', '') for a in j.get('Answer', [])]
    return (j.get('Status'), answers)


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """RFC 8461 3.3: HTTP 3xx redirects MUST NOT be followed"""
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


_opener = urllib.request.build_opener(_NoRedirect)


def fetch_policy(domain):
    """ポリシーを取得する。戻り値は (HTTPステータス, Content-Type, 本文, エラー)

    注意: urllib は既定で 3xx を黙って追跡するが、RFC 8461 3.3 は
    ポリシー取得でのリダイレクト追跡を禁止している(MUST NOT)。
    ここでは追跡せず、3xx はエラーとして報告する。
    """
    url = 'https://mta-sts.%s/.well-known/mta-sts.txt' % domain
    req = urllib.request.Request(url, headers={'User-Agent': UA})
    try:
        with _opener.open(req, timeout=TIMEOUT) as r:
            return (r.status, r.headers.get('Content-Type', ''), r.read().decode('utf-8', 'replace'), None)
    except urllib.error.HTTPError as e:
        if 300 <= e.code < 400:
            return (e.code, '', '', 'HTTP %d リダイレクト。RFC 8461 3.3 は追跡を禁止(MUST NOT)' % e.code)
        return (e.code, e.headers.get('Content-Type', ''), '', 'HTTP %d' % e.code)
    except ssl.SSLCertVerificationError as e:
        # 証明書は mta-sts.<ドメイン> に対して有効でなければならない(RFC 8461 3.3)
        return (None, '', '', '証明書検証エラー: %s' % e.verify_message)
    except Exception as e:
        return (None, '', '', '%s: %s' % (type(e).__name__, e))


def parse_policy(body):
    """ポリシーを辞書にする。mx は複数行あるのでリストで保持する"""
    policy = {'mx': []}
    for line in body.splitlines():
        line = line.strip()
        if not line:
            continue
        key, sep, value = line.partition(':')
        if not sep:
            continue
        key, value = key.strip().lower(), value.strip()
        if key == 'mx':
            policy['mx'].append(value)
        else:
            policy[key] = value
    return policy


def check(domain):
    print('=' * 56)
    print(domain)
    print('=' * 56)

    # 1) _mta-sts TXT レコード
    status, recs = doh('_mta-sts.' + domain)
    sts = [r for r in recs if 'STSv1' in r]
    if not sts:
        print('  MTA-STS TXT : なし(DNS status=%s)' % status)
    else:
        print('  MTA-STS TXT : %s' % sts[0])
        fields = dict(
            (p.split('=', 1)[0].strip().lower(), p.split('=', 1)[1].strip())
            for p in sts[0].split(';') if '=' in p
        )
        if 'id' not in fields:
            print('    [警告] id フィールドが無い。RFC 8461 3.1 で必須')
        else:
            print('    id = %s(ポリシー更新時はこの値も変える必要がある)' % fields['id'])
        if len(sts) > 1:
            print('    [警告] STSv1 のTXTが %d 件ある。1件にすること' % len(sts))

    # 2) TLS-RPT
    status, recs = doh('_smtp._tls.' + domain)
    rpt = [r for r in recs if 'TLSRPTv1' in r]
    print('  TLS-RPT     : %s' % (rpt[0] if rpt else 'なし(DNS status=%s)' % status))

    if not sts:
        print('  → MTA-STS 未設定のためポリシー取得はスキップ')
        return

    # 3) ポリシー取得
    code, ctype, body, err = fetch_policy(domain)
    if err:
        print('  ポリシー取得: 失敗 - %s' % err)
        return
    print('  ポリシー取得: HTTP %s / Content-Type: %s' % (code, ctype))

    # media type は text/plain であることを確認する(RFC 8461 3.2)
    # charset 等のパラメータが付くので、前方一致で見る
    media = ctype.split(';')[0].strip().lower()
    if media != 'text/plain':
        print('    [警告] media type が text/plain ではない: %s' % media)

    p = parse_policy(body)
    print('  version : %s' % p.get('version', '(なし)'))
    print('  mode    : %s' % p.get('mode', '(なし)'))
    print('  max_age : %s' % p.get('max_age', '(なし)'))
    print('  mx      : %d件' % len(p['mx']))
    for m in p['mx']:
        print('      %s' % m)

    # 必須フィールドの検査(RFC 8461 3.2)
    for k in ('version', 'mode', 'max_age'):
        if k not in p:
            print('    [警告] 必須フィールド %s が無い' % k)
    if p.get('mode') != 'none' and not p['mx']:
        print('    [警告] mode が none 以外なのに mx が1件も無い')
    try:
        if int(p.get('max_age', 0)) < 86400:
            print('    [注意] max_age が 86400 未満。運用が安定したら伸ばす')
    except ValueError:
        print('    [警告] max_age が数値ではない')
    if p.get('mode') == 'testing':
        print('    [情報] mode=testing。ポリシー違反でも配送は継続される')
    if p.get('mode') == 'none':
        print('    [情報] mode=none。MTA-STS を無効化する意味になる')


def main():
    if len(sys.argv) < 2:
        print(__doc__)
        return 0
    for d in sys.argv[1:]:
        check(d)
    return 0


if __name__ == '__main__':
    sys.exit(main())

google.comと未設定ドメイン(yahoo.co.jp)に対する出力です。DNS応答とポリシーは2026年8月14日に実際に取得したものを、検証のためスクリプトに通しています。

========================================================
google.com
========================================================
  MTA-STS TXT : v=STSv1; id=20210803T010101;
    id = 20210803T010101(ポリシー更新時はこの値も変える必要がある)
  TLS-RPT     : v=TLSRPTv1;rua=mailto:[email protected]
  ポリシー取得: HTTP 200 / Content-Type: text/plain
  version : STSv1
  mode    : enforce
  max_age : 86400
  mx      : 3件
      smtp.google.com
      aspmx.l.google.com
      *.aspmx.l.google.com
========================================================
yahoo.co.jp
========================================================
  MTA-STS TXT : なし(DNS status=3)
  TLS-RPT     : なし(DNS status=3)
  → MTA-STS 未設定のためポリシー取得はスキップ

実データで踏んだ落とし穴

今回も、第1版を実データに通して直した箇所が3つ、仕様を読み直して直した箇所が1つあります。

1. mxを辞書で持つと黙って消える

ポリシーはkey/value形式なので、素朴に辞書へ詰めたくなります。

policy = {}
for line in body.splitlines():
    k, _, v = line.partition(':')
    policy[k.strip()] = v.strip()

google.comの実ポリシーをこれに通すと、mxが3行あるのに最後の1行しか残りません。

実際のmx行数 : 3
辞書に残ったmx: '*.aspmx.l.google.com'

smtp.google.comとaspmx.l.google.comが消えました。例外は出ません。この上にMX照合を実装すると、正当なMXを不一致と誤判定します。mxだけはリストで持つ必要があります。

2. DoHの応答にAnswerキーが無い

j['Answer']と書いていた第1版は、未設定ドメインを調べた瞬間にKeyErrorで落ちました。NXDOMAIN(Status 3)のときだけでなく、NOERROR(Status 0)でも回答が無ければAnswerキー自体が存在しません

nxdomain(Status 3)         -> KeyError: 'Answer'
noerror-noanswer(Status 0) -> KeyError: 'Answer'

今回の調査では30ドメイン中28件がこのどちらかでした。「未設定のドメインを調べる」というこのツールの主目的で必ず踏むバグです。j.get('Answer', [])にします。

3. Content-Typeを完全一致で比べない

text/plainの確認をctype == 'text/plain'と書くと、パラメータが付いた瞬間に偽になります。

'text/plain; charset=utf-8'  -> 完全一致だと False
'TEXT/PLAIN; charset=UTF-8'  -> 完全一致だと False

RFC 8461自身が「Content-Typeの追加パラメータは無視する」と明記しているので、セミコロンで切って小文字化してから比較します。

4. urllibは黙ってリダイレクトを追跡する(仕様の読み直しで発見)

これは実行ではなくRFCの読み直しで見つけました。前述のとおりポリシー取得では3xxを追跡してはいけませんが、urllibの既定は追跡です。素朴に書いたクライアントは、リダイレクト先から取ってきたポリシーを「正しく取得できた」と報告してしまいます。HTTPRedirectHandlerを差し替えて、3xxはエラーとして報告するようにしました。

なお「CRLF区切りだから改行の扱いで壊れるはず」と予想して検証もしましたが、これは外れました。strip()が\rを吸収するため実害はありません。事前の予想が外れた例として記録しておきます。

自ドメインに導入する手順

実務では次の順番が安全です。

  • (1) TLS-RPTを先に置く。レポートを受け取るだけなので配送への影響はゼロです。_smtp._tlsにTXTでv=TLSRPTv1; rua=mailto:[email protected]のように受信先を書きます。自ドメイン宛の配送でTLSがどれだけ失敗しているかが先に見えるようになります
  • (2) ポリシーホストを用意する。mta-sts.<ドメイン>のHTTPSで/.well-known/mta-sts.txtをtext/plainで返せるようにします。中身は静的ファイル1個なので、サブドメインを1つ用意して置くだけです。証明書がこのホスト名で有効であること、リダイレクトを挟まないことに注意します
  • (3) mode: testingで公開する。max_ageは最初は短め(86400=1日など)にして、TLS-RPTのレポートで正当なメールが落ちていないことを確認します
  • (4) enforceに上げ、max_ageを伸ばす。RFCの想定は数週間以上です。以後、MXを変更するときはポリシーファイルの更新とTXTレコードのid変更を必ずセットで行います。max_ageが長いほど、古いポリシーが送信側にキャッシュされている期間も長くなります

まとめ

  • MTA-STSは受信側が公開し送信側が守る、SMTP配送経路のTLS強制の仕組み。SPF・DKIM・DMARCとは守る対象が違う
  • 主要30ドメインの実測では導入は2件(google.com、microsoft.com)。日本企業系20ドメインはゼロだった(2026年8月14日時点)
  • ポリシー取得には「証明書はmta-sts.ホスト名で有効」「3xx追跡禁止」「HTTPキャッシュ禁止」という見落としやすい規定がある
  • 実装の落とし穴は、mxの複数行・DoHのAnswer欠落・Content-Typeのパラメータ・urllibのリダイレクト既定、の4つだった
  • 導入はTLS-RPT→testing→enforceの順で。MX変更時はidの変更を忘れない

参考:RFC 8461(SMTP MTA Strict Transport Security)Section 3.1〜3.3、RFC 8460(SMTP TLS Reporting)

関連記事

タイトルとURLをコピーしました