技術ログ

同じURLなのに Node だけ 403 — 既定の User-Agent が「node」の3文字だった

公開: 2026-09-13 · 著者: GRAMSHIFT

403 と「データが無い」と「未登録」は、全部 別の事実

同じURLなのに Node だけ 403 — 既定の User-Agent が「node」の3文字だった

ドメインの登録情報(登録日・有効期限・ネームサーバ)をまとめて引く処理を書いていて、手元では全滅、ブラウザでは普通に見えるという状態になった。返ってきていたのは 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 / 追わない302rdap.verisign.com/com/v1/domain/github.com
ブラウザのUA / 追う200同上(最終URL)

User-Agent だけを差し替えて、403 と 200 が分かれた。応答ヘッダの servercloudflare。つまりアプリケーションではなく、その手前のエッジで弾かれている。

ついでに curl でも叩いたら、こちらは302が返った(cf-cache-status: HITAge: 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 が返ったとき、どの順に疑うか

今回の切り分けで使った順番を書いておく。上から順に、かかる手間が小さい。

  1. 自分が何を送っているかを見る。ヘッダをそのまま返すサービスへ同じコードで投げれば、User-Agent も Accept も一覧で出る。ここで「node」のような既定値が見つかれば、ほぼこれが原因だ(1分で終わる)
  2. User-Agent だけを変えてもう1回投げる。他は一切変えない。403 が 200 になるなら、相手はクライアントの名前だけを見て判断している
  3. Accept ヘッダを疑う。RDAP なら application/rdap+json、JSON API なら application/json。これが無いとHTMLを返す実装もある(403ではなく「中身が違う」形で壊れる)
  4. 同じURLを別のクライアントで叩くcurl とブラウザで結果が割れたなら、IPやレート制限ではない。今回は curl=302 / Node=403 / ブラウザUAのNode=200 と3通りに割れた
  5. ここまで全部同じ結果なら、はじめてIPや回数制限を疑う。応答ヘッダに Retry-AfterX-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種類あった。

  1. こちらのUAが拒否された(403)
  2. 中継サーバの回数制限に当たった(429)
  3. そのTLDの担当サーバが存在しない(.jp.eu.edu
  4. レジストリがその項目を公開していない(gov.uk の登録日)
  5. そのドメインは登録可能な単位ではない(サブドメインの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の出方)は日によって変わるため、結果ではなく測り方を残すようにしている。

よくある質問