--- a/templately/assets/js/admin.asset.php
+++ b/templately/assets/js/admin.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => 'f05b515bdd9ba821d1b3');
+<?php return array('dependencies' => array(), 'version' => 'fc481390f6f1a103e42b');
--- a/templately/assets/js/dashboard-style.asset.php
+++ b/templately/assets/js/dashboard-style.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '2c24a27a9ee3346c15c4');
+<?php return array('dependencies' => array(), 'version' => '4aa4ec9c911776e096d4');
--- a/templately/assets/js/notices.asset.php
+++ b/templately/assets/js/notices.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => 'c9a252314c85f179e21b');
+<?php return array('dependencies' => array(), 'version' => 'a2546b1b9047e583cec4');
--- a/templately/assets/js/templately.asset.php
+++ b/templately/assets/js/templately.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'wp-api-fetch', 'wp-blocks', 'wp-components', 'wp-data', 'wp-dom-ready', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-media-utils', 'wp-polyfill', 'wp-url'), 'version' => 'dca02c2eaf413c6210d6');
+<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'wp-api-fetch', 'wp-blocks', 'wp-components', 'wp-data', 'wp-dom-ready', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-media-utils', 'wp-polyfill', 'wp-url'), 'version' => 'a4e571d2893613fdcb1d');
--- a/templately/includes/API/AIContent.php
+++ b/templately/includes/API/AIContent.php
@@ -16,6 +16,7 @@
use WP_Error;
use TemplatelyCoreImporterUtilsUtils;
use TemplatelyCoreImporterUtilsAIUtils;
+use TemplatelyCoreImporterUtilsSignatureVerifier;
use TemplatelyCoreImporterParsersWXR_Parser;
class AIContent extends API {
@@ -42,10 +43,30 @@
$_route = $request->get_route();
if ('/templately/v1/ai-content/ai-update' === $_route || '/templately/v1/ai-content/ai-update-preview' === $_route) {
+ Helper::log( [
+ 'headers' => $request->get_headers(),
+ 'body' => $request->get_params(),
+ ], 'ai_update_request' );
+
if (empty($process_id)) {
return $this->error('invalid_id', __('Invalid ID.', 'templately'), 'calculate_credit', 400);
}
+ $header_api_key = sanitize_text_field($request->get_header('x_templately_apikey'));
+ if (empty($header_api_key)) {
+ $header_api_key = sanitize_text_field($request->get_header('X-Templately-Apikey'));
+ }
+
+ // Validate API key from header against database
+ if (empty($header_api_key)) {
+ return $this->error('missing_api_key', __('Missing API key in header.', 'templately'), 'ai-content/permission', 403);
+ }
+
+ $is_valid_key = $this->validate_api_key_in_db($header_api_key);
+ if (!$is_valid_key) {
+ return $this->error('invalid_api_key', __('Invalid API key provided in header.', 'templately'), 'ai-content/permission', 403);
+ }
+
// Check AI process data using API key-based storage
$ai_process_data = AIUtils::get_ai_process_data();
if (is_array($ai_process_data) && !empty($ai_process_data[$process_id])) {
@@ -59,8 +80,7 @@
// if ('/templately/v1/ai-content/attachments' === $_route) {
// return true;
// }
-
- return parent::permission_check($request);
+ return parent::_permission_check($request);
}
@@ -185,6 +205,15 @@
$ai_page_ids = $this->get_param('ai_page_ids', [], null);
$content_ids = $this->get_param('content_ids', [], null);
$session_id = $this->get_param('session_id'); // Add session_id parameter
+
+ // Security: Sanitize session_id if provided
+ if (!empty($session_id)) {
+ $session_id = AIUtils::sanitize_path_component($session_id, 'session_id');
+ if (is_wp_error($session_id)) {
+ return $session_id;
+ }
+ }
+
$preview_pages = $this->get_param('preview_pages', [], null);
$image_replace = $this->get_param('imageReplace', [], null);
$platform = $this->get_param('platform');
@@ -1040,4 +1069,43 @@
// Return the response as-is
return $data;
}
+
+ /**
+ * Validate API key against database
+ * Checks if the provided API key exists for any user on the current site
+ * Handles both single-site and multisite WordPress installations
+ *
+ * @param string $api_key The API key to validate
+ * @return bool True if valid, false otherwise
+ */
+ private function validate_api_key_in_db($api_key) {
+ global $wpdb;
+
+ $api_key = sanitize_text_field($api_key);
+
+ if (empty($api_key)) {
+ return false;
+ }
+
+ $meta_key = '_templately_api_key';
+
+ // Handle multisite: key will have site prefix in multisite
+ if (is_multisite()) {
+ // get_user_option() uses the format: {$wpdb->base_prefix}{$blog_id}_{$meta_key}
+ // For current blog, we need to check with the current blog prefix
+ $blog_id = get_current_blog_id();
+ $meta_key = $wpdb->get_blog_prefix($blog_id) . $meta_key;
+ }
+
+ // Query to check if this API key exists for any user
+ $query = $wpdb->prepare(
+ "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1",
+ $meta_key,
+ $api_key
+ );
+
+ $user_id = $wpdb->get_var($query);
+
+ return !empty($user_id);
+ }
}
--- a/templately/includes/API/Items.php
+++ b/templately/includes/API/Items.php
@@ -519,7 +519,7 @@
];
$response = $this->http()->query(
'featuredItems',
- 'data{ id, name, slug, price, type, thumbnail }',
+ 'data{ id, name, slug, price, type, thumbnail, badges }',
$funcArgs
)->post();
@@ -549,7 +549,7 @@
];
$response = $this->http()->query(
'trendingItems',
- 'data{ id, name, slug, price, type, thumbnail }',
+ 'data{ id, name, slug, price, type, thumbnail, badges }',
$funcArgs
)->post();
@@ -590,12 +590,12 @@
$funcArgs = [
'id' => $item_id,
'type' => $type,
- 'limit' => 3, // Limit to 3 related items
+ 'limit' => 4, // Limit to 3 related items
];
$response = $this->http()->query(
'relatedItems',
- 'id, name, slug, price, type, thumbnail',
+ 'id, name, slug, price, type, thumbnail, badges',
$funcArgs
)->post();
--- a/templately/includes/Core/Importer/FullSiteImport.php
+++ b/templately/includes/Core/Importer/FullSiteImport.php
@@ -125,6 +125,12 @@
if(!empty($data['session_id'])){
$session_id = $data['session_id'];
+ // Security: Sanitize session_id from user input
+ $session_id = AIUtils::sanitize_path_component($data['session_id'], 'session_id');
+ if (is_wp_error($session_id)) {
+ wp_send_json_error(['message' => $session_id->get_error_message()]);
+ return;
+ }
$session_data = Utils::get_session_data($session_id);
$data = array_merge($session_data, $data);
}
@@ -173,8 +179,12 @@
$upload_dir = wp_upload_dir();
- // passed in post
- $session_id = $data['session_id'];
+ // Security: Sanitize session_id from user input
+ $session_id = AIUtils::sanitize_path_component($data['session_id'], 'session_id');
+ if (is_wp_error($session_id)) {
+ wp_send_json_error(['message' => $session_id->get_error_message()]);
+ return;
+ }
$tmp_dir = trailingslashit($upload_dir['basedir']) . 'templately' . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR;
$prv_dir = trailingslashit($upload_dir['basedir']) . 'templately' . DIRECTORY_SEPARATOR . 'preview' . DIRECTORY_SEPARATOR;
@@ -465,7 +475,12 @@
try {
// TODO: Need to check if user is connected or not
if(!empty($_GET['session_id'])){
- $this->session_id = sanitize_text_field($_GET['session_id']);
+ // Security: Sanitize session_id from user input
+ $session_id = AIUtils::sanitize_path_component(sanitize_text_field($_GET['session_id']), 'session_id');
+ if (is_wp_error($session_id)) {
+ $this->throw($session_id->get_error_message());
+ }
+ $this->session_id = $session_id;
}
else {
$this->throw(__('Invalid Session ID.', 'templately'));
@@ -728,6 +743,12 @@
'download_key' => $this->download_key,
]);
+ // Security: Validate file path is within WordPress upload directory before writing
+ $validation = AIUtils::validate_file_path($this->filePath);
+ if (is_wp_error($validation)) {
+ $this->throw($validation->get_error_message());
+ }
+
wp_mkdir_p(dirname($this->filePath));
if (file_put_contents($this->filePath, $response['body'])) { // phpcs:ignore
@@ -946,7 +967,8 @@
'name' => 'ai-content',
'message' => __('Missing Credit Cost', 'templately'),
],
- null // No specific template ID for this context
+ null, // No specific template ID for this context
+ 30
);
}
--- a/templately/includes/Core/Importer/Runners/Finalizer.php
+++ b/templately/includes/Core/Importer/Runners/Finalizer.php
@@ -168,7 +168,7 @@
$processed_pages = get_option("templately_ai_processed_pages", []);
$updated_ids = $processed_pages[$this->process_id] ?? [];
$ai_paths = $this->generateAiFilePaths($old_template_id);
- if(!empty($this->process_id) && is_numeric($this->process_id) && $this->is_ai_content($old_template_id) && !file_exists($ai_paths['ai_file_path'])){
+ if($this->is_ai_content($old_template_id) && !file_exists($ai_paths['ai_file_path'])){
// Use the static timeout-aware wait handler from AIUtils
AIUtils::handle_sse_wait_with_timeout(
$this->session_id,
--- a/templately/includes/Core/Importer/Utils/AIUtils.php
+++ b/templately/includes/Core/Importer/Utils/AIUtils.php
@@ -12,6 +12,111 @@
class AIUtils {
/**
+ * Sanitize path component for security - prevents path traversal attacks
+ *
+ * @param mixed $value The value to sanitize (string or numeric)
+ * @param string $type Type for error message ('session_id', 'content_id', 'path_key')
+ * @return string|WP_Error Sanitized value or error if invalid
+ */
+ public static function sanitize_path_component($value, $type = 'path_component') {
+ // Convert numeric values to string (content IDs like 136 are valid)
+ if (is_numeric($value)) {
+ $value = (string) $value;
+ }
+
+ // Check for empty or non-string values
+ if (empty($value) || !is_string($value)) {
+ return Helper::error(
+ 'invalid_' . $type,
+ sprintf(__('Invalid %s: empty or not a valid value.', 'templately'), $type),
+ 'sanitize_path_component',
+ 400
+ );
+ }
+
+ // Check for path traversal attempts BEFORE sanitizing
+ if (strpos($value, '..') !== false ||
+ strpos($value, '/') !== false ||
+ strpos($value, '\') !== false) {
+ return Helper::error(
+ 'invalid_' . $type,
+ sprintf(__('Invalid %s: contains path separators.', 'templately'), $type),
+ 'sanitize_path_component',
+ 400
+ );
+ }
+
+ // Apply WordPress sanitize_file_name for additional safety
+ return sanitize_file_name($value);
+ }
+
+ /**
+ * Validate that a file path is within WordPress upload directory
+ * Prevents path traversal attacks
+ *
+ * @param string $file_path The file path to validate
+ * @return bool|WP_Error True if valid, WP_Error otherwise
+ */
+ public static function validate_file_path($file_path) {
+ // Get WordPress upload directory
+ $upload_dir = wp_upload_dir();
+ $real_upload_base = realpath($upload_dir['basedir']);
+
+ if ($real_upload_base === false) {
+ return Helper::error(
+ 'invalid_upload_dir',
+ __('WordPress upload directory is not accessible.', 'templately'),
+ 'validate_file_path',
+ 500
+ );
+ }
+
+ // For file path, check the directory if file doesn't exist yet
+ $dir_to_check = dirname($file_path);
+
+ // Create directory if it doesn't exist (for pre-write validation)
+ if (!file_exists($dir_to_check)) {
+ wp_mkdir_p($dir_to_check);
+ }
+
+ $real_path = realpath($dir_to_check);
+ if ($real_path === false) {
+ return Helper::error(
+ 'path_not_exists',
+ __('File path does not exist or is not accessible.', 'templately'),
+ 'validate_file_path',
+ 400
+ );
+ }
+
+ // Normalize paths with trailing directory separator
+ $normalized_upload_base = rtrim($real_upload_base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+ $normalized_path = rtrim($real_path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+
+ // Check if path starts with upload directory (PHP 8+ style with polyfill)
+ // This prevents partial matches like /var/www/uploads-backup matching /var/www/uploads
+ $is_subdirectory = false;
+ if (function_exists('str_starts_with')) {
+ // PHP 8+
+ $is_subdirectory = str_starts_with($normalized_path, $normalized_upload_base);
+ } else {
+ // PHP 7.4 polyfill
+ $is_subdirectory = substr($normalized_path, 0, strlen($normalized_upload_base)) === $normalized_upload_base;
+ }
+
+ if (!$is_subdirectory) {
+ return Helper::error(
+ 'path_outside_uploads',
+ __('Invalid file path: path is outside WordPress upload directory.', 'templately'),
+ 'validate_file_path',
+ 400
+ );
+ }
+
+ return true;
+ }
+
+ /**
* Handle SSE message wait with timeout exit condition
* Static utility function for reusable timeout handling across different import contexts
*
@@ -24,7 +129,7 @@
* @param string|null $old_template_id Optional template ID for local site polling
* @return bool True if should continue processing, false if should exit
*/
- public static function handle_sse_wait_with_timeout($session_id, $progress_id, $updated_ids, $ai_page_ids, $sse_message_callback, $additional_sse_data = [], $old_template_id = null) {
+ public static function handle_sse_wait_with_timeout($session_id, $progress_id, $updated_ids, $ai_page_ids, $sse_message_callback, $additional_sse_data = [], $old_template_id = null, $timeout_seconds = 420) {
$total_pages = count($ai_page_ids);
$updated_pages = count($updated_ids['pages'] ?? []);
@@ -47,8 +152,8 @@
return true;
}
- // Check if time difference is less than 5 minutes (timeout condition)
- if (empty($last_time) || ($current_time - $last_time) < 7 * MINUTE_IN_SECONDS) {
+ // Check if time difference is less than timeout(default 7 minutes) (timeout condition)
+ if (empty($last_time) || ($current_time - $last_time) < $timeout_seconds) {
// For local sites with template ID, attempt polling before waiting
if ($is_local_site && !empty($old_template_id)) {
$process_id = $session_data['process_id'] ?? null;
@@ -412,6 +517,18 @@
* @return array|WP_Error Result array with status and data
*/
public static function save_template_to_file($process_id, $session_id, $content_id, $template, $ai_page_ids, $is_skipped = false) {
+ // Security: Sanitize session_id
+ $session_id = self::sanitize_path_component($session_id, 'session_id');
+ if (is_wp_error($session_id)) {
+ return $session_id;
+ }
+
+ // Security: Sanitize content_id
+ $content_id = self::sanitize_path_component($content_id, 'content_id');
+ if (is_wp_error($content_id)) {
+ return $content_id;
+ }
+
$upload_dir = wp_upload_dir();
// Always save to tmp directory for AI content workflow
@@ -442,9 +559,28 @@
return Helper::error('invalid_content_id', __('Content ID not found in AI page IDs.', 'templately'), 'save_template_to_file', 400);
}
+ // Security: Sanitize found_key parts (e.g., "templates/page" -> sanitize each part)
+ $key_parts = explode('/', $found_key);
+ $sanitized_parts = [];
+ foreach ($key_parts as $part) {
+ $sanitized = self::sanitize_path_component($part, 'path_key');
+ if (is_wp_error($sanitized)) {
+ return $sanitized;
+ }
+ $sanitized_parts[] = $sanitized;
+ }
+ $found_key = implode(DIRECTORY_SEPARATOR, $sanitized_parts);
+
// Create directory and file path
$page_dir = $tmp_dir . $found_key . DIRECTORY_SEPARATOR;
$file_path = $page_dir . $content_id . '.ai.json';
+
+ // Security: Validate path is within expected directory before writing
+ $validation = self::validate_file_path($file_path);
+ if (is_wp_error($validation)) {
+ return $validation;
+ }
+
wp_mkdir_p($page_dir);
// Save the file
--- a/templately/includes/Core/Importer/Utils/SignatureVerifier.php
+++ b/templately/includes/Core/Importer/Utils/SignatureVerifier.php
@@ -0,0 +1,134 @@
+<?php
+namespace TemplatelyCoreImporterUtils;
+
+use TemplatelyUtilsHelper;
+
+/**
+ * Class SignatureVerifier
+ *
+ * Verifies HMAC-SHA256 signatures from Templately backend callbacks
+ * to prevent arbitrary file write vulnerabilities.
+ *
+ * @package TemplatelyCoreImporterUtils
+ */
+class SignatureVerifier {
+
+ /**
+ * Verify callback signature from Templately backend.
+ *
+ * @param array $payload Request payload data
+ * @param string $signature Signature from X-Templately-Signature header
+ * @param int $timestamp Timestamp from X-Templately-Timestamp header
+ * @param string $api_key User's API key (used as secret)
+ * @param int $tolerance Time tolerance in seconds (default: 300 = 5 minutes)
+ * @return bool|WP_Error True if valid, WP_Error otherwise
+ */
+ public static function verify($payload, $signature, $timestamp, $api_key, $tolerance = 300) {
+ if (empty($api_key)) {
+ return Helper::error(
+ 'missing_api_key',
+ __('API key not provided for signature verification', 'templately'),
+ 'verify_signature',
+ 401
+ );
+ }
+
+ // Validate inputs
+ if (empty($signature) || empty($timestamp) || !is_numeric($timestamp)) {
+ return Helper::error(
+ 'invalid_signature_headers',
+ __('Invalid signature or timestamp headers', 'templately'),
+ 'verify_signature',
+ 401
+ );
+ }
+
+ // Check timestamp to prevent replay attacks
+ if (!self::is_timestamp_valid((int) $timestamp, $tolerance)) {
+ return Helper::error(
+ 'timestamp_expired',
+ __('Callback timestamp expired or invalid', 'templately'),
+ 'verify_signature',
+ 401
+ );
+ }
+
+ // Generate expected signature using API key
+ $expected_signature = self::generate_signature($payload, (int) $timestamp, $api_key);
+
+ // Use hash_equals to prevent timing attacks
+ if (!hash_equals($expected_signature, $signature)) {
+ return Helper::error(
+ 'invalid_signature',
+ __('Invalid signature', 'templately'),
+ 'verify_signature',
+ 401
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if timestamp is within acceptable tolerance.
+ *
+ * @param int $timestamp Unix timestamp to check
+ * @param int $tolerance Tolerance in seconds
+ * @return bool True if timestamp is valid
+ */
+ private static function is_timestamp_valid($timestamp, $tolerance) {
+ $current_time = time();
+ $time_difference = abs($current_time - $timestamp);
+
+ return $time_difference <= $tolerance;
+ }
+
+ /**
+ * Generate HMAC-SHA256 signature for payload.
+ *
+ * @param array $payload Request payload
+ * @param int $timestamp Unix timestamp
+ * @param string $api_key User's API key (used as secret)
+ * @return string HMAC signature
+ */
+ private static function generate_signature($payload, $timestamp, $api_key) {
+ $canonical_string = self::create_canonical_string($payload, $timestamp);
+ return hash_hmac('sha256', $canonical_string, $api_key);
+ }
+
+ /**
+ * Create canonical string from payload and timestamp.
+ *
+ * Only includes security-critical fields in signature to avoid
+ * performance issues with large template content.
+ *
+ * @param array $payload Request payload
+ * @param int $timestamp Unix timestamp
+ * @return string Canonical string
+ */
+ private static function create_canonical_string($payload, $timestamp) {
+ // Extract only security-critical fields for signature
+ // Exclude large content fields like 'template' and 'error'
+ // Also exclude 'isSkipped' as requested
+ $signature_fields = [
+ 'process_id' => isset($payload['process_id']) ? $payload['process_id'] : null,
+ 'content_id' => isset($payload['content_id']) ? $payload['content_id'] : null,
+ 'template_id' => isset($payload['template_id']) ? $payload['template_id'] : null,
+ 'type' => isset($payload['type']) ? $payload['type'] : null,
+ ];
+
+ // Remove null values
+ $signature_fields = array_filter($signature_fields, function ($value) {
+ return $value !== null;
+ });
+
+ // Sort keys for consistency
+ ksort($signature_fields);
+
+ // JSON encode with consistent flags
+ $payload_json = wp_json_encode($signature_fields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
+ // Combine timestamp and payload
+ return $timestamp . '.' . $payload_json;
+ }
+}
--- a/templately/includes/Plugin.php
+++ b/templately/includes/Plugin.php
@@ -41,7 +41,7 @@
use TemplatelyCorePlatformElementor;
final class Plugin extends Base {
- public $version = '3.4.8';
+ public $version = '3.4.9';
public $admin;
public $settings;
--- a/templately/includes/Utils/Helper.php
+++ b/templately/includes/Utils/Helper.php
@@ -97,6 +97,15 @@
*/
public static function get_api_url($endpoint): string {
$base_url = self::is_dev_api() ? 'https://app.templately.dev' : 'https://app.templately.com';
+
+ /**
+ * Filter the base URL for development API
+ *
+ * @since 3.5.0
+ * @param string $base_url The default base URL
+ */
+ $base_url = apply_filters('templately_dev_api_base_url', $base_url);
+
return "{$base_url}/api/{$endpoint}";
}
--- a/templately/includes/Utils/Http.php
+++ b/templately/includes/Utils/Http.php
@@ -44,6 +44,15 @@
if ( !$is_live_api && Helper::is_dev_api() ) {
$this->url = 'https://app.templately.dev/api/plugin';
}
+
+ /**
+ * Filter the API endpoint URL
+ *
+ * @since 3.5.0
+ * @param string $url The endpoint URL
+ */
+ $this->url = apply_filters('templately_dev_api_endpoint_url', $this->url);
+
return $this->url;
}
--- a/templately/templately.php
+++ b/templately/templately.php
@@ -5,7 +5,7 @@
* Description: The Best Templates Cloud for Elementor & Gutenberg. Get access to stunning templates, WorkSpace, Cloud Library & many more.
* Plugin URI: https://templately.com
* Author: Templately
- * Version: 3.4.8
+ * Version: 3.4.9
* Author URI: https://templately.com/
* Text Domain: templately
* Domain Path: /languages