🍨

【react/typescript】propsの渡し方をミスると大変なことに!?(気がついたことのまとめ)

に公開

みなさん、こんにちは。
てるし〜です。

今回、propsの渡し方をミスると大変なことになってしまうのでは?という記事です。
完璧な説明ができないとは思いますが、少しでも意識付けしていただければと記事にします。

私自身がやっているpropsの渡し方

今回、簡単なソースを用意しました。
まず、AComponentというコンポーネントを用意しました。

a-components.tsx
interface Props {
  username: string;
}

export function AComponent(props: Props) {
  return (
    <>
      <h1>AComponent</h1>
      {props.username}
      <br />
      {JSON.stringify(props)}
    </>
  );
}

次に上記コンポーネントを呼び出すコンポーネントです。

App.tsx
import { AComponent } from "./components/a-component";

function App() {
  const sample = {
    username: "taro",
    password: "hogehoge",
  };
  return <AComponent {...sample} />;
}

export default App;

上記コードは問題があります。
何が問題かわかるでしょうか?

上記ソースコードの問題

では、実行して描画された画面を見ていきましょう。

なんと、、、意図せずpasswordが渡ってしまっています。
「typescriptの型定義で良しなに防げている」とずっと勘違いしていました。
そして、大切なことを忘れていました。

typescriptはあくまでもAltJSの仲間であり実行上はJavascriptである

しかも、残念なことにReact上で上記コードでpasswardはスキームに定義されていないのに型エラーに引っかからずに通ってしまいました。

私はあまりセキュリティに詳しくはないのですが、Reactのpropsに渡ってしまっているということはDOMにパスワードの情報が載ってしまうことになりデベロッパーツール等でなんらかのもので閲覧されてしまい攻撃を受けてしまう危険性があるのではという考察が生まれました。

上記問題に対していくつかGPTちゃんと会話しながら解決方法を見出してみました。
あくまでも例ですが参考になればと

対策法1(ライブラリを使わない)

まずは、ライブラリを使わないバージョンで予期せず定義していないpropsを渡さない方法を見ていきます。

createSafeTypeを作成する

create-safe-type.ts
import { createResult, type Result } from "./result";

function isStringArray(value: unknown): value is string[] {
  if (!Array.isArray(value)) {
    return false;
  }

  const findNotString: string | undefined = value.find(
    (val) => typeof val !== "string"
  );

  if (findNotString === undefined) {
    return true;
  }

  return false;
}

export function createSafeTypes<T extends object>(
  raw: unknown,
  allowedKeys: (keyof T)[]
): Result<Partial<T>, Error> {
  if (typeof raw !== "object") {
    return createResult.ng<Error>(new Error("オブジェクトじゃありません。"));
  }

  if (raw === null) {
    return createResult.ng(new Error("nullはダメで〜〜す。"));
  }

  const rawKeys = Object.keys(raw);

  for (const key of rawKeys) {
    if (!isStringArray(allowedKeys)) {
      return createResult.ng(
        new Error("arrowKeysが文字列型の集団になっていません。")
      );
    }

    if (!allowedKeys.includes(key)) {
      return createResult.ng(new Error("存在しないpropsが定義されました。"));
    }
  }

  return createResult.ok(raw);
}

簡単なものなのでざっくりとしたものになりますが、これで定義していないpropsがないかどうかを検証します。

定義したものだったらOKを、ダメならNGを返しています。

余談

Resultについては自作しています。

result.ts
export const RESULT_OK = "ok" as const;
export const RESULT_NG = "ng" as const;

interface Success<T> {
  readonly kind: typeof RESULT_OK;
  value: T;
}

interface Failed<E> {
  readonly kind: typeof RESULT_NG;
  err: E;
}

export type Result<T, E> = Success<T> | Failed<E>;

export const createResult = {
  ok: <T>(value: T): Success<T> => {
    return {
      kind: RESULT_OK,
      value,
    };
  },
  ng: <E>(err: E): Failed<E> => {
    return {
      kind: RESULT_NG,
      err,
    };
  },
};

AComponentを修正

a-component.tsx
import { createSafeTypes } from "../utils/create-safe-types";
import { RESULT_NG } from "../utils/result";

