テーマや表示件数、一覧に並べる項目をURLで共有できると、別の人や別の端末でも同じ表示設定を開けます。
ただし、URLに入っている値は外部入力です。途中で編集されることもあれば、古い仕様で作られたURLが残ることもあります。
そこで、復元処理では入力全体を検証して復元候補を作り、必要な確認を済ませてから現在の状態を置き換えるようにします。検証中は、画面が使っている状態や保存済みのデータを変更しません。
この記事では、表示設定を復元する説明例を使って、この境界の作り方を整理します。
1分でわかる要点
- URLから取得した値は、読み取れても正しいとは限りません。
- 入力全体を検証し、問題がなければ新しい設定の候補を作ります。
- 検証中は現在の設定を変更せず、必要な確認を経てから反映します。
- 不正な入力やキャンセル時は元の状態を維持します。保存失敗への対処は別途設計が必要です。
読み込めた部分だけを適用すると何が起きるか
たとえば、共有された表示設定に「ダークテーマ」と「現在のアプリには存在しない表示項目」が含まれていたとします。
知らない項目だけを捨てて適用すると、エラーは出なくても、送り手が共有した状態を再現できません。そのうえ、受け手が調整していた設定を上書きしてしまう可能性があります。
この記事では、不正な値が一つでもあれば全体の復元をやめ、現在の状態を残す方針にします。
部分的な復元が適した機能もあります。その場合は、何を除外するのかを示し、その内容で復元する操作を利用者に選んでもらいます。黙って一部だけ適用しないことが重要です。
復元処理を六つの段階に分ける
処理の責任を、次のように整理します。
-
入力を受け取る
対象パラメーターの有無や重複、入力サイズを確認します。 -
構文を解析する
決めた形式でデコードし、JSONなどを値に変換します。 -
全体を検証する
バージョン、必須項目、型、許容値、項目同士の関係を確かめます。 -
復元候補を作る
検証済みの値から、アプリが使う新しいオブジェクトを組み立てます。 -
必要な確認を取る
現在の編集内容を置き換える場合などに、復元内容を確認してもらいます。 -
状態へ反映する
検証と確認を通った候補だけを適用します。
大切なのは、関数を必ず六つ作ることではありません。反映する段階まで、現在の状態に触れないことです。
図1:入力の検証と状態更新の境界。反映後の保存・描画が失敗した場合の復旧は、別に設計します。
新旧の共有形式を扱う場合も、それぞれの解析結果を共通の形へそろえてから同じ検証へ渡すと、形式によって受け入れる値が食い違うのを防ぎやすくなります。
解析できることと、利用できることを分ける
URLSearchParams はクエリを扱うAPIです。パラメーターを取り出せても、その値がアプリの仕様を満たすとは限りません。JSONの解析に成功した場合も同じです。URL Standard
表示設定なら、次のような条件を決めます。
- 対応する形式のバージョンか
- 必須項目が存在し、想定した型になっているか
- テーマや表示項目が許可された値か
- 表示件数が許容範囲内か
- 同じ表示項目が重複していないか
- 未知の項目を拒否するか、互換性のために無視するか
個々の値が正しくても、全体として不正になる場合があります。たとえば、表示項目の名前がすべて有効でも、同じ項目が重複していれば、重複を許さない仕様には合いません。
入力サイズにも上限を設けます。解析後に配列の長さを調べるだけでは、解析そのものの負荷を抑えられません。入口でのサイズ制限に加え、圧縮を使うなら展開後のサイズなども制限します。OWASP Input Validation Cheat Sheet
全体を検証してから候補を返す例
以下は、表示設定をJSONで受け取る説明用のJavaScriptです。2026年10月8日にWindows(x64)のNode.js v24.3.0で本文コードと補助テストを実行し、57項目が成功しました。 入力の解析・検証、サイズ境界、重複、確認の承認とキャンセル、既存状態の維持を確認しています。window.confirm はテスト用の代替関数を使用しました。実際のブラウザ操作、保存・描画・本番動作、JSDocの型検査は対象外です。保存、描画、エラー通知はコードから省いています。
この例では、settings パラメーターを一つだけ受け付けます。JSONの項目は version、theme、pageSize、columns の四つです。未知の項目や重複した表示項目は拒否します。
/** @typedef {{theme: string, pageSize: number, columns: string[]}} Settings */
const MAX_QUERY_LENGTH = 4096;
const allowedKeys = new Set(["version", "theme", "pageSize", "columns"]);
const allowedColumns = new Set(["name", "category", "updatedAt"]);
/**
* クエリから検証済みの復元候補を作る。現在の状態は変更しない。
* @param {string} search - location.search 相当の文字列
* @returns {{status: "absent" | "invalid"} |
* {status: "ready", value: Settings}}
*/
function prepareSettings(search) {
// ① パラメーター解析前にも、アプリ側の入力上限を設ける。
if (search.length > MAX_QUERY_LENGTH) return { status: "invalid" };
const values = new URLSearchParams(search).getAll("settings");
if (values.length === 0) return { status: "absent" };
if (values.length !== 1) return { status: "invalid" };
let input;
try {
input = JSON.parse(values[0]);
} catch {
return { status: "invalid" };
}
// ② 構造、各値、項目間の条件をすべて確認する。
if (
input === null || typeof input !== "object" || Array.isArray(input) ||
!Object.keys(input).every(key => allowedKeys.has(key)) ||
input.version !== 1 ||
!["light", "dark"].includes(input.theme) ||
!Number.isSafeInteger(input.pageSize) ||
input.pageSize < 1 || input.pageSize > 100 ||
!Array.isArray(input.columns) ||
input.columns.length < 1 || input.columns.length > 3 ||
!input.columns.every(id => allowedColumns.has(id)) ||
new Set(input.columns).size !== input.columns.length
) return { status: "invalid" };
// ③ 外部オブジェクトを丸ごと渡さず、必要な値だけで候補を作る。
return {
status: "ready",
value: {
theme: input.theme,
pageSize: input.pageSize,
columns: [...input.columns],
},
};
}
let current = { theme: "light", pageSize: 20, columns: ["name"] };
const query = new URLSearchParams({
settings: JSON.stringify({
version: 1,
theme: "dark",
pageSize: 50,
columns: ["name", "unknown"],
}),
}).toString();
const result = prepareSettings(`?${query}`);
// ④ 確認を通った候補だけを反映する。ここまでは current を変更しない。
if (result.status === "ready" &&
window.confirm("現在の表示設定を共有された設定に置き換えますか?")) {
current = result.value;
}
// 期待値:invalid。確認は表示されず、current は元の設定のまま。
この入力では、unknown が許可された表示項目に含まれないため、全体を拒否します。テーマだけを変更することもありません。
Number.isSafeInteger は、数値が安全に表現できる整数かを判定します。文字列の "50" を数値へ変換する処理ではありません。この例では型の違いを拒否したうえで、1〜100というアプリ側の範囲も確認しています。ECMAScript Number.isSafeInteger
検証後も、外部から来たオブジェクトをそのまま状態へ渡さず、必要な項目だけを新しいオブジェクトへ移しています。これにより、入力の形式と内部の状態を分けられます。
4096という上限は、この説明例で決めた値です。ブラウザ共通のURL上限ではありません。また、JavaScriptの文字列長なので、通信量のバイト数を表しているわけでもありません。実際の形式と必要量に合わせて設計します。
確認画面も実際のUIに合わせます。ここでは同期的な window.confirm を使っています。非同期の確認画面にする場合は、確認待ちの間に編集された状態を古い候補で上書きしないよう、反映直前に変更の有無を確認するなどの設計が必要です。
成功時だけでなく、変わらないことをテストする
復元処理では、返り値に加えて、既存の状態や保存処理への影響も確認します。
- 有効な入力で、期待する候補ができる
- 対象パラメーターがなければ、何もしない
- 空文字や不正なJSONで、状態を変えない
- 未対応のバージョン、未知の項目、型違いを拒否する
- 表示件数や入力サイズの上限ちょうどを受け入れ、超過を拒否する
- 有効な表示項目と不正な表示項目が混在していても、一部だけ適用しない
- 検証失敗時は、確認画面や保存処理を呼ばない
- 上書きをキャンセルした場合は、元の状態を残す
- 新旧形式を扱う場合は、同じ意味の入力が同じ結果になる
関数単体のテストに加えて、実際のURL、画面の確認操作、保存領域まで含めた経路も別に確認します。
保存の失敗は別に設計する
ここまでの設計で防げるのは、主に不正な入力による部分適用や、確認前の上書きです。
状態の変更、保存、画面描画をまとめて一つのトランザクションにする仕組みは含んでいません。状態を置き換えた後に保存が失敗した場合、自動で元へ戻る保証はありません。
たとえば、Web Storageの setItem は、値を保存できない場合に例外を投げます。HTML Standard Web storage
保存できなかったことを知らせるのか、メモリ上の変更を残して再試行できるようにするのか、変更前へ戻すのかを決め、その経路もテストします。複数の永続データを一体として更新する必要がある場合は、利用する保存先のトランザクション機能などを別に検討します。
また、入力検証だけで認可や安全な画面出力まで済むわけではありません。権限に関わる操作はサーバー側で確認し、受け取った文字列をHTMLとして直接挿入しないなど、使う場所に応じた対策も必要です。
共有URLには、共有相手やブラウザの履歴などから見られて困る情報を載せない前提にします。
まとめ
復元処理を見直すときは、まず現在の状態を書き換えている場所を探します。
解析や検証の途中で状態を変更しているなら、候補を作る処理と反映する処理を分けます。そのうえで、不正な入力、キャンセル、保存失敗のそれぞれで何を残すかを決めます。
共有できることに加えて、復元できないときにも受け手の作業を守れることまで設計しておくと、意図しない上書きを防ぎやすくなります。
