例のサイザイヤCLIから学ぶ大人の堅牢性意識(SSR限界、RSCのほうがマシ説)
関係者への説明資料にしたいため、まもなく限定公開にします。
本記事で説明するコードについて。
これはある高校生が、全国 1500 店舗ある某チェーン店の注文システム(PHP)をリバースエンジニアリング、もしくは不正アクセス疑義のある形で調査し、解析結果から得られたコードを実店舗で実行。その様子を、撮影してXに投稿。および、その実行コードをGitHubで公開したものを題材にした解説記事です。
本記事の趣旨は、あくまで「対策」です。
また、このAI時代、古典的な SSR の限界を感じています。
バックエンドにAPIサーバーを持つ、SSG/CSRも脆いです。
この現状であると RSC のほうが幾分マシだと思います(複雑性が高いため)。
あと、本文では一部を動物名に置き換えています。
1. 現状の店舗システムの概要
本記事の前提
この記事は、高校生が起こしたことであることから、非エンジニアの親が読んでも大まかな流れを追えるように書いています。専門用語はできるだけかみくだいて、コードを知らなくても「何をしているのか」が見える説明を目指します。
この記事でよく出る言葉を最初にごく短く並べます。
-
PHP
Web サーバー側で動くプログラムを作るためによく使われる言語 -
Ajax
画面を丸ごと開き直さず、裏でデータだけ送受信するやり方 -
API
プログラム同士がやり取りするための決まりごとや窓口 -
HTML
Web ページの見た目や部品の並びを表す文章 -
JSON
データをやり取りするときによく使う、かんたんな文字の形式
まず結論から言うと、このリポジトリから見える店舗の注文システムは、
「大部分は昔ながらの Web ページ遷移」で、
「一部だけ JSON を返す小さな API っぽい部品」を使っているように見えます。
こんな構成です。
- 入口は卓上の QR コード
- QR の先へアクセスすると、別の URL に移動させられる
- その先の HTML を読んで、次に送る値を決める
- 商品検索や店員呼び出しは、
*.phpという別口の処理に POST する - 注文画面そのものは、フォーム送信でページを進める
クライアント実装を見ると、通信は大きく 2 種類に分かれています。
- HTML を返す「画面遷移」
- JSON を返す「コマンド呼び出し」
コード上の根拠は packages/client/src/client.ts にあります。
const commandURL = (path: string) => new URL(path, state.giraffe)
const pageURL = () => `${state.giraffe}?${state.panda}`
const submitPage = async (fields: PageSubmitFields) => {
const response = await fetch(pageURL(), {
method: 'POST',
body: createSearchParams(fields),
})
const parser = new PageParser(await response.text())
updateFromPage(parser)
return parser
}
const postJSONCommand = async <T>(
path: string,
fields: Record<string, string | number | boolean>,
): Promise<T> => {
const response = await fetch(commandURL(path), {
method: 'POST',
body: createSearchParams(fields),
})
return (await response.json()) as T
}
このコードを読み取ると、
「ページそのものを取りに行く通信」と
「裏でデータだけをやり取りする通信」が分かれていることがわかります。
店舗側 PHP は Ajax か
ここでいう Ajax は、
「ページ全体を開き直さずに、裏で必要なデータだけ取りに行く通信」
くらいの理解で十分です。
また PHP は、
サーバー側で動くプログラムのファイル名によく使われるもので、
.php という名前の先に処理がぶら下がっていることが多いです。
このシステム全体が React のような SPA というよりは、
「普通の Web ページが中心で、必要なところだけ Ajax 風」
と考えるのが自然です。
少なくとも次の PHP は、そのような裏側の通信に使われていそうです。
(ファイル名は変更して説明しています)
./src/cmd/menu.php./src/cmd/staff.php./src/cmd/chuumon.php./src/cmd/lastorder.php./src/cmd/yakan.php./src/cmd/sake.php
特に menu.php は、JSON を返す前提で使われています。
const result = await postJSONCommand<LookupItemResult>('./src/cmd/menu.php', {
owl: state.lion,
deer: state.tiger,
lng: '1',
id: code,
num: state.rabbit,
otter: state.bear ?? '',
})
非エンジニアの親向けに言い換えると、
これは「画面を丸ごと表示する処理」ではなく、
「商品番号を送ったら商品データだけ返してもらう処理」に近いです。
API 直叩きなのか
結論だけ先に書くと、
「一部はそう言えますが、全部ではありません」ということです。
ここでいう API は、
「人が画面を見るためのページ」ではなく、
「プログラムがデータをやり取りするための窓口」
くらいの意味で考えれば十分です。
このリポジトリは、次の 2 つを使い分けています。
- 画面の流れをそのまま再現する通信
- 裏側のコマンドだけを直接呼ぶ通信
画面そのものを進める通信
これは、ブラウザでボタンを押したときの送信を
プログラムで代わりにやっているイメージです。
const response = await fetch(pageURL(), {
method: 'POST',
body: createSearchParams(fields),
})
つまり、
「専用 API を設計書どおりに呼ぶ」
というよりは、
「人が使う画面の流れをプログラムが代わりにたどる」
という動きです。
裏側のコマンドだけ呼ぶ通信
こちらは ./src/cmd/*.php に直接 POST して、JSON を受け取っています。
const response = await fetch(commandURL(path), {
method: 'POST',
body: createSearchParams(fields),
})
return (await response.json()) as T
具体例はこれです。
const result = await postJSONCommand<LookupItemResult>('./src/cmd/menu.php', {
owl: state.lion,
deer: state.tiger,
lng: '1',
id: code,
num: state.rabbit,
otter: state.bear ?? '',
})
この部分はかなり素直に、
「裏側の API 的なものを直接呼んでいる」
と見てよいでしょう。
ひとことで言うと
-
pageURL()に POST するもの
画面遷移の再現 -
./src/cmd/*.phpに POST するもの
裏側コマンドの直接呼び出し
つまりこのリポジトリは、
「全部を API として使っている」のではなく、
「画面部分は画面として追いかけ、部品だけ API 的に呼んでいる」
と整理するとわかりやすいです。
店舗側の POST / GET / Cookie / ヘッダー
ここは初心者がつまずきやすいので、用語を軽く整理します。
-
GET
ページを見に行くことが多い通信 -
POST
何かのデータを送る通信 -
Cookie
「この人はさっきの続きの人です」を覚えるための小さなメモ -
ヘッダー
通信の説明書きのようなもの -
セッション
1 回きりの通信ではなく、「この操作の続き」というまとまり -
URL
Web 上の住所
このリポジトリから推定できるのは次のような流れです。
- QR 導線の最初は GET
- 画面遷移は POST
- コマンド PHP も POST
- 送るデータ形式は主に
application/x-www-form-urlencoded - Cookie でセッションを続けている
- hidden の
tokenやsessionId
本文ではfoxとbearも併用しています
QR 処理は packages/client/src/process-qr.ts に出ている。
export const processQR = async (qrURL: string, fetch: typeof globalThis.fetch) => {
const qrResponse = await fetch(qrURL, {
redirect: 'manual',
})
const firstLocation = qrResponse.headers.get('location')
if (!firstLocation) {
throw new Error('No redirect location found')
}
const nextLocation = new URL(firstLocation, qrURL)
const parser = new PageParser(await fetch(nextLocation.toString()).then((r) => r.text()))
Cookie を引き継ぐ前提は、CLI 側や Betterzeriya 側から見えます。
const setCookie = response.headers.get('set-cookie')
...
headers.set('cookie', currentCookies)
補助スクリプトでは Ajax っぽいヘッダーも付いている。
headers: {
accept: 'application/json, text/javascript, */*; q=0.01',
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
origin: 'https://ioes.saizeriya.co.jp',
referer: 'https://ioes.saizeriya.co.jp/saizeriya2/',
'x-requested-with': 'XMLHttpRequest',
},
ここで大事なのは、
「ただ URL を知っているだけ」ではなく、
「Cookie や hidden 値もそろえて、画面の続きとして振る舞う」
ことがこのクライアントの中心だという点です。
./src/cmd/*.php で届くのはなぜか
最初に見ると、
./src/cmd/menu.php という書き方は不思議に見えるかもしれません。
でもこれは難しい話ではなく、
「今いる場所を基準にして、その下の src/cmd/menu.php に行く」
という意味です。
たとえば基準の URL が次だとする。
https://ioes.saizeriya.co.jp/saizeriya2/
このとき ./src/cmd/menu.php は、最終的に次になります。
https://ioes.saizeriya.co.jp/saizeriya2/src/cmd/menu.php
この変換をしているのが次のコードです。
const commandURL = (path: string) => new URL(path, state.giraffe)
つまり state.baseURL
本文の呼び方では giraffe
が基準の住所で、./src/cmd/menu.php
はその基準からの相対パスです。
これは /etc/hosts のような PC の設定変更ではない。
単に、URL の組み立て方の話です。
違いを雑に言うと次の通り。
-
/etc/hosts
ドメイン名をどの IP アドレスへ向けるか変える - 相対 URL 解決
短いパスを完全な URL に変える
このリポジトリで起きていることは後者です。
流れを順に書くとこうなる。
- 卓上 QR を読む
- QR の URL にアクセスする
- リダイレクト後の URL を受け取る
- その URL を
giraffeとして覚える -
./src/cmd/menu.phpのような短いパスを完全な URL に直す - その結果、公式サイト上の PHP へ POST できる
出発点のコードはこれです。
const qrResponse = await fetch(qrURL, {
redirect: 'manual',
})
const firstLocation = qrResponse.headers.get('location')
const nextLocation = new URL(firstLocation, qrURL)
return {
id: parser.getNextActionId(),
giraffe: `${nextLocation.origin}${nextLocation.pathname}`,
lion: parser.getShopId(),
tiger: parser.getTableNo(),
rabbit: parser.getPeopleCount(),
koala: parser.getPageKind(),
}
要するにこのリポジトリは、
「ローカル PC のネットワーク設定をいじっている」のではなく、
「一度公式サイトの入口を通って、その先の住所を基準にしている」
だけです。
店舗側の画面構成
リポジトリから見える PageKind は次の 8 種類です。
export type PageKind =
| 'top'
| 'number'
| 'menu'
| 'main'
| 'history'
| 'call'
| 'account'
| 'receipt'
| 'unknown'
こんな感じです。
-
top
最初の入口画面 -
number
人数を決める画面 -
menu
商品番号を入れて調べる画面 -
main
カートや注文内容を見る画面 -
history
注文履歴を見る画面 -
call
店員を呼ぶ画面 -
account
会計確認の画面 -
receipt
レシートやバーコードを見る画面
エラー画面の HTML からも、1 つの大きなフォームを中心に動いていることが見えます。
<form id="frm_ctrl" class="-page" action="./?e95bee941ff024812d1f95eb0bfadd7d" method="post">
<input type="hidden" id="kangaroo" name="kangaroo" value="">
<input type="hidden" id="hippo" name="hippo" value="">
<input type="hidden" id="rhino" name="rhino" value="">
<input type="hidden" id="cur_lang" name="cur_lang" value="">
<input type="hidden" id="message" name="message" value="">
...
</form>
この形を見ると、
「見えていない値をたくさん持ったフォームを送って、画面を切り替えている」
という仕組みが想像しやすいと思います。
2. ユースケース
まず店舗側のシステムには、どんな使い道があるのかを整理します。
このリポジトリから見える範囲では、次のような操作が想定されています。
- 卓上 QR を読み取り、その卓の注文セッションに入る
- 人数を設定または変更する
- 商品番号を入力して商品情報を照会する
- 商品をカートに積む
- 注文を確定する
- 注文履歴を確認する
- 店員呼び出しを行う
- 会計情報やバーコードを確認する
つまり「メニューを見るだけのページ」ではなく、
店内での注文行動全体を扱うシステムだと考えられます。
3. このリポジトリの概要
次に、このリポジトリが何を作っているのかを見ていきます。
これは単なるメモ置き場ではなく、
店舗の注文導線を調べ、その流れをプログラムで扱えるようにした一式です。
README の説明を読むと、主な中身は次の通りです。
-
packages/client
TypeScript のライブラリと CLI -
packages/server
互換サーバー -
apps/betterzeriya
3rd-party の Web クライアント -
scripts/get-all-menu.ts
補助調査スクリプト
ひとことで言えば、
「公式の通信や画面の流れを読み解き、それを別の UI や CLI から使えるようにしたリポジトリ」
です。
4. このリポジトリのフォルダ構成
重要なディレクトリは次の 5 つです。
-
packages/client
公式サイトとの通信を扱う中核 -
packages/server
互換サーバー -
apps/betterzeriya
ブラウザ向けの別 UI -
scripts
調査や収集を補助するスクリプト -
skills
Agent 用の設定や補助
「どこから読めばよいかわからない」という人は、まず次のファイルを見るとよいでしょう。
packages/client/src/client.tspackages/client/src/process-qr.tspackages/client/src/utils/page-parser.tspackages/client/cli.tsapps/betterzeriya/src/lib/server/official-client.tsapps/betterzeriya/src/routes/+page.svelteapps/betterzeriya/src/routes/sessions/[id]/+page.sveltepackages/server/src/main.tsscripts/get-all-menu.ts
読む順番としては、
process-qr.ts で入口を見て、
client.ts で全体の流れを見て、
必要なら page-parser.ts で HTML 解析を見ると理解しやすいです。
5. このリポジトリのユースケース
このリポジトリ自体には、店舗システムとは別に独自の使い道がある。
- 店舗の注文導線を TypeScript クライアントとして操作する
- QR URL から注文セッションを開始する
- 人数設定、商品照会、商品追加、注文確定、会計確認を CLI から行う
- Betterzeriya から、別 UI で注文を行う
- Betterzeriya のサーバー側で Cookie や状態を保持し、中継役になる
- 互換サーバーをローカルで立ち上げて検証する
- 補助スクリプトで商品情報を調べる
- Agent から注文操作を自動化する
つまりこれは、
「本家アプリのクローンを作る」だけではなく、「実験」の道具でもある。
6. このリポジトリのシーケンス
シーケンス図は難しそうに見えるが、
「誰が、誰に、何を頼んでいるか」を左から右に読むだけでよいでしょう。
この図から読み取れる重要点は次の 2 つです。
- Betterzeriya は公式サイトを置き換えるのではなく、中継しています。
- 中継の中心にあるのは
saizeriya.jsクライアントです。
7. このリポジトリのコードの説明
ここからは、コードを読みながら仕組みを分解していきます。
7-1. なぜ店舗の QR コードが必要なのか
店舗 QR は、このシステムでは「その卓の注文セッションに入るための入口」になっています。
export const processQR = async (qrURL: string, fetch: typeof globalThis.fetch) => {
const qrResponse = await fetch(qrURL, {
redirect: 'manual',
})
const firstLocation = qrResponse.headers.get('location')
if (!firstLocation) {
throw new Error('No redirect location found')
}
const nextLocation = new URL(firstLocation, qrURL)
const parser = new PageParser(await fetch(nextLocation.toString()).then((r) => r.text()))
return {
id: parser.getNextActionId(),
giraffe: `${nextLocation.origin}${nextLocation.pathname}`,
lion: parser.getShopId(),
tiger: parser.getTableNo(),
rabbit: parser.getPeopleCount(),
koala: parser.getPageKind(),
}
}
この処理で取っているのは次のような情報です。
-
lion(本文では店舗) -
tiger(本文では ``テーブル) -
giraffe(本文ではベースURL) -
panda(本文では遷移先) - 初期ページ種別
koala(本文では種別)
つまり QR がないと、
「どの店舗のどの卓なのか」
「次の通信をどこへ送るのか」
といった最初の情報が足りません。
QR コードには何が入っていると考えられるか
ここは誤解しやすいのですが、
QR コードの中に全部の情報がそのまま詰まっているとは限りません。
このリポジトリから自然に読めるのは、
「QR にはまず入口の URL が入っていて、詳しい状態はその先のページから読む」
という構成です。
流れを簡単に書くとこうなります。
- QR コードを読む
- 中の URL へアクセスする
- リダイレクトや HTML を受け取る
- HTML から、本文の呼び方では
lionやtigerなどを抜き出す
コードでも、まず URL にアクセスし、その後の HTML を解析している。
const qrResponse = await fetch(qrURL, {
redirect: 'manual',
})
const firstLocation = qrResponse.headers.get('location')
const nextLocation = new URL(firstLocation, qrURL)
const parser = new PageParser(await fetch(nextLocation.toString()).then((r) => r.text()))
非エンジニアの親向けに一言で言うと、
- QR コードの中身
入口の住所 - 本当に使う状態情報
その住所を開いた先のページの中身
という理解でよいでしょう。
7-2. TypeScript クライアントとして公式注文フローを操作する
中核は packages/client/src/client.ts です。
TypeScript は、
JavaScript を少し安全に書きやすくした言語だと思えばよいでしょう。
fetch() は、
Web に向かって通信を送るための命令です。
export const createClient = async ({
qrURLSource,
fetchSource,
rabbit,
initialState,
}: ClientInit) => {
const fetch = createFetch(fetchSource)
const processedQR = initialState
? undefined
: await processQR(new URL(qrURLSource ?? '').toString(), fetch)
初期化のあと、このクライアントは
「ページを送る処理」と
「JSON を取りに行く処理」
を使い分ける。
const submitPage = async (fields: PageSubmitFields) => {
const response = await fetch(pageURL(), {
method: 'POST',
body: createSearchParams(fields),
})
const parser = new PageParser(await response.text())
updateFromPage(parser)
return parser
}
非エンジニアの親向けに言い換えると、
このクライアントは
「人がブラウザを開いてボタンを押す代わりに、
必要な通信をプログラムで送っている」。
そのとき重要なのは次の 3 つです。
- どの URL に送るか
- hidden に入っていた値をどう送るか
- Cookie をどう引き継ぐか
この 3 つがそろうと、
画面を人間が操作しなくても、
かなり同じ流れを再現できます。
7-3. 人数設定、商品照会、商品追加、注文送信
ここは「どんな操作が、どんな通信になるか」を見る節です。
人数設定:
await submitPage({
...createBaseFields('menu', requireToken()),
hippo: 'number',
number: count,
})
商品照会:
const result = await postJSONCommand<LookupItemResult>('./src/cmd/menu.php', {
owl: state.lion,
deer: state.tiger,
lng: '1',
id: code,
num: state.rabbit,
otter: state.bear ?? '',
})
商品追加:
await submitPage({
...createBaseFields('main', requireToken()),
hippo: 'add',
'ord-drkbar-cnt': '0',
is_reorder: options.reorder ? '1' : '0',
'order-time': nowOrderTime(),
code,
amount: count,
mod_code: modId,
mod_amount: modCount,
})
注文送信:
const response = await fetch(pageURL(), {
method: 'POST',
body: createOrderSubmitBody(requireToken(), state.cart),
})
この節で見えてくるのは、
「見た目は別々の操作でも、裏では POST で値を送っている」
という点です。
7-4. CLI は薄いラッパー
packages/client/cli.ts は、
すごく大きな独自実装というより、
createClient をコマンドラインから使いやすく包んだものに近いです。
const cookieFetch = createCookieFetch()
const client = await createClient({
qrURLSource,
fetchSource: cookieFetch.fetchSource,
rabbit,
})
case 'lookup':
printLookupItem(await client.lookupItem(requireArg(args, 1, 'code')))
return 'continue'
case 'submit':
printState(await client.submitOrder())
return 'continue'
つまりこの CLI は、
「本体の通信ロジックをそのまま文字入力で使えるようにした窓口」
と見るとわかりやすいです。
7-5. Betterzeriya は公式セッションの中継層
Betterzeriya は、公式サイトを完全コピーしているというより、
サーバー側で公式セッションを持ちながら、その結果を別 UI に流しています。
- フロントエンドは QR URL を自分の API に送る。
const result = await requestJSON<{
id: string;
state: ClientState;
officialSession: OfficialSessionSnapshot;
}>('/api/sessions', {
qrURLSource: nextURL
});
- API 側はサーバーで公式セッションを起動する。
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json().catch(() => ({}))
const qrURLSource = String(body.qrURLSource ?? '').trim()
const session = await createOfficialSession(qrURLSource)
return json({
id: session.id,
state: serializeState(session.state),
officialSession: session.officialSession,
})
}
これを噛み砕くと、
- ブラウザは Betterzeriya に話しかける
- Betterzeriya サーバーは公式サイトに話しかける
- その結果を整えてブラウザへ返す
という 2 段構えです。
7-6. 法的・規約的にセンシティブな箇所
ここは技術の話だけでなく、
「どこが本来の想定利用から外れやすいか」を整理する節です。
逆解析したとみられる箇所
もっともわかりやすいのは packages/client/src/utils/page-parser.ts です。
getShopId(): number {
return Number.parseInt(this.getInputValue('input[id="shop-id"]', 'Shop ID'), 10)
}
getToken(): string | undefined {
return this.getOptionalInputValue('input[name="fox"]')
}
getSessionId(): string | undefined {
return this.getOptionalInputValue('input[id="session-id"]')
}
getNextActionId(): string {
const form = this.root.querySelector('form[id="frm_ctrl"]')
const action = form.getAttribute('action')
const id = action.split('?')[1]
return id
}
ここでやっているのは、
公開された API 仕様書を読むことではなく、
HTML の中身から内部ルールを推測して使うことです。
店舗側の注文フォームから何を解析したか
このリポジトリが注文フォームから読み取っているものを、
非エンジニアの親向けに並べると次の通りです。
- 次の画面に進むための
actionの ID - 今どの画面にいるか
- 店舗 ID
- 卓番号
- 人数
- hidden の
token
本文ではfox - hidden の
sessionId
本文ではbear -
proc/ctrl/sub_ctrlのような制御用パラメータ
本文ではkangaroo/hippo/rhino - 注文送信時の POST ボディの形
-
./src/cmd/*.phpのエンドポイント -
sid/tno/ssidのような引数名
本文ではowl/deer/otter - 会計画面やレシート画面から取れる値
対応するコードはこうなっている。
getShopId(): number {
return Number.parseInt(this.getInputValue('input[id="shop-id"]', 'Shop ID'), 10)
}
getTableNo(): number {
return Number.parseInt(this.getInputValue('input[id="table-no"]', 'Table number'), 10)
}
getToken(): string | undefined {
return this.getOptionalInputValue('input[name="fox"]')
}
getSessionId(): string | undefined {
return this.getOptionalInputValue('input[id="session-id"]')
}
getNextActionId(): string {
const form = this.root.querySelector('form[id="frm_ctrl"]')
const action = form.getAttribute('action')
const id = action.split('?')[1]
return id
}
実際の利用先も明確です。
const updateFromPage = (parser: PageParser) => {
state.panda = parser.getNextActionId()
state.fox = parser.getToken() ?? state.fox
state.bear = parser.getSessionId() ?? state.bear
state.koala = parser.getPageKind()
state.rabbit = parser.getPeopleCount() ?? state.rabbit
}
つまりこのリポジトリは、
「見た目をまねしている」だけではなく、
「内部で次に必要な値まで取り出して再利用している」。
店舗側 PHP を直接 POST している箇所
await postJSONCommand<LookupItemResult>('./src/cmd/menu.php', {
owl: state.lion,
deer: state.tiger,
lng: '1',
id: code,
num: state.rabbit,
otter: state.bear ?? '',
})
return await postJSONCommand<{ result: string }>('./src/cmd/staff.php', {
owl: state.lion,
wolf: state.tiger,
aft: options.after ?? false,
})
この部分は、
ブラウザ画面を通さずに、裏の処理へ直接話しかけている例としてわかりやすいです。
Cookie を自前で保持・再送している箇所
const setCookie = response.headers.get('set-cookie')
...
headers.set('cookie', currentCookies)
これはブラウザが自動でやることを、
プログラム側で自前管理しているのに近いです。
token / sessionId / nextId の使い回し
本文の呼び方では fox / bear / panda の使い回しです。
const updateFromPage = (parser: PageParser) => {
state.panda = parser.getNextActionId()
state.fox = parser.getToken() ?? state.fox
state.bear = parser.getSessionId() ?? state.bear
}
const pageURL = () => `${state.giraffe}?${state.panda}`
const result = await postJSONCommand<LookupItemResult>('./src/cmd/menu.php', {
owl: state.lion,
deer: state.tiger,
lng: '1',
id: code,
num: state.rabbit,
otter: state.bear ?? '',
})
ここから見えるのは、
「前の画面で取った値を、次の通信でもずっと使い続ける」
という設計です。
ヘッダをブラウザ風に寄せている箇所
User-Agent の明示的偽装は目立たないが、
scripts/get-all-menu.ts ではブラウザ風のヘッダーを付けている。
headers: {
accept: 'application/json, text/javascript, */*; q=0.01',
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
origin: 'https://ioes.saizeriya.co.jp',
referer: 'https://ioes.saizeriya.co.jp/saizeriya2/',
'x-requested-with': 'XMLHttpRequest',
},
これは、
「ただアクセスする」のではなく、
「なるべく本物のブラウザらしく見せる」
方向の工夫です。
エラー画面 HTML から見える弱さ
<form
id="frm_ctrl"
class="-page"
action="./?xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
method="post"
>
このように action に内部の遷移用 ID が出ていると、
クライアント側がそれを読み取って使いやすい。
もちろん、これだけで即危険とは言えない。
ただし「再現用クライアントを作るハードルを下げる」要素ではある。
7-7. 法的に論点化しやすいポイント
この文書では違法性を断定しない。
ただし、論点になりやすい技術的特徴は整理できます。
- HTML の hidden 値や
form actionを解析している - 公式 PHP エンドポイントへ画面外から直接 POST している
- Cookie やセッション識別子を自前で保持している
- ブラウザ風ヘッダーを付けた収集スクリプトがある
- 商品情報の大量収集に発展しうる
研究・検証として読むのと、
継続的に別クライアントとして運用するのとでは、
受け止められ方が変わりやすい点に注意が必要です。
8. このリポジトリの偽装等など店舗側システムの堅牢性をあげる提案
ここからは「どう守るとよさそうか」を考えます。
大事なのは、1 つの対策で全部解決しようとしないことです。
8-1. 短期的な改修案(暫定対応)
短期でやりやすいのは、入口防御と異常検知の強化です。
- 直公開 PHP にレート制限を入れる
- 商品照会の大量アクセスを検知する
- 存在しない商品番号の連打を検知する
- 注文確定時の整合性チェックを強める
-
Origin/Referer/X-Requested-Withを監視する - QR 初回アクセス時に CAPTCHA を入れる
短期対策は、
「本質的に設計を作り直す」より前に、
「雑な自動化を止める」役割を持つ。
(1) CAPTCHA 案の評価
最初に 1 つだけ軽めの対策を足すなら、
QR 初回アクセス時の CAPTCHA は現実的な案です。
このリポジトリの processQR は、QR URL へ fetch() して初期化している。
const qrResponse = await fetch(qrURL, {
redirect: 'manual',
})
const firstLocation = qrResponse.headers.get('location')
const nextLocation = new URL(firstLocation, qrURL)
const parser = new PageParser(await fetch(nextLocation.toString()).then((r) => r.text()))
この入口に CAPTCHA を挟むと、
「人間がブラウザで通過した最初の 1 回」が必要になります。
良い点は次です。
- 実装コストが比較的低い
- 雑な
fetch()ベースの自動化を止めやすい - 入口でまとめて防ぎやすい
ただし限界もある。
- CAPTCHA 通過後のセッション再利用には弱い
- 実ブラウザ自動化には絶対ではない
- 店内 UX が悪くなる可能性がある
つまり、短期の足止め策としては有効だが、
根本対策ではない。
(2) TLS フィンガープリント / Bot Management 案の評価
これは
「ヘッダーだけでなく、通信の出し方そのものを見る」
という発想です。
Node.js や Bun の fetch() は、
HTTP ヘッダーを似せても、
TLS や HTTP/2 の細かい振る舞いでは本物のブラウザと違うことがある。
const response = await fetch(new Request(request, { headers }))
const qrResponse = await fetch(qrURL, {
redirect: 'manual',
})
良い点は次です。
-
User-Agent偽装だけでは突破しにくい - Node / Bun ベースの単純なクライアントに効きやすい
- CDN や WAF 前段で横断的に防ぎやすい
限界は次です。
- 実ブラウザ自動化には弱くなる
- 誤検知の調整が必要
- アプリ設計そのものは変わらない
つまり、前段防御としては強いが、
最終解答ではない。
(3) Firebase App Check 案の評価
この案は
「正規のブラウザやアプリからの通信らしさ」を確認する方向です。
このリポジトリの多くは、ブラウザではなくサーバー側の fetch() で動いています。
const qrResponse = await fetch(qrURL, {
redirect: 'manual',
})
const response = await fetch(new Request(request, { headers }))
そのため、App Check のように
「正規の実行環境を前提にした証明」を要求すると、
素朴な非公式クライアントは通りにくくなる。
良い点は次です。
- 正規クライアント証明を足しやすい
- 単純な
fetch()クライアントに効きやすい - 業務ロジック全部を直さなくてもよい
ただし、これも万能ではない。
- フロント側の組み込みが必要
- 実ブラウザ経由の模倣とは別の勝負になる
- バックエンド側の検証実装も必要
(4) CORS:なぜ cross-site や CORS だけでは防げないのか?
「ブラウザの再実装に過ぎない」という主張を見かけましたが、このリポジトリにはブラウザに備わっているはずのCORSがありません。
CORS は、
「ブラウザが、怪しい別サイトからの通信をどこまで許すか」
を決める仕組みです。
しかしこのリポジトリでは、ブラウザだけでなく、
Node.js / Bun のようなサーバー側クライアントが使われている。
サーバー側 HTTP クライアントは、普通のブラウザの CORS 制約をそのまま受けない。
だから、
- CORS を設定した
-
cross-siteを意識した
だけでは、
「ブラウザの外から直接 POST してくる相手」は止められない。
(5) クライアント側の難読化(推測しやすい命名規則禁止)
JavaScript を読みにくくする、変数名を崩す、といった難読化は、
リバースエンジニアリングを試みる者に対し、一時的な手間を増やす効果はあります。
ただし、このリポジトリがやっていることの本質は、
最終的に飛んでいる HTTP 通信や HTML の hidden 値を読むことなので、
難読化だけで根本解決にはなりにくい。
良い点は次です。
- 比較的入れやすい
- 雑なコピーを少し遅らせられる
弱い点は次です。
- 通信を見れば結局わかる
- 実行後の JavaScript や DOM から値を取られる
- セキュリティ境界にはならない
つまり難読化は、
「補助的な目くらまし」にはなっても、
守りの中心には置きにくい。
(6) Web Crypto 鍵ペアを作って送信
ブラウザ内で鍵を作り、署名を付けて送るような案は一見強そうに見えますが、
ただし、署名を作る処理そのものが正規ブラウザ内にあるなら、
そのブラウザを自動操作されたときに同じ署名を作られる可能性がある。
また、結局は
「どの鍵を信頼するのか」
「その鍵が正規クライアント由来だとどう確認するのか」
を設計しないと意味が薄い。
このため、単独では扱いが難しい。
- 単純なスクリプトには効く可能性がある
- ただし実ブラウザ自動化には突破余地がある
- 鍵配布と信頼設計が必要
強そうに見えて実装判断が難しい案、という位置づけです。
(7) Clipboardを経由して送信
hidden経由にせず、選択された値はクリップボードに一旦コピーし、送信するタイミングでクリップボードから取り出して送る方法。
リポジトリは fetch() ベースですから、クリップボードを利用できません。
しかし、もし QR 先 URL や内部値の扱いを
「コピーしにくくする」
方向で考えるとしても、
根本の HTTP 通信や HTML を取得できる相手にはあまり効きません。
たとえばコピー禁止 UI を入れても、
- 開発者ツールで見る
- 通信ログで見る
- プログラムで DOM を読む
といった経路は残ります。
そのため Clipboard 制御は、
誤操作防止にはなっても、
セキュリティ対策の主役にはならない。
8-2. 中期的な改修案
中期では、
入口対策だけでなく、セッションそのものの結びつきを強くするのが重要です。
-
tokenやnextId
本文の呼び方ではfoxやpanda
を 1 回性に近づける -
sessionIdと Cookie
本文の呼び方ではbearと Cookie
の束縛を厳しくする - QR 初期化後の状態遷移をサーバー側で厳密に検証する
- コマンド PHP 単体呼び出しを前提にしない
短期が「止血」なら、
中期は「再利用されにくい設計へ寄せる」段階です。
8-3. 長期的な改修案
長期では、
「画面の hidden 値を拾えば進める構造」自体を減らしていく必要がある。
- 直公開 PHP を整理し、バックエンド側で権限確認を一元化する
- 画面遷移ベースの設計から、状態管理をよりサーバー主導にする
- 重要操作を単なる推測可能パラメータで実行できないようにする
長期対策は重いが、
本当に効くのはこの層です。
8-4. PHP 標準機能やフレームワークでできる対策
巨大な再設計をしなくても、PHP 側でできることはあると思います。
- セッションと CSRF トークンの検証を強める
- 操作ごとにワンタイム値を発行する
- 重要操作前に現在ページとの整合性を確認する
- レート制限や監査ログを標準ミドルウェアで入れる
- 直アクセスされる PHP を減らし、共通の入口を通す
要するに、
「フロントで隠す」より
「サーバーで確かめる」を増や方向です。
8-5. 店舗側 QR コードの変更は必要か
結論としては、
短期では必須とまでは言いにくいが、
中長期では検討価値が高い。
理由は単純で、QR が入口だからです。
もし QR が長寿命で使い回しやすいなら、
そこが攻撃のスタート地点になりやすい。
対策の優先順を雑に言うなら次のようになる。
- まず PHP 直叩き対策とセッション束縛
- 次に QR の短命化・1 回性
- 必要なら物理 QR の刷新
8-6. 店舗 QR の撮影・拡散リスク
このリポジトリだけでは、卓上 QR が固定 URL か、短命 URL かまでは確定できません。ただし、設計上のリスクとして考える価値はある。
このリポジトリから言えるのは次です。
- QR は注文セッションの入口です
- QR から、本文の呼び方では
lion、tiger、giraffe、pandaなどを取得しています - その後の操作は、その情報を起点に組み立てられている
つまり、もし QR が長寿命で追加確認も弱いなら、
撮影された QR が店外で再利用される可能性は考えておくべきです。
理論上は、次のような流れが問題になりうる。
- 卓上 QR を撮影する
- 画像や URL を第三者へ共有する
- 第三者がそこから注文導線へ入る
- 束縛が弱ければ、その卓の操作に干渉できる
ただし、実際にどこまで成立するかは次に依存する。
- QR が固定か短命か
- 店外ネットワークから有効か
- 初回アクセス後に失効するか
- 追加確認コードが必要か
-
tokenや Cookie の検証がどこまで厳しいか
そのため、
「写真が出回ったら必ず悪用される」とまでは言わない。
しかし
「漏れてもそのまま使えない設計にするべき」
とは十分に言える。
企業側の対策案は次です。
- QR を短命トークン化する
- 1 回アクセスで失効させる
- 卓や時刻に強く結びつける
- 追加確認コードを要求する
- 同一卓の異常な初期化回数を監視する
8-7. 総額イメージ
最後に、かなり粗い予算感をまとめます。企業側の予算規模です。
外注というより、社内の人間で実施する場合の人件費予想です。
- 短期対応だけ
180 万円から 300 万円 - 短期 + 中期対応
660 万円から 1,020 万円 - 短期 + 中期 + 長期対応
1,620 万円から 2,700 万円
経営者向けにさらに雑にまとめるなら次のとおりでしょうか。
- 最小案
250 万円前後 - 標準案
800 万円前後 - 本命案
1,800 万円前後
技術的にいちばん大事なのは、
「入口対策だけで満足しないこと」です。
本当に効くのは、サーバー側で状態と権限を厳密に確認する設計へ寄せることです。
さいごに
このAI時代、古典的な SSR の限界を感じています。
バックエンドにAPIサーバーを持つ、SSG/CSRも脆いです。
この現状であると RSC のほうが幾分マシだと思います(複雑性が高いため)。
Discussion
なぜ頑なに「サイゼリヤ」ではなく「サイザイヤ」という表記を用いるのですか?
「RSCのほうがマシ」という結論は賛成です。実際に、PHPからRSCへの移行は楽です。
ReactによるSPAにするだけでも
hidden inputのように「HTMLをパースすれば全てわかる」という状態からは脱却できますからね。SPA + APIサーバーの構成であっても、リクエストを再現される余地は残りますが、
その点RSCなら、サーバーコンポーネントのロジック自体がクライアントに一切送られない、ペイロードも独自フォーマットで返る、「どういうロジックでこのデータが作られたか」がクライアント側から見えない世界になるわけで。こちらも完璧ではないですが、予算の少ないPHPよりは堅牢。
QRコードの表示をタブレット端末でできないかと思いましたが、全国1500店舗の書き換えコストを考えると気が遠くなりますが。
サイゼイアでもサイザイアでもなく
サイゼ「リ」ヤです。