مقاله ووکامرس

Abilities API در عمل؛ یک توانایی ووکامرسی امن برای وضعیت سفارش

Abilities API به ابزارهای هوشمند اجازه می‌دهد کارهای افزونهٔ شما را پیدا و اجرا کنند. در این یادداشت یک توانایی واقعی برای خواندن وضعیت سفارش ووکامرس می‌سازیم و روی مهم‌ترین بخشش، یعنی بررسی دسترسی، وسواس به خرج می‌دهیم.

حمید اعراب توسعه‌دهنده ارشد بک‌اند وردپرس و ووکامرس
انتشار
زمان مطالعه
۶ دقیقه مطالعه

حمید اعراب ۲۷ مهر ۱۴۰۵ ۶ دقیقه مطالعه

جلد پیکسلی: سپر با تیک سبز و خروجی اجرای یک Ability
فهرست مطالب (7)

از وقتی وردپرس ۷ آمده، زیاد این سؤال را می‌شنوم: «این Abilities API به درد فروشگاه من هم می‌خورد یا فقط برای هوش مصنوعی است؟» جواب کوتاه این است که Abilities API در اصل یک قرارداد است. شما اعلام می‌کنید افزونه‌تان چه کاری بلد است، چه ورودی‌ای می‌گیرد، چه خروجی‌ای می‌دهد و چه کسی اجازهٔ اجرایش را دارد. حالا چه یک دستیار هوشمند از طریق MCP صدایش بزند، چه یک اسکریپت ساده یا اپلیکیشن موبایل خودتان، همه از همان قرارداد استفاده می‌کنند.

در این یادداشت یک توانایی واقعی می‌سازیم: «وضعیت سفارش». مثال ساده‌ای است، ولی همهٔ تصمیم‌های مهم امنیتی را لازم دارد.

اول تصمیم بگیرید چه چیزی را بیرون نمی‌دهید

قبل از نوشتن حتی یک خط کد، فهرست کنید که خروجی چه چیزهایی دارد. برای وضعیت سفارش، من فقط این‌ها را برمی‌گردانم: شناسه، وضعیت، مبلغ کل، واحد پول و تاریخ ثبت. نه نام مشتری، نه ایمیل، نه آدرس.

دلیلش ساده است: هر چیزی که در خروجی یک توانایی باشد، دیر یا زود از جایی سر درمی‌آورد که انتظارش را ندارید؛ در لاگ یک ابزار، در حافظهٔ یک مدل زبانی یا در اسکرین‌شات یک گزارش باگ. کمینه‌سازی داده این‌جا یک الزام طراحی است، نه یک توصیهٔ حقوقی.

ثبت دسته و توانایی

هر توانایی باید دقیقاً در یک دسته باشد، و دسته باید زودتر ثبت شود:

add_action( 'wp_abilities_api_categories_init', function () {
	wp_register_ability_category( 'store-orders', array(
		'label'       => __( 'Store orders', 'hamid-store' ),
		'description' => __( 'Read-only information about WooCommerce orders.', 'hamid-store' ),
	) );
} );

add_action( 'wp_abilities_api_init', function () {
	if ( ! function_exists( 'wc_get_order' ) ) {
		return; // WooCommerce is not active.
	}

	wp_register_ability( 'hamid-store/order-status', array(
		'label'               => __( 'Order status', 'hamid-store' ),
		'description'         => __( 'Returns the status, total and date of one order. Read-only.', 'hamid-store' ),
		'category'            => 'store-orders',
		'input_schema'        => array(
			'type'                 => 'object',
			'properties'           => array(
				'order_id' => array( 'type' => 'integer', 'minimum' => 1 ),
			),
			'required'             => array( 'order_id' ),
			'additionalProperties' => false,
		),
		'output_schema'       => array(
			'type'       => 'object',
			'properties' => array(
				'id'       => array( 'type' => 'integer' ),
				'status'   => array( 'type' => 'string' ),
				'total'    => array( 'type' => 'string' ),
				'currency' => array( 'type' => 'string' ),
				'created'  => array( 'type' => 'string' ),
			),
		),
		'execute_callback'    => 'hamid_store_order_status',
		'permission_callback' => 'hamid_store_can_read_order',
		'meta'                => array(
			'show_in_rest' => true,
			'annotations'  => array(
				'readonly'    => true,
				'destructive' => false,
				'idempotent'  => true,
			),
		),
	) );
} );

چند نکته دربارهٔ همین بخش:

  • نام توانایی: باید namespace داشته باشد و فقط حروف کوچک، عدد، خط تیره و اسلش مجاز است.
  • **additionalProperties => false:** جلوی ورودی‌های اضافه و پیش‌بینی‌نشده را می‌گیرد. توانایی‌ای که فقط یک عدد لازم دارد، نباید هر چیز دیگری را قبول کند.
  • annotationها: فقط راهنما هستند و جلوی هیچ کاری را نمی‌گیرند. ولی به ابزارها می‌گویند این توانایی چیزی را تغییر نمی‌دهد، و مسیر REST هم بر همین اساس برای اجرای آن متد GET را انتظار دارد.
  • **show_in_rest:** پیش‌فرضش خاموش است. اگر روشنش نکنید، توانایی فقط از داخل PHP قابل اجراست.

