記事一覧

blog

本番CSPで消えた配色をdist実測で捕まえた

  • Claude Code
  • CSP
  • Astro

Console Kludge の public/_headers には style-src-attr 'none' を設定してある。インラインの style="..." 属性を全部ブロックするポリシーで、scoped CSS を外部ファイル化する inlineStylesheets: 'never' と組み合わせて style-src 'self' だけで通す構成にしている。

問題は shiki だった。Astro の markdown ハイライトは shiki 任せで、既定テーマ(github-dark)はトークンごとに style="color:#F97583" のようなインライン color を直接出力する。ローカルで astro devastro preview を見ている分にはコードブロックはちゃんと色付いて見える。CSPの設定は入れたから大丈夫なはずだ、と自分では当然のように思っていた。

なぜローカルで気づけなかったか

astro preview は本当に CSP を見ているのか、念のため中身を追ってみた。ビルド成果物をローカルの HTTP サーバーで返しているだけで、CSP ヘッダーを付与している形跡はどこにもなかった。_headers は Cloudflare Pages がデプロイ時に読むファイルで、Astro のプレビューサーバーの管轄外にある。つまり preview は「CSP が無い状態」の HTML を見ていたことになる。壊れているようには見えない。

本番にはまだ一度も出していなかった。ただ、公開前に CSP まわりを詰めるセキュリティレビューがあり、2026-07-21、astro build の成果物である dist を自分の目で読んでみることにした。生成済み HTML から style= を数えると、42 箇所も残っていた。このまま style-src-attr 'none' の下でブラウザに読ませれば、インライン style は全部無視される。キーワードもコメントも文字列も同じ色になり、エラーも出さずに配色情報だけが消える。preview で見えていた色は、本番の挙動を何も保証していなかった。

直し方: styleをクラスへ変換する

shiki は transformers でノードを加工できる。astro.config.mjs に、pre/code/span それぞれから style 属性を剥がし、意味クラス(sh-accent / sh-dim / sh-italic)へ置き換えるトランスフォーマーを足した。

const ACCENT_TOKENS = new Set(['keyword', 'function', 'link', 'inserted']);
const DIM_TOKENS = new Set(['comment', 'punctuation', 'deleted']);
const styleToClassTransformer = {
  name: 'ck-style-to-class',
  pre(node) { delete node.properties.style; },
  code(node) { delete node.properties.style; },
  span(node) {
    const style = String(node.properties?.style ?? '');
    delete node.properties.style;
    const classes = [];
    const token = style.match(/--terminal-code-token-([a-z-]+)/)?.[1];
    if (token && ACCENT_TOKENS.has(token)) classes.push('sh-accent');
    else if (token && DIM_TOKENS.has(token)) classes.push('sh-dim');
    if (/font-style\s*:\s*italic/.test(style)) classes.push('sh-italic');
    if (classes.length) {
      const existing = node.properties.class;
      node.properties.class = [
        ...(Array.isArray(existing) ? existing : existing ? [existing] : []),
        ...classes,
      ].join(' ');
    }
  },
};

前提として、テーマ自体も shikicreateCssVariablesTheme に切り替えている。既定テーマだと色が直接の16進値で出るので token を判定できない。CSS変数テーマなら style="color:var(--terminal-code-token-keyword)" のように変数名が入るので、その名前からトークン種別を正規表現で拾える。色の実体は global.css 側に書く。

.prose pre .sh-accent { color: var(--terminal-accent); }
.prose pre .sh-dim { color: var(--terminal-dim); }
.prose pre .sh-italic { font-style: italic; }

CSSファイル経由の色指定は style-src 'self' で許可される。インライン属性ではなくファイル参照になった時点で、CSP的には別物として扱われる。

dist を実測する検査を足す

コード側を直しても、次に別の記法や shiki のオプション変更でまたインライン style が紛れ込む可能性は残る。ローカルでは気づけないバグなので、「ローカルで確認する」は再発防止策にならない。そこで scripts/verify.py に、ビルド後の site/dist を実際に読んで style= 属性と on*= イベント属性がゼロかを数える検査を足した。

style_attr_re = re.compile(r"""\sstyle\s*=\s*["']""", re.IGNORECASE)
onattr_re = re.compile(r"""\son[a-z]+\s*=\s*["']""", re.IGNORECASE)
for h in htmls:
    text = read_text(h)
    n_style = len(style_attr_re.findall(text))
    n_on = len(onattr_re.findall(text))
    if n_style or n_on:
        attr_bad.append(f"{h.relative_to(dist)}(style={n_style},on*={n_on})")

csp_no_inline_attr という check id で、dist が存在するとき(=ビルド後)だけ走る。ソースを静的に眺めて「style属性を出していないはず」と推測するのではなく、実際に生成された HTML を数える。CSP はヘッダーとHTMLの組み合わせで初めて効いてくる挙動なので、検査もビルド成果物を対象にするしかないと考えた。

教訓

「ローカルで緑」と「本番で正しい」は別物だった。astro preview が通っていても、CSPヘッダーは Cloudflare Pages 側の _headers にしか存在しないので、preview は本番の一部しか再現していない。ヘッダー由来で挙動が変わるものは、ヘッダーが効いた状態を想定してビルド成果物そのものを検査するしかない。今回は本番に一度も出す前に dist を実測して見つけられたが、もし気づかないまま初回デプロイ(2026-07-27)を迎えていたら、配色が消えるだけでは済まなかったかもしれない。同じ仕組みに unsafe-inline 頼みの箇所が他にあれば、機能そのものが壊れていた可能性もある。正直、自分ではまだ気づけていない差分がローカルと本番のあいだに残っているかもしれない、とは思っている。

この検証地獄の息抜きに触れるゲームはKludge Worksから。

STAGE10/50SCORE0BEST0COMBO×1貯蓄0KILLS0
TAP = FOCUS FIRE
強化ツリー倒した数で買う。1つの強化は1回だけ。枝は左から順に開く。
kill -9 agentKILLS 0
kill -9 agent 停止中