Input要素のmaxlength属性は信用してはいけない
はじめに
input 要素の maxlength 属性は比較的よく利用される属性です。
しかし、maxlength 属性信用して、バリデーションを省略すると、特殊な方法(ブラウザの開発者ツールで maxlength 属性を削除するなど)により、
不正なデータが送信されてしまうリスクがあることは周知の事実です。
このような操作は一般のユーザには難しく、また意図的に操作をしないと発生しないためエッジケースだと考えていましたが、
一般ユーザでも容易に maxlength 以上の文字列が入力できるケースに遭遇したため、対策を検討したいと思います。
(今回は React ですが、原理は素の js や Vue などの他のライブラリにも適用できると思います。)
再現方法
再現環境
macOS x Safari x macOS 標準 IME
(Google 日本語入力を利用していると再現しません)
再現手順
- maxlength 属性が設定された input 要素に、日本語入力で maxlength 以上の文字を入力する (まだ確定はしない)
- どこでもいいので、input 要素の外側をクリックする
- 入力していた文字が(maxlength を超過していたとしても)全て imput 要素に反映される
※ 他の環境では、3.の時点で超過分は除去されて、input 要素に反映されます
対策
まず、input 属性をラップしたコンポーネントを作成します。
import React, { useCallback, forwardRef } from "react";
// -----
type Props = React.ComponentPropsWithoutRef<"input">;
export const Input = forwardRef<HTMLInputElement, Props>(
(
{ ...props }: Props,
ref: React.Ref<HTMLInputElement>
): React.ReactElement => {
return (
<input
ref={ref}
{...props}
/>
);
}
);
// -----
続いて、IME 確定イベントの handler を定義します。
export const Input = forwardRef<HTMLInputElement, Props>(
(
- { ...props }: Props,
+ { onCompositionEnd, ...props }: Props,
ref: React.Ref<HTMLInputElement>
): React.ReactElement => {
+ // IME確定イベント
+ const handleCompositionEnd = useCallback(
+ (event: React.CompositionEvent<HTMLInputElement>) => {
+ console.log("handleCompositionEnd", event);
+
+ onCompositionEnd?.(event);
+ },
+ []
+ );
+
+ // -----
return (
<input
ref={ref}
{...props}
+ onCompositionEnd={handleCompositionEnd}
/>
);
}
);
// -----
IME 確定時に、 maxLength を超過している場合は value を補正する処理を追加します。
export const Input = forwardRef<HTMLInputElement, Props>(
(
- { onCompositionEnd, ...props }: Props,
+ { maxLength, onCompositionEnd, ...props }: Props,
ref: React.Ref<HTMLInputElement>
): React.ReactElement => {
// IME確定イベント
const handleCompositionEnd = useCallback(
(event: React.CompositionEvent<HTMLInputElement>) => {
console.log("handleCompositionEnd", event);
+ if (
+ maxLength !== undefined &&
+ event.target instanceof HTMLInputElement &&
+ event.target.value.length > maxLength
+ ) {
+ console.log("handleCompositionEnd", "fix value");
+
+ const input = event.target;
+
+ // -----
+
+ const newValue = input.value.slice(0, maxLength);
+
+ // -----
+
+ // input イベント発火
+ // https://stackoverflow.com/a/46012210
+ Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value"
+ )?.set?.call(input, newValue);
+ input.dispatchEvent(
+ new Event("input", { bubbles: true, cancelable: true })
+ );
+ }
+
onCompositionEnd?.(event);
},
[]
);
// -----
return (
<input
ref={ref}
{...props}
+ maxLength={maxLength}
onCompositionEnd={handleCompositionEnd}
/>
);
}
);
一度挙動を見てみます。
いい感じですが、まだ考慮不足のケースがありそうです。
(以下のケースでは、超過後に入力された「さしす」が消えてほしい)
value を切り取る位置の起点を、末尾から範囲選択の終端に変更します。
// -----
- const newValue = input.value.slice(0, maxLength);
+ const newValue = `${input.value.slice(
+ 0,
+ maxLength - (input.value.length - (input.selectionEnd ?? 0))
+ )}${input.value.slice(input.selectionEnd ?? 0)}`;
// -----
これで Chrome や Google 日本語入力の挙動と揃いました。
9 文字(あいうえおかきくけこ)の途中に 3 文字(さしす)を追加して、2 文字(しす)が削除される挙動も問題なさそうです。
できたもの
実際の挙動は以下からお試しいただけます。
さいごに
この対策を行ったところで、別の抜け道はあるかもしれないので、データ送信前のバリデーションや、
サーバーサイドでのバリデーションは行うようにしましょう。
また、この事象は macOS の IME や Safari の一時的な不具合の可能性がありますので、
そのうち解消されるのではないかと考えています。
よりよい方法や考慮漏れのケースがあればコメント欄などでお知らせいただけると助かります。
Discussion