SPFの10回制限はinclude先まで数える:Node.jsで再帰計測

SPF レコードを書くと「DNS 参照は10回まで」という制限に必ず行き当たる。ここで間違えやすいのは、10回を自分の TXT レコードに並んだ include の数だと思ってしまうことだ。実際には include の先のレコードがさらに include や mx を持っていれば、それも同じ10回の枠から引かれる。レコード単体を眺めて「まだ4回」と思っていても、評価全体では上限を超えていることがある。

この記事では、RFC 7208 が何を数えると決めているかを確認したうえで、include と redirect の先までたどって参照回数を数える小さな Node.js スクリプトを作り、架空のレコードと実在のドメインで数えてみる。

貼り付けたレコードの構文を分解して数えるだけなら SPF / DMARC レコード構文チェック(ハシトシステム) がある。このツールは DNS を引かないので、数えられるのは貼ったレコード自身の分だけである。その先を数えるのがこの記事のスクリプトの役目になる。

RFC 7208 が数えるもの

SPF の仕様 RFC 7208 の 4.6.4 節(DNS Lookup Limits)で確認できた規則を並べる。

項目 内容 出典
数える項目 include・a・mx・ptr・exists の各メカニズムと redirect 修飾子 RFC 7208 4.6.4
上限 SPF 評価の間に、上の項目の合計を10に制限しなければならない(MUST)。超えたら permerror RFC 7208 4.6.4
数えない項目 all・ip4・ip6 と exp 修飾子(exp は評価の後で引かれるので対象外) RFC 7208 4.6.4
mx の追加制限 1つの mx が引く MX レコードそれぞれについて、A/AAAA の問い合わせは10件まで。超えたら permerror RFC 7208 4.6.4、5.4
void lookup 応答が0件または NXDOMAIN になった参照は2回までに制限すべき(SHOULD)。超えたら permerror RFC 7208 4.6.4
redirect と all レコードのどこかに all があれば redirect は無視しなければならない(MUST) RFC 7208 6.1

「include 先も数える」点は RFC の「SPF 評価の間に」という書き方から読めるが、Google Workspace のヘルプ「Troubleshoot SPF issues」ははっきり書いている。ネストした参照も上限に数えられ、include したドメインがさらに別のドメインを include していれば、その分も合計に入る、という説明である。ヘルプは上限を超えるとSPFの検証が失敗するとも書いている。

実際に試す

前提は Node.js(ここでは Windows 11 上の v24.11.1)。外部パッケージは使わず、標準の node:dns の resolveTxt で TXT レコードを引く。次のファイルを spf-count.mjs として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// spf-count.mjs : SPF レコードの DNS 参照回数を include / redirect の先までたどって数える
// 使い方: node spf-count.mjs <ドメイン> ... 実際の DNS を引く
// node spf-count.mjs --demo ... 下の DEMO 表だけで数える(ネット不要)
import { promises as dns } from 'node:dns';

const LOOKUP_TERMS = new Set(['include', 'a', 'mx', 'ptr', 'exists']);

// 架空ドメインの TXT 表(example.* は RFC 2606 の予約名)
const DEMO = {
'example.com': 'v=spf1 include:_spf.example.net include:mail.example.org mx a ip4:203.0.113.0/24 ~all',
'_spf.example.net': 'v=spf1 include:_a.example.net include:_b.example.net include:_c.example.net -all',
'_a.example.net': 'v=spf1 ip4:198.51.100.0/24 -all',
'_b.example.net': 'v=spf1 ip4:198.51.100.128/25 include:_d.example.net -all',
'_c.example.net': 'v=spf1 ip6:2001:db8::/32 -all',
'_d.example.net': 'v=spf1 ip4:192.0.2.0/24 -all',
'mail.example.org': 'v=spf1 include:_e.example.org include:_f.example.org a mx -all',
'_e.example.org': 'v=spf1 ip4:192.0.2.64/26 -all',
'_f.example.org': 'v=spf1 ip4:192.0.2.128/26 -all',
};

