WordPress REST APIのValidation回避と、投稿のtitleとtitle.rawの関係。

今回は投稿のREST APIに疑問があったので調べてみました。
例えば投稿のtitleはスキーマ定義ではtitle.rawおよびtitle.renderedが定義してあります。

以下はREST APIからスキーマの一部を表示したものです。

以下はtitleのスキーマを定義しているWP_REST_Posts_Controller#get_item_schema()からの抜粋です。

case 'title':
    $schema['properties']['title'] = array(
        'description' => __( 'The title for the post.' ),
        'type'        => 'object',
        'context'     => array( 'view', 'edit', 'embed' ),
        'arg_options' => array(
            'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
            'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
        ),
        'properties'  => array(
            'raw'      => array(
                'description' => __( 'Title for the post, as it exists in the database.' ),
                'type'        => 'string',
                'context'     => array( 'edit' ),
            ),
            'rendered' => array(
                'description' => __( 'HTML title for the post, transformed for display.' ),
                'type'        => 'string',
                'context'     => array( 'view', 'edit', 'embed' ),
                'readonly'    => true,
            ),
        ),
    );

title.renderedは読み込み専用です。
なのでtitle.rawだけが使えるはずです。

ところが以下のコードでも投稿できてしまいます。

wp.apiFetch({ path: '/wp/v2/posts', method: 'POST', data: { title: 'Hello Title', status: 'publish' }})