بخش اصلی: بررسی دسترسی

permission_callback پیش از هر اجرا صدا زده می‌شود و همان ورودی‌ای را می‌گیرد که به execute_callback می‌رسد. این‌جا همان جایی است که بیشتر باگ‌های امنیتی رخ می‌دهد:

function hamid_store_can_read_order( $input ) {
	// Shop managers and administrators can read any order.
	if ( current_user_can( 'edit_shop_orders' ) ) {
		return true;
	}

	// A logged-in customer can read their own orders only.
	$user_id = get_current_user_id();
	if ( $user_id <= 0 ) {
		return false;
	}

	$order = wc_get_order( absint( $input['order_id'] ?? 0 ) );

	return $order instanceof WC_Order && (int) $order->get_customer_id() === $user_id;
}

خط if ( $user_id <= 0 ) را دست‌کم نگیرید. سفارش‌هایی که مهمان ثبت کرده، شناسهٔ مشتری‌شان صفر است. کاربر واردنشده هم شناسه‌اش صفر است. بدون این شرط، 0 === 0 درست درمی‌آید و هر بازدیدکنندهٔ ناشناسی می‌تواند وضعیت همهٔ سفارش‌های مهمان را بخواند. این دقیقاً همان نوع باگ «کنترل دسترسی شکسته» است که در گزارش‌های امنیتی وردپرس بیشترین سوءاستفاده را داشته.

یک نکتهٔ ظریف دیگر: اگر سفارش وجود نداشت، false برمی‌گردانم، نه خطای «سفارش پیدا نشد». پیام متفاوت به مهاجم می‌گوید کدام شماره‌ها واقعاً سفارش‌اند.

اجرای توانایی

خود اجرا ساده است، چون کار سخت را بررسی دسترسی انجام داده. فقط یادتان باشد از لایهٔ CRUD ووکامرس استفاده کنید، نه get_post_meta. این‌طوری کد شما با HPOS و هر روش ذخیره‌سازی بعدی هم کار می‌کند:

function hamid_store_order_status( $input ) {
	$order = wc_get_order( absint( $input['order_id'] ?? 0 ) );

	if ( ! $order instanceof WC_Order ) {
		return new WP_Error( 'hamid_store_no_order', __( 'Order not available.', 'hamid-store' ) );
	}

	$created = $order->get_date_created();

	return array(
		'id'       => $order->get_id(),
		'status'   => $order->get_status(),
		'total'    => $order->get_total(),
		'currency' => $order->get_currency(),
		'created'  => $created ? $created->date( 'c' ) : '',
	);
}

با اینکه طرح ورودی، عدد بودن order_id را تعیین کرده، من همچنان absint را داخل هر دو callback می‌گذارم. این دفاع لایه‌ای است و هزینه‌ای هم ندارد.

صدا زدن از PHP و REST

از داخل خود وردپرس:

$ability = wp_get_ability( 'hamid-store/order-status' );
$result  = $ability ? $ability->execute( array( 'order_id' => 1042 ) ) : null;

و از بیرون، با کاربر احراز هویت‌شده (مثلاً با Application Password)، چون توانایی فقط‌خواندنی است با GET و ورودی JSON در پارامتر input:

curl -u 'shop-manager:xxxx xxxx xxxx xxxx' \
  "https://example.com/wp-json/wp-abilities/v1/hamid-store/order-status/run?input=%7B%22order_id%22%3A1042%7D"

همهٔ مسیرهای wp-abilities/v1 کاربر واردشده می‌خواهند. اگر permission_callback رد کند، پاسخ خطای ability_invalid_permissions با کد ۴۰۳ است.

چک‌لیست قبل از انتشار یک توانایی

  1. خروجی را به حداقل لازم محدود کنید و دادهٔ شخصی را بیرون نگذارید.
  2. permission_callback را با سه کاربر تست کنید: مدیر، مشتری صاحب سفارش و مشتری دیگر. یک بار هم بدون ورود تست کنید.
  3. سفارش مهمان را جدا تست کنید؛ همان‌جا که شناسهٔ مشتری صفر است.
  4. برای هر کاری که چیزی را تغییر می‌دهد، readonly را false و در صورت لزوم destructive را true بگذارید، و بررسی دسترسی‌اش را از نسخهٔ فقط‌خواندنی سخت‌گیرانه‌تر کنید.
  5. اگر لازم نیست از بیرون صدا زده شود، show_in_rest را خاموش بگذارید.

Abilities API یکی از بهترین چیزهایی است که در این چند سال به وردپرس اضافه شده، چون بالاخره یک راه استاندارد برای گفتن «افزونهٔ من چه کاری بلد است» داریم. ولی همین استاندارد بودن یعنی هر اشتباه در بررسی دسترسی، با یک درخواست ساده از بیرون قابل سوءاستفاده است. توانایی‌ها را مثل endpointهای REST جدی بگیرید، چون در عمل همان هستند.

منابع

$ ./request_review

همین مشکل را در فروشگاه خودتان دارید؟

توسعه‌دهنده ارشد WordPress و WooCommerce برای معماری، اتصال سیستم‌ها و حل مسائل پیچیده production.

درخواست بررسی فنی

دیدگاه‌ها

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *