blog

CSPを緩めずに常駐ゲームを載せた

  • CSP
  • Astro
  • canvas
  • Claude Code

以前、shiki が吐くインライン style が本番の CSP で全消しになる事故を書いた。あの回で style-src-attr 'none'script-src-attr 'none' を守り切る構成にして、dist を実測する検査まで足した。

そのあとで、全ページの下端に canvas のミニゲームを常駐させることにした。ブラウザのタスクバーみたいに、どのページを開いても画面の一番下で動き続けるやつだ。

正直、着手する前は CSP をどこか1箇所は緩めることになるだろうと身構えていた。canvas を動かすとなると、サイズや位置を JS から element.style.width = ... で書き換えたくなる場面がいくらでもある。あれも style 属性への書き込みだから style-src-attr 'none' に引っかかるはずだ、と。

でも本当にそうか。手を動かす前に仕様を読んでみたら、思い込みだった。

style-src-attr 'none' が止めるのは style 属性 の評価で、具体的には setAttribute('style', ...)element.style.cssText = ... だ。element.style.width = '100px' のような個別プロパティへの代入は、属性を文字列として解釈し直すのではなく CSSOM を直接触るので、このディレクティブの管轄外にある。JS から見た目を動かす経路が全部塞がるわけではない。

つまり守るべき線は「JS でスタイルを触らないこと」ではなく、「style 属性そのものを文字列として書かないこと」と「インラインの <style><script> を1つも出さないこと」の2つだった。だいぶ違う。

結果から言うと、ヘッダーは以前のままで済んだ。

Content-Security-Policy: default-src 'self'; script-src 'self' https://static.cloudflareinsights.com https://plausible.io; style-src 'self'; style-src-attr 'none'; script-src-attr 'none'; img-src 'self' data:; ...

style タグを1つも書かない

ゲーム本体は 1,415 行の .astro コンポーネントで、site/src/layouts/BaseLayout.astro から呼ばれている。レイアウトが1つなので、これで全ページに乗る。

このコンポーネントには <style> タグが1つも無い。数えると 0 だ。ゲームの見た目に必要な CSS は site/src/styles/global.css の 945 行目から 1136 行目までの 192 行に置いてある。

Astro の scoped CSS を使ってもよかった。astro.config.mjsbuild.inlineStylesheets: 'never' にしてあるので、scoped CSS は必ず外部 .css ファイルとして出力され、インライン <style> にはならない。CSP 的には通る。

それでも global.css に寄せたのは、CSS 変数がサイト全体で共有されているからだ。ゲームの配色はサイトの配色から派生していて、切り離すと2箇所で色を管理することになる。同じ値を2箇所に書くと片方が腐る、というのはこのリポジトリで何度か踏んでいる。

<script> はコンポーネントに1つだけある。Astro は既定でこれをバンドルして外部ファイルにするので、生成される HTML にインライン script は出ない。is:inline は使っていない。

canvas も DOM も触らないロジック

ゲームのロジックは .astro の中には書かなかった。site/src/scripts/taskbar-hero/ に7ファイルに分けて置いてある。

hero.ts      475行   相棒の状態機械・物理・自律行動の判断
arsenal.ts   342行   武器10種の定義・ドロップ・拾得・消耗
foes.ts      288行   敵9種の性質
upgrades.ts  209行   強化ツリー5枝20ノード
stages.ts    109行   ステージ進行とボス
balance.ts    49行   バランス定数
impact.ts     34行   ヒットストップとノックバック

合わせて 1,506 行あって、このうち canvas に触るコードは1行も無い。documentwindow も参照しない。乱数は引数で差し替えられるようにしてある。

差し替えられる、と書いたのは正確を期すためで、既定値は Math.random だ。

_rand: () => number = Math.random,

呼び出し側が何も渡さなければ非決定的に動く。「必ず注入させる設計」ではなく「注入できる設計」で、テストのときだけシードつきの乱数を渡している。

分けた理由は CSP ではなくテストだ。canvas の描画結果を検証するのは現実的でないが、状態機械の遷移や当たり判定の距離計算なら vitest でそのまま回せる。実際、この純粋ロジックに対して 883 行のシミュレーションを書いて、18条件を固定5シードで測っている。時刻も DOM もネットワークも見ないので、同じシードを渡せば同じ実行環境で同じ結果になる。

結果として、CSP と相性が良くなったのは副産物だった。ロジックが DOM を触らないので、そもそも style 属性を書く動機が発生しない。canvas のサイズ変更は .astro 側の一箇所で canvas.width / canvas.height を代入するだけだが、これは style でも CSS でもないので、そもそも style-src-attr の管轄外にある。

高さ 45px で成立させる

設計で一番厄介だったのはここだった。

バーの通常時の高さは 84px で、そのうち実際に相棒や敵が動くプレイ領域は 45px しかない。拡大しても 61px だ。縦 45px の中で、ジャンプして、着地して、スライディングで敵弾の下をくぐる。

最初は物理定数を固定値で書いていて、通常時に合わせると拡大時に間延びし、拡大時に合わせると通常時で頭が天井を突き抜けた。なので重力もジャンプ力もノックバック量も、全部プレイ領域の高さからの比率で算出するようにした。ノックバックは高さの 1.6%、といった具合だ。

跳躍の頂点は比率だけでは足りなかった。高さの 0.55 倍で跳ばせると、相棒の背丈のぶんだけ頭が上端を超える。min(高さ × 0.55, 頭上の余白 - 3px) で頭打ちにして、通常45px と拡大61px の両方で「立ったとき足が地面線に一致」「跳躍の頂点でも頭が上端より下」をテストで固定した。

HUD の帯は、プレイ領域に重ねないことにした。重ねるとスコアの文字と敵が視覚的にぶつかる。狭い場所で情報を出すときに一番やりがちな失敗が、重ねて済ませることだと思っている。上から HUD の帯、プレイ領域、武器の帯、と場所を分けた。

全ページで動き続けることの後ろめたさ

これが一番迷ったところだ。読みに来た人が記事を読んでいる間、下でずっと requestAnimationFrame が回っている。バッテリーを削っている自覚はある。

いくつか手は打った。バーを畳んだら rAF を止める。タブが非表示になったら止める。起動そのものを off にできるボタンを置いて、状態は localStorage に残す。prefers-reduced-motion が指定されていればヒットストップは無効にする。

それでも、開いた瞬間に勝手に動き出すものを全ページに置く判断が正しかったかは、今のところ自分でも確信が持てていない。読み手の反応を測れるだけの訪問数がまだ無いので、判断材料が集まっていないというのが実情だ。

検査は dist を数える

CSP まわりは前回と同じで、ソースを眺めて「インライン style は書いていないはず」と推測するのはやめている。scripts/verify.py がビルド後の site/dist を読んで、style= 属性と on*= 属性の数を数える。src の無い <script> も数える。どちらも 0 でなければ落ちる。

ゲームを1,400行足しても、この検査が緑のままなら CSP の契約は保たれている。手で確認する運用にしていたら、10回目くらいで確認を飛ばしていたと思う。

腰を据えて遊べるほうのゲームはKludge Worksにある。

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