interface Props {
  username: string;
}

const keys: (keyof Props)[] = ["username"];

export function AComponent(props: Props) {
  const typeResult = createSafeTypes<Props>(props, keys);

  //const zodResult = propsSchema.safeParse(props);
  if (typeResult.kind === RESULT_NG) {
    throw typeResult.err;
  }
  
  const mainProps = typeResult.value;


  return (
    <>
      <h1>AComponent</h1>
      {mainProps.username}
      <br />
      {JSON.stringify(props)}
    </>
  );
}

これで、実行すると。。。

このようにエラーで防ぐことができました。

Appのコンポーネントの呼び出しを変更

App.tsx

import { AComponent } from "./components/a-component";

function App() {
  const sample = {
    username: "taro",
    password: "hogehoge",
  };
  return <AComponent username={sample.username} />;
}

export default App;

上記に変更することで、

必要な情報だけが描画されるようになりました!

対策法2(zodを使って)

次はzodを使っていきます。

https://zod.dev/

AComponentの修正

a-component.tsx
import { z } from "zod";

const propsSchema = z
  .object({
    username: z.string(),
  })
  .strict();

type Props = z.infer<typeof propsSchema>;

export function AComponent(props: Props) {

  const zodResult = propsSchema.safeParse(props);
  
  if (zodResult.error) {
    throw new Error("いらない型が入ってるよ");
  }
  
  const mainProps = zodResult.data;

  return (
    <>
      <h1>AComponent</h1>
      {mainProps.username}
      <br />
      {JSON.stringify(props)}
    </>
  );
}

これで実行してみると、

これもしっかりとガードできているようです。

App.tsx
import { AComponent } from "./components/a-component";

function App() {
  const sample = {
    username: "taro",
    password: "hogehoge",
  };
  return <AComponent username={sample.username} />;
}

export default App;

に修正することで

上記のように必要な情報だけを抽出できます。

(6/16追記)対策3(eslintで未然に防ぐ)

ありがたいことにXでこんなのあるよとコメントしてくれたのでそれも記載します!
Thank you!!!!

https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-props-no-spreading.md

今回はvite+reactでやってます。

パッケージインストール

pnpm i -D eslint-plugin-react

eslint.config.jsに追記

import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
+import react from "eslint-plugin-react";

export default tseslint.config(
  { ignores: ["dist"] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ["**/*.{ts,tsx}"],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      "react-hooks": reactHooks,
      "react-refresh": reactRefresh,
+      react: react,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      "react-refresh/only-export-components": [
        "warn",
        { allowConstantExport: true },
      ],
+      "react/jsx-props-no-spreading": [
+        "error",
+        {
+          html: "enforce",
+          custom: "enforce",
+          exceptions: [],
+        },
+      ],
    },
  }
);

最終的には

eslint.config.js
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";

export default tseslint.config(
  { ignores: ["dist"] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ["**/*.{ts,tsx}"],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      "react-hooks": reactHooks,
      "react-refresh": reactRefresh,
      react: react,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      "react-refresh/only-export-components": [
        "warn",
        { allowConstantExport: true },
      ],
      "react/jsx-props-no-spreading": [
        "error",
        {
          html: "enforce",
          custom: "enforce",
          exceptions: [],
        },
      ],
    },
  }
);

となります。

vscode上でみると、

おぉ〜〜〜〜!!!!エラーで弾いてる!!!

まとめ

今回は簡単にpropsを渡す上での注意についてをまとめました。
それぞれの対策方法はケースバイケースかなとは思っています。

他にもtypiaを使ってガードする方法、lodashを使ってpickで排除する方法などあるのではないかと思いますが私自身が検証できていないのでまたの機会にしようと思います。

Typescriptを使っているからと安心してはいけません!
後々悲しいことになります!

スプレッド構文で渡すのは確かにシンプルに書けるので良いですが、不要なものが入っていないかを確認、そして排除した上でpropsに渡しましょう!!!!

追記

なんか型でガードできるっぽいから今度やってみようかなと思いました。

https://zenn.dev/kagaya_22/articles/1887a1b5f90a4a

Discussion