では新規投稿のルート定義を見ます(WP_REST_Posts_Controller#register_route()から抜粋です)。

register_rest_route(
    $this->namespace,
    '/' . $this->rest_base,
    array(
        array(
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => array( $this, 'get_items' ),
            'permission_callback' => array( $this, 'get_items_permissions_check' ),
            'args'                => $this->get_collection_params(),
        ),
        array(
            'methods'             => WP_REST_Server::CREATABLE,
            'callback'            => array( $this, 'create_item' ),
            'permission_callback' => array( $this, 'create_item_permissions_check' ),
            'args'                => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
        ),
        'allow_batch' => $this->allow_batch,
        'schema'      => array( $this, 'get_public_item_schema' ),
    )
);

get_endpoint_args_for_item_schema()が重要です。
このメソッドは内部でrest_get_endpoint_args_for_schema()を呼び出しますが、
以下を見るとvalidate_callbackおよびsanitize_callbackが初期値として定義されています。

function rest_get_endpoint_args_for_schema( $schema, $method = WP_REST_Server::CREATABLE ) {

    $schema_properties       = ! empty( $schema['properties'] ) ? $schema['properties'] : array();
    $endpoint_args           = array();
    $valid_schema_properties = rest_get_allowed_schema_keywords();
    $valid_schema_properties = array_diff( $valid_schema_properties, array( 'default', 'required' ) );

    foreach ( $schema_properties as $field_id => $params ) {

        // Arguments specified as `readonly` are not allowed to be set.
        if ( ! empty( $params['readonly'] ) ) {
            continue;
        }

        //
        // ほらほらほら!
        //
        // ↓ ここを見てみてみて! すべてのargsに二つのコールバックが自動的に追加されるよ!
        //
        $endpoint_args[ $field_id ] = array(
            'validate_callback' => 'rest_validate_request_arg',
            'sanitize_callback' => 'rest_sanitize_request_arg',
        );

        if ( WP_REST_Server::CREATABLE === $method && isset( $params['default'] ) ) {
            $endpoint_args[ $field_id ]['default'] = $params['default'];
        }

        if ( WP_REST_Server::CREATABLE === $method && ! empty( $params['required'] ) ) {
            $endpoint_args[ $field_id ]['required'] = true;
        }

        foreach ( $valid_schema_properties as $schema_prop ) {
            if ( isset( $params[ $schema_prop ] ) ) {
                $endpoint_args[ $field_id ][ $schema_prop ] = $params[ $schema_prop ];
            }
        }

        // Merge in any options provided by the schema property.
        if ( isset( $params['arg_options'] ) ) {

            // Only use required / default from arg_options on CREATABLE endpoints.
            if ( WP_REST_Server::CREATABLE !== $method ) {
                $params['arg_options'] = array_diff_key(
                    $params['arg_options'],
                    array(
                        'required' => '',
                        'default'  => '',
                    )
                );
            }

            //
            //
            // でもね、でもね、args_optionsでマージされるんだねー!
            //
            $endpoint_args[ $field_id ] = array_merge( $endpoint_args[ $field_id ], $params['arg_options'] );
        }
    }

    return $endpoint_args;
}

ところが、これらのコールバックを上書きする箇所があります。

$endpoint_args[ $field_id ] = array_merge( $endpoint_args[ $field_id ], $params['arg_options'] );

arg_optionsの部分は上書きされるんですね。

もう一度titleのスキーマ定義を見てみます。

$schema['properties']['title'] = array(
    'description' => __( 'The title for the post.' ),
    'type'        => 'object',
    'context'     => array( 'view', 'edit', 'embed' ),
    'arg_options' => array(
        'sanitize_callback' => null, // Note: sanitization implemented in self::prepare_item_for_database().
        'validate_callback' => null, // Note: validation implemented in self::prepare_item_for_database().
    ),
    'properties'  => array(
        'raw'      => array(
            'description' => __( 'Title for the post, as it exists in the database.' ),
            'type'        => 'string',
            'context'     => array( 'edit' ),
        ),
        'rendered' => array(
            'description' => __( 'HTML title for the post, transformed for display.' ),
            'type'        => 'string',
            'context'     => array( 'view', 'edit', 'embed' ),
            'readonly'    => true,
        ),
    ),
);

はい、arg_optionsがあればその値は上書きされます。
validate_callbackおよびsanitize_callbackはともにnullになってしまいます。
実際にtitleの定義を見てみましょう。

試しにformatを見るとデフォルトの値が設定されていることもわかります。

次に、

このスキーマが実際に使用されるのはどのタイミングを知る必要があります。
REST APIが実行されるタイミングで真っ先に見るメソッドがWP_REST_Server#dispatch()です。

このメソッドはリクエストをバリデートおよびサニタイズした後、RESTコントローラのアクションメソッドを実行するようになってます。

バリデートして、サニタイズしますが、もし失敗したらエラーになります。
成功したらコントローラのアクションを実行します。

大まかには以下のようになってます。

WP_REST_Server

    // REST APIを叩くと実行されるメソッド
    dispatch( request )
        WP_REST_Request#has_valid_params()
        WP_REST_Request#sanitize_params()
        this#render_to_request(request, ...)

    // RESTコントローラのアクションを実行(register_route()で設定したルートを実行)
    respond_to_request( request, ... )
        WP_REST_Posts_Controller#create_item()

WP_REST_Request

    // バリデートする
    has_valid_params()

    // サニタイズする
    sanitize_params()

以下はWP_REST_Server#dispatch()を省略したものです。

public function dispatch( $request ) {

    if ( ! is_wp_error( $error ) ) {
        $check_required = $request->has_valid_params();
        if ( is_wp_error( $check_required ) ) {
            $error = $check_required;
        } else {
            $check_sanitized = $request->sanitize_params();
            if ( is_wp_error( $check_sanitized ) ) {
                $error = $check_sanitized;
            }
        }
    }

    $response = $this->respond_to_request( $request, $route, $handler, $error );
    array_pop( $this->dispatching_requests );
    return $response;
}

バリデートするのが以下の部分です。

$request->has_valid_params();

このメソッドはWP_REST_Request#has_valid_params()を実行してます。
実はここでargsを取り出してバリデーションを行っているのですが、
validate_callbackのコールバックにargsを渡してバリデートしているのです。

つまりこのコールバックがrest_validate_request_argsであれば期待通りの動きをするのですが、
コールバックが先ほどのtitleのようにnullだとargsによるバリデーションはされないんです。
独自のバリデーションをしたければデフォルトのコールバックでもnullでもなく独自のコールバックを設定することもできます。

今回のことからtitleはバリデーションされません。

RESTコントローラを見てみます。
なんとリクエストの情報をそのまま取得してます。

WP_REST_Posts_Controller#prepare_item_for_database()

if ( ! empty( $schema['properties']['title'] ) && isset( $request['title'] ) ) {
    if ( is_string( $request['title'] ) ) {
        $prepared_post->post_title = $request['title'];
    } elseif ( ! empty( $request['title']['raw'] ) ) {
        $prepared_post->post_title = $request['title']['raw'];
    }
}

見た感じ、titleが文字列型かチェック、違えばtitle.rawを無条件に取得してます。
title.rawに数値やオブジェクトを渡してもそのまま有効になってしまいます。

取得した情報はwp_insert_post()によってDBに追加されます、
スキーマによるバリデーションはされてませんが、wp_indert_post()は内部で最低限のサニタイズはしてます。
以下の実行例ではtitle.rawの値は123という数値として渡しますが、

wp.apiFetch({ path: '/wp/v2/posts', method: 'POST', data: { title: { raw: 123 }, status: 'publish' }})

DBに追加するころにはwp_insert_post()が内部的に以下のサニタイズをするため文字列に変換されるようになってます。

$postarr = sanitize_post( $postarr, 'db' );

というように、コールバックをnullにすることでバリデーション回避ができるんですね。

ところが以下のコードを実行するとエラーが発生します。

wp.apiFetch({ path: '/wp/v2/posts', method: 'POST', data: { title: { raw: { msg: 'Hello' } }, status: 'publish' }})

title.rawにオブジェクトを渡すと、trim()関数にそのオブジェクトを渡してエラーになってます。

この辺もうちょっとスマートに設計できなかったんやろか・・・。

BlockEditor certificate css DataGrid Docker Gutenberg Hyper-V iframe MUI openssl PHP React ReduxToolkit REST ubuntu WordPress オレオレ認証局 フレームワーク