async function getSpf(domain, demo) {
if (demo) return DEMO[domain] ?? null;
try {
const rows = await dns.resolveTxt(domain); // 1レコードが複数文字列に分かれていることがある
const spf = rows.map(r => r.join('')).filter(s => /^v=spf1(\s|$)/i.test(s));
if (spf.length > 1) throw new Error(`${domain}: v=spf1 が ${spf.length} 個(permerror)`);
return spf[0] ?? null;
} catch (e) {
if (e.code === 'ENOTFOUND' || e.code === 'ENODATA') return null; // void lookup
throw e;
}
}

async function walk(domain, ctx, depth = 0) {
const rec = await getSpf(domain, ctx.demo);
const pad = ' '.repeat(depth);
if (rec === null) { ctx.voids++; console.log(`${pad}${domain} (SPF なし: void lookup)`); return; }
console.log(`${pad}${domain}`);
const terms = rec.trim().split(/\s+/).slice(1);
const hasAll = terms.some(t => /^[+\-~?]?all$/i.test(t));
for (const t of terms) {
const m = t.match(/^[+\-~?]?([a-z]+)(?::(.+))?$/i);
const mod = t.match(/^(redirect)=(.+)$/i);
if (mod) {
if (hasAll) { console.log(`${pad} ${t} -> all があるので無視(RFC 7208 6.1)`); continue; }
ctx.count++; console.log(`${pad} ${t} [${ctx.count}]`);
await walk(mod[2], ctx, depth + 1);
continue;
}
if (!m || !LOOKUP_TERMS.has(m[1].toLowerCase())) continue; // ip4 / ip6 / all / exp は数えない
ctx.count++;
console.log(`${pad} ${t} [${ctx.count}]`);
if (m[1].toLowerCase() === 'include') await walk(m[2], ctx, depth + 1);
}
}

const arg = process.argv[2];
const demo = arg === '--demo';
const ctx = { count: 0, voids: 0, demo };
await walk(demo ? 'example.com' : arg, ctx);
console.log(`\nDNS 参照: ${ctx.count} / 10 void: ${ctx.voids} / 2`);
console.log(ctx.count > 10 || ctx.voids > 2 ? '結果: permerror になる' : '結果: 上限内');

架空のレコードで数える(ネット不要)

1
node spf-count.mjs --demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
example.com
include:_spf.example.net [1]
_spf.example.net
include:_a.example.net [2]
_a.example.net
include:_b.example.net [3]
_b.example.net
include:_d.example.net [4]
_d.example.net
include:_c.example.net [5]
_c.example.net
include:mail.example.org [6]
mail.example.org
include:_e.example.org [7]
_e.example.org
include:_f.example.org [8]
_f.example.org
a [9]
mx [10]
mx [11]
a [12]

DNS 参照: 12 / 10 void: 0 / 2
結果: permerror になる

example.com のレコードそのものに並んでいる参照は include 2つと mx・a の4つだけである。ところが2つの include の先がそれぞれ4つずつ参照を持っているので、評価全体では12回になり、上限を2回超える。レコード単体を見て「4回だから余裕がある」と判断すると、ここを見落とす。

実在のドメインで数える

引数にドメインを渡すと実際の DNS を引く。2026年10月23日に手元で実行した結果を載せる。SPF レコードは各社が随時変えるので、読者が実行すると数字は変わり得る。

1
node spf-count.mjs github.com
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
github.com
include:spf.protection.outlook.com [1]
spf.protection.outlook.com
include:_netblocks.google.com [2]
_netblocks.google.com
include:_netblocks2.google.com [3]
_netblocks2.google.com
include:mail.zendesk.com [4]
mail.zendesk.com
include:_spf.salesforce.com [5]
_spf.salesforce.com
exists:%{i}._spf.mta.salesforce.com [6]
include:servers.mcsv.net [7]
servers.mcsv.net
include:mktomail.com [8]
mktomail.com
include:sendgrid.net [9]
sendgrid.net
include:ab.sendgrid.net [10]
ab.sendgrid.net

DNS 参照: 10 / 10 void: 0 / 2
結果: 上限内

このときの github.com の SPF レコードには include が8つ並んでいた。include 先のうち _spf.salesforce.com が exists を1つ、sendgrid.net が include:ab.sendgrid.net を1つ持っていて、合計はちょうど10回だった。上限内ではあるが、include 先のどれかが参照を1つ増やせば permerror になる位置にある。自分のレコードを変えていなくても、取り込んでいる送信サービス側の変更で上限を超え得る、というのがこの制限のやっかいなところである。

redirect の例として gmail.com も数えた。レコードは v=spf1 redirect=_spf.google.com で、_spf.google.com 側は ip4・ip6 と ~all だけだったので、合計は1回だった。

1
2
3
4
5
6
gmail.com
redirect=_spf.google.com [1]
_spf.google.com

DNS 参照: 1 / 10 void: 0 / 2
結果: 上限内

このスクリプトが数えないもの

手元で確かめる用途に絞ったので、仕様のすべては実装していない。

  • mx が引く MX ホストごとの A/AAAA 問い合わせ(1つの mx につき10件まで)は調べていない。MX レコードが多いドメインを mx で指しているなら、dns.resolveMx で件数を別に確かめる。
  • exists や include のマクロ(%{i} など)は展開しない。exists は SPF レコードではなく A レコードを引くので、ここでは1回として数えて先はたどらない。
  • void lookup は include 先・redirect 先に SPF が無かった場合だけを数えている。a や mx の応答が0件だった場合は数えていない。

ハシトシステムのツールの数え方について

SPF / DMARC レコード構文チェック は、貼り付けたレコードを空白で区切り、include・a・mx・ptr・exists・redirect を1回ずつ数える。ip4・ip6・all・exp は数えない。数え方は RFC 7208 4.6.4 の一覧と同じで、8回以上で注意、11回以上で上限超過を出す。

上の github.com のレコードを貼ると、表示は「8 / 10」で注意が出る。スクリプトで include 先まで数えると10回なので、ツールの数字は「この先の分を足す前の下限」として読むのが正しい。ツールのページにも、include の先も参照を消費するので実際の合計はこれより多くなる、と書いてある。

もう1点、ツールはレコードに all があっても redirect= を1回として数える。RFC 7208 6.1 では all があれば redirect は無視されるので、この場合は実際より1回多く出る。安全側にずれるだけなので実害は小さいが、all と redirect を両方書いているならどちらかを消す方がよい。redirect が効いていないことに気づいていない書き方であることが多いからである。

まとめ

  • SPF の DNS 参照10回は、include 先・redirect 先のレコードが使う参照も合算する(RFC 7208 4.6.4、Google Workspace ヘルプ)。
  • 数えるのは include・a・mx・ptr・exists・redirect。ip4・ip6・all・exp は数えない。
  • レコード単体で4回でも、include 先次第で全体は12回になり permerror になる。
  • 2026年10月23日の実測で github.com はちょうど10回だった。取り込んでいる送信サービスの変更だけで上限を超え得る。
  • all があると redirect は無視される。

参照した一次情報:

ツール: SPF / DMARC レコード構文チェック(ハシトシステム)。SPF と DMARC の TXT レコードを貼ると、メカニズムとタグを1件ずつ分解して意味を表示し、貼ったレコード自身が使う DNS 参照の回数と、+all や ptr などの危険な指定を指摘する。処理はブラウザ内で完結する。


SPFの10回制限はinclude先まで数える:Node.jsで再帰計測
https://blog.hashito.biz/2026/10/23/spf-10-dns-lookup-limit-nested-include-node-recursive-count/
著者
hashito
作成日
2026年10月23日
著作権