同じURLなのに Node だけ 403 — 既定の User-Agent が「node」の3文字だった
403 と「データが無い」と「未登録」は、全部 別の事実

ドメインの登録情報(登録日・有効期限・ネームサーバ)をまとめて引く処理を書いていて、手元では全滅、ブラウザでは普通に見えるという状態になった。返ってきていたのは HTTP 403 で、10ドメイン試して10件とも403。
レート制限かと思って間隔を空けても変わらない。IPを疑う前に、送っているリクエストのほうを1つずつ変えて測った。原因は3文字の文字列だった。
同じURL・同じ時刻で、403 と 200 の両方が返った
対象は https://rdap.org/domain/github.com。RDAP(WHOIS の後継プロトコル)の共有中継サーバで、どのTLDでも同じ入口から引けるので便利に使われている。4通りの条件で叩いた結果がこれ。
| 条件 | 応答 | 行き先 |
|---|---|---|
| 既定UA / リダイレクト追わない | 403 | — |
| 既定UA / リダイレクト追う | 403 | — |
| ブラウザのUA / 追わない | 302 | rdap.verisign.com/com/v1/domain/github.com |
| ブラウザのUA / 追う | 200 | 同上(最終URL) |
User-Agent だけを差し替えて、403 と 200 が分かれた。応答ヘッダの server は cloudflare。つまりアプリケーションではなく、その手前のエッジで弾かれている。
ついでに curl でも叩いたら、こちらは302が返った(cf-cache-status: HIT、Age: 53300=15時間前のキャッシュ)。同じURLに対して、クライアントによって403・302・200 が返る状態だった。
Node の fetch が送っている既定 User-Agent は「node」
何を送っているのか確かめるために、ヘッダをそのまま返すサービスへ投げた。
node fetch の既定UA = "node"
node バージョン v24.13.1
node。バージョンも何も付かない3文字だ。ブラウザやbotの一覧と突き合わせる仕組みから見れば、素性のわからないクライアントとして扱われても不思議はない。
ここで言いたいのは「UAを偽装しろ」ではない。偽装で通ることは今回 確かめたが、それは相手が拒否している判断を回避しているだけで、明日も通る保証は無い。分かったのは「403 の原因が自分の側の既定値にあることがある」という点で、これはIPやレート制限を疑い始める前に1回で確認できる。
実際、自分の道具では素性を明かすUA(製品名+連絡先URL)を必ず付けるようにしている。相手が拒否したいなら拒否できるようにしておくのが筋で、そのうえで拒否されたら「拒否された」と記録する。
403 が返ったとき、どの順に疑うか
今回の切り分けで使った順番を書いておく。上から順に、かかる手間が小さい。
- 自分が何を送っているかを見る。ヘッダをそのまま返すサービスへ同じコードで投げれば、User-Agent も Accept も一覧で出る。ここで「node」のような既定値が見つかれば、ほぼこれが原因だ(1分で終わる)
- User-Agent だけを変えてもう1回投げる。他は一切変えない。403 が 200 になるなら、相手はクライアントの名前だけを見て判断している
- Accept ヘッダを疑う。RDAP なら
application/rdap+json、JSON API ならapplication/json。これが無いとHTMLを返す実装もある(403ではなく「中身が違う」形で壊れる) - 同じURLを別のクライアントで叩く。
curlとブラウザで結果が割れたなら、IPやレート制限ではない。今回はcurl=302 / Node=403 / ブラウザUAのNode=200 と3通りに割れた - ここまで全部同じ結果なら、はじめてIPや回数制限を疑う。応答ヘッダに
Retry-AfterやX-RateLimit-*が付いていないかを見る
私はこの順番を逆から始めて時間を無駄にした。「403=ブロックされた=IPか頻度の問題」と反射的に決めつけていたからだ。実際は1番目で終わる話だった。相手の都合を調べる前に、自分が何を送っているかを1回 目で見る。
そもそも中継サーバを経由しない
UAで通せることは分かったが、採用しなかった。1つの共有入口に全リクエストを集めると、その入口の都合(レート制限・キャッシュ・エッジの判定)が自分の処理の成否になる。
RDAP には、そもそも中継を使わずに済む仕組みがある。IANA がTLDごとの担当RDAPサーバの一覧を公開している(RFC 7484)。
$ curl -s https://data.iana.org/rdap/dns.json
publication : 2026-09-09T23:00:03Z
services : 590 件
カバーするTLD: 1,200
この表を引いて、担当サーバに直接聞く。
.com -> https://rdap.verisign.com/com/v1/
.uk -> https://rdap.nominet.uk/uk/
そして直接聞くほうは、既定の User-Agent("node")のままで200が返った。github.com・apify.com・bbc.co.uk・gov.uk、いずれも登録日つきのJSONが返ってくる。
github.com registration=2007-10-09 expiration=2028-10-09
bbc.co.uk registration=1994-12-13 expiration=2034-12-13
つまり403は「RDAPが自動アクセスを拒んでいる」わけではなく、共有の中継サーバの前段の話だった。中継を外したら、条件を変えずに通った。
なお以前(この道具を作っていた8月の測定)は、同じ共有入口が20ドメイン中15件に 429(回数制限)を返していて、登録情報が取れたのは5件だけだった。今回は429ではなく403。壊れ方が日によって違うのも、共有入口に依存したくない理由になっている。
.jp は、そのブートストラップに載っていない
一覧を引いたついでに、載っているTLDを数えた。1,200件。そこで .jp を探すと、無い。
edu? false eu? false jp? false
com? true uk? true
.jp・.eu・.edu は、IANA のブートストラップに担当RDAPサーバが登録されていない。.jp についてはレジストリが登録者向け・指定事業者向けのRDAPサービスを案内しているが、この公開の一覧から辿れる公開エンドポイントは今回 見つけられなかった(rdap.jprs.jp は名前解決せず、jprs.jp/rdap/... は404)。
ここで大事なのは、「引けなかった」を「登録されていない」と書かないこと。.jp のドメインを RDAP で引こうとすれば、担当サーバが見つからない。これは対象のドメインの話ではなく、こちらが聞ける先が無いという話だ。出力では前者と後者を必ず分ける。
200 が返っても、登録日が入っていないことがある
同じレジストリ(Nominet、.uk)に2件 聞いた結果を並べる。
bbc.co.uk -> HTTP 200
events: registration=1994-12-13T03:49:48Z
expiration=2034-12-13T03:49:48Z
last changed=2025-10-29T03:51:11Z
gov.uk -> HTTP 200
events: last update of RDAP database=2026-09-12T16:48:20Z ← 登録日が無い
gov.uk は200で正常に応答しているのに、registration のイベントが入っていない。同じレジストリが、あるドメインには1994年の登録日を返し、別のドメインには返さない。
これを「登録日: 空欄」として表に出すと、バグと区別がつかない。だからこちらの出力では、登録日が取れなかった行に理由を書く列(registrationLookupError)を持たせて、「このレジストリはこのドメインの登録日を公開していない」と文章で入れている。空欄は情報がゼロだが、理由が1行あれば、読んだ人はそこで調査を止められる。
サブドメインは404になる。「未登録」と書いてはいけない
もう1つ、実測で確かめた挙動。
badssl.com -> HTTP 200
expired.badssl.com -> HTTP 404
www.github.com -> HTTP 404
RDAP に記録があるのは登録可能な単位のドメインだけで、サブドメインには記録が無い。www.github.com を聞くと404。この404を「未登録」として出力したら、それは嘘になる。github.com は当然 登録されている。
対処は、登録可能な親ドメインまで遡って聞き直し、どのドメインについての答えなのかを列として一緒に返すこと。「登録されている: true、答えたドメイン: badssl.com」と書いてあれば、expired.badssl.com を渡した人も何が起きたか分かる。
結局、守ったのは「null と false を混ぜない」の一点だった
ここまでの実測を全部並べると、「取れなかった」には少なくとも5種類あった。
- こちらのUAが拒否された(403)
- 中継サーバの回数制限に当たった(429)
- そのTLDの担当サーバが存在しない(
.jp・.eu・.edu) - レジストリがその項目を公開していない(gov.uk の登録日)
- そのドメインは登録可能な単位ではない(サブドメインの404)
そして、これらのどれでもない6番目が「本当に未登録」だ。6種類を1つの空欄に潰すと、受け取った側には区別する手段が無くなる。だから「登録されているか」の列は true / false / null の3値にして、null(聞けなかった)には必ず理由の文章を添える。
この形は、件数の上限でも同じことをしている。GitHub の検索APIが7,705件 一致と言いながら1,000件で打ち切る話 では「0件」が同じ問題を起こしていた。HTMLを取ったのに本文が17文字だった話 でも同じだった。空欄が2つ以上の意味を持った瞬間に、そのデータは読めなくなる。
この形で作った道具
上の判断を全部入れたものを公開している。Bulk WHOIS & DNS Checker(Apify)は、ドメインの一覧を渡すと1ドメイン1行で、登録情報(IANAのブートストラップを読んで担当レジストリへ直接問い合わせる)・ネームサーバ・MX・SPF・DMARC・TLS証明書の期限を返す。取れなかった項目には null と理由が入る。SPFレコードが2つあるとき(SPFが静かに無効になる設定ミス)は件数を出し、なりすまし対策の強さは実際に受信側が守る DMARC のポリシーから出している。
筆者: GRAMSHIFT — Android アプリと自動化ツールを個人開発。この記事の数値は 2026-09-13 に Node v24.13.1 から実際にHTTPリクエストを投げて取得したもので、測定スクリプトと本文の下書きは Claude Code との作業の中で書いている。相手側の挙動(とくに403の出方)は日によって変わるため、結果ではなく測り方を残すようにしている。