開発ブログ

BLOG

フォーラムの投稿を、悪意のある Markdown でも安全に表示する

フォーラムの本文を react-markdown で表示するときに、生の HTML を描かず、javascript: などの危ない行き先のリンクを通さず、ほかのサイトの画像を読み込まないようにした。試しに何千段も重ねた引用を書くとページが表示できなくなったため、24 段より深い入れ子は、中の文字を 1 つの段落にまとめて残す。

公開 2026年10月19日

Metronome.fm に、練習のことを話し合うフォーラムをリリースしました。スレッドや返信の本文は Markdown で書けるようにしました。

本文はサインインした人なら誰でも書けて、開いた人のブラウザで表示します。悪意のある本文を書かれても、開いた人のブラウザで何かが動いたり、開いただけで情報が外に出たりしないようにする必要があります。この記事では、本文の欄の作りと、何を表示して何を表示しないかの決め方、何千段もの入れ子の引用でページが表示できなくなった件について書きます。

書く欄とプレビュー

本文の欄は、GitHub のコメントと同じく「書く」と「プレビュー」のタブで切り替えます。プレビューは、投稿したあとのスレッドと同じ部品で表示しているので、プレビューで見たとおりに投稿されます。

スレッドを立てる画面の本文の欄。1 は「プレビュー」のタブ、2 は太字・リスト・引用を表示したプレビュー
本文の欄を「プレビュー」に切り替えたところ
  1. 「書く」と「プレビュー」のタブ
  2. 投稿したときと同じ部品で表示したプレビュー

Markdown の変換には react-markdown を使い、remark-gfm で表・取り消し線・タスクリスト・URL の自動リンクも書けるようにしています。Markdown では、ふつう 1 回の改行は無視されて前の行とつながります。remark-breaks を使い、1 回の改行もそのまま改行として表示します。GitHub のコメントと同じ書き心地にしたかったためです。

本文に書いた HTML は表示しない

Markdown の中には HTML のタグをそのまま書けます。<script> や、画像の読み込みに失敗したときに動く onerror を付けた <img> をそのまま表示すると、開いた人のブラウザでスクリプトが動きます。

フォーラムでは、本文に書いた HTML を一切表示しません。react-markdown の skipHtml で HTML の部分を捨てています。HTML をそのまま差し込む dangerouslySetInnerHTML や、HTML を表示できるようにする rehype-raw も使っていません。HTML を許したうえで危ない部分だけを消す方法もありますが、消し漏れが 1 つでもあれば、そこからスクリプトを動かされます。表示しないと決めておけば、消し漏れを気にしなくて済みます。

さらに、Markdown から作る HTML の要素(p・a などのタグ)も、許可リストにあるものだけに限っています。リストにない要素は、タグを捨てて中の文字だけを残します。

const ALLOWED_ELEMENTS = [
  "p", "br", "strong", "em", "del", "a", "ul", "ol", "li",
  "blockquote", "code", "pre", "hr", "h1", "h2", "h3", "h4", "h5", "h6",
  "table", "thead", "tbody", "tr", "th", "td", "img",
  "input", // タスクリスト(- [ ])のチェックボックス
];

<Markdown
  remarkPlugins={[remarkGfm, remarkBreaks, remarkLimitDepth]}
  skipHtml
  allowedElements={ALLOWED_ELEMENTS}
  unwrapDisallowed
  components={components}
>
  {markdown}
</Markdown>

単体テストでは、<script>・<iframe>・<svg> の中のスクリプト・style 属性・ontoggle 付きの <details> などを書いた本文を表示し、できた HTML にスクリプトを動かせるものが残っていないかを見ています。実際のブラウザで画面を操作する E2E テストでも、<script> と javascript: のリンクを含む本文を、プレビューと投稿後の両方で表示し、スクリプトが動かないことを確かめています。

本文の欄の「書く」のタブ。1 は script のタグの行、2 は javascript: のリンクの行
HTML のタグと javascript: のリンクを書いた本文
  1. script のタグ
  2. javascript: を行き先にしたリンク
同じ本文のプレビュー。script のタグの行は消え、1 の「押す」はリンクにならず文字だけで出ている
同じ本文のプレビュー
  1. リンクにならず、文字だけが出る。script のタグの行は表示されずに消える

リンクの行き先を限る

Markdown のリンク [文字](URL) の URL も、書いた人が自由に決められます。javascript: や data: で始まる URL をそのままリンクにすると、押した人のブラウザでスクリプトを動かせます。

react-markdown にも、はじめから URL を限る処理(urlTransform)があります。ただ、許すのが http(s)・mailto・irc・xmpp と相対の URL です。//evil.example のように // で始まる URL は相対の URL として許されますが、ブラウザは別のサイトを指すものとして読みます。そこで、リンクを表示する部品で URL をもう一度確かめています。許すのは次のものだけで、それ以外はリンクにせず文字だけを出します。

  • http(s) と mailto
  • サイト内のパス(/ で始まり、// でも /\ でもないもの)
  • ページ内の #

許すかどうかを決める前に、URL の前後の制御文字と空白、途中のタブ・改行を取り除いています。ブラウザは URL を読むときにこれらを無視します。取り除かずに確かめると、java\tscript:(\t はタブ)のような URL が確かめをすり抜けたうえで、ブラウザには javascript: として読まれてしまいます。

const LEADING_OR_TRAILING_CONTROL = new RegExp(
  String.raw`^[\u0000- ]+|[\u0000- ]+$`,
  "g",
);
const TAB_OR_NEWLINE = /[\t\n\r]/g;

export const sanitizeForumUrl = (url: string): string | null => {
  const trimmed = url
    .replace(LEADING_OR_TRAILING_CONTROL, "")
    .replace(TAB_OR_NEWLINE, "");
  if (trimmed === "") return null;

  if (trimmed.startsWith("#")) return trimmed;
  // // と /\ はブラウザが別のホストとして読むので外す
  if (
    trimmed.startsWith("/") &&
    !trimmed.startsWith("//") &&
    !trimmed.startsWith("/\\")
  ) {
    return trimmed;
  }

  try {
    const parsed = new URL(trimmed);
    if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) return null;
    // https://metronome.fm@evil.example/ は行き先を偽って見せるのに使われる
    if (parsed.username || parsed.password) return null;
    return parsed.href;
  } catch {
    return null;
  }
};

https://metronome.fm@evil.example/ のようなユーザー名付きの URL も通しません。先頭だけを見ると metronome.fm へのリンクに見えますが、実際の行き先は evil.example です。

サイトの外へのリンクは新しいタブで開き、rel="nofollow ugc noopener noreferrer" を付けています。このうち nofollow と ugc は、リンクを貼るだけのスパムが、このサイトからのリンクで検索の評価を得られないように付けています。ugc は、利用者が書いたリンクであることを検索エンジンに伝える値です。

外の画像は読み込まない

Markdown の画像 ![説明](URL) は、URL が外のサーバーを指していても、開いた人のブラウザがそのサーバーへ画像を取りに行きます。取りに行ったときに、開いた人の IP アドレスや開いた時刻が相手のサーバーに届きます。1 ピクセルの画像を貼っておけば、書いた人は、スレッドがいつどこから開かれたかを集められます。

そこで、外のサーバーの画像は読み込まず、「画像: 説明」という文字を代わりに出しています。フォーラムには画像を上げることもでき、上げた画像は決まった置き場所に、決まった形の名前で置きます。表示するのは、この置き場所と名前の形に合う画像だけです。その画像も、Cloudflare の画像変換を通して縮め、メタデータを捨ててから出しています。

外の URL の画像を書いた本文のプレビュー。1 は画像の代わりに出た「画像: 練習の様子」の文字
外のサーバーの画像は読み込まない
  1. 画像の代わりに出す文字

見出しは h3・h4 に下げる

スレッドのページでは、h1 はスレッドの題です。投稿の中に # で h1 を書かれると、ページの見出しの並びがおかしくなり、読み上げで見出しをたどる人が迷います。

投稿の中の見出しは、# と ## を h3 に、### 以下を h4 にして表示しています。同じ h3 でも # は ## より文字を少し大きくしているので、書いた人には大きな見出しと小さな見出しの違いが伝わります。

何千段もの入れ子の引用でページが表示できなくなった

本文の上限は 5,000 文字です。試しに上限いっぱいまで > を並べた本文を表示すると、ページが表示できなくなっていました。1 件の投稿でスレッドを開けなくできるので、嫌がらせに使えます。

> を重ねると、引用の中に引用が入った入れ子が 5,000 段近くできます。Markdown を表示する処理は、本文から作った構文木を再帰でたどるので、入れ子が深すぎると呼び出しが積み重なり、スタックが足りなくなります。

そこで、構文木の入れ子を 24 段までに抑える remark のプラグイン(remarkLimitDepth)を作りました。24 段より深い部分は、中の文字だけを 1 つの段落にまとめます。引用やリストを 24 段も重ねる投稿はまずないので、ふだんの本文の見た目は変わりません。

export const MAX_MARKDOWN_DEPTH = 24;

export const limitMarkdownDepth = (tree: MarkdownNode) => {
  // 再帰を使わず、自前のスタックで木をたどる
  const stack = [{ node: tree, depth: 0 }];
  while (stack.length > 0) {
    const entry = stack.pop();
    if (!entry) break;
    const { node, depth } = entry;
    if (!node.children) continue;
    if (depth >= MAX_MARKDOWN_DEPTH) {
      // 深すぎる部分は、中の文字を 1 つの段落にまとめる
      node.children = [
        {
          type: "paragraph",
          children: [{ type: "text", value: collectText(node) }],
        },
      ];
      continue;
    }
    node.children.forEach((child) =>
      stack.push({ node: child, depth: depth + 1 }),
    );
  }
};

深い木を浅くするための処理が、深い木で落ちては意味がありません。そのため、このプラグインも、中の文字を集める処理も、再帰を使わず、自前のスタックで木をたどります。

> を 100 個重ねた本文のプレビュー。1 は 24 段で止まった引用の線
> を 100 個重ねた引用
  1. 引用の線は 24 段で止まり、その奥は文字だけにまとめる

単体テストでは、深い引用のほかにも重くなりそうな入力として、深いリスト、引用とリストの組み合わせ、閉じていない強調の記号や括弧・リンク・バッククォート、列の多い表を、それぞれ上限の 5,000 文字いっぱいで表示しています。落ちずに 3 秒以内に表示できることと、深すぎる部分の文字が捨てられずに残ることを確かめています。

この記事を共有

X でポストはてなブックマーク