Преглед изворни кода

Phase C — gated product-export download — v1.6.4

Implements review-00-plan-00.md Phase C. Items addressed:

* H5 — the product exporter no longer drops a public, never-cleaned CSV
  under uploads/. It now mirrors the category exporter pattern:

  - Files live in uploads/studiou-wcpcm-product-exports/ with an
    index.php silence file and an .htaccess deny rule written lazily on
    first use.

  - Filenames embed a 32-char hex token from random_bytes(16), so the
    path is unguessable even if directory listing leaks.

  - Downloads route through admin_init via a query-string action on the
    Product Export admin page. The handler verifies the
    studiou-wcpcm-product-export-download nonce, checks
    manage_woocommerce, validates the requested file matches the strict
    regex /^product-export-[a-f0-9]{32}\.csv$/, and confirms via
    realpath() that the resolved file lives inside the export dir (so
    a symlink can't escape).

  - Every new export prunes files older than 24 hours, so the
    directory can't grow without bound.

No view or JS contract change — the response still includes a
download_url; it's just an admin URL now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dalibor Votruba пре 1 месец
родитељ
комит
286d2510db

+ 1 - 1
studiou-wc-product-cat-manage/CLAUDE.md

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 
 **QDR - Studiou WC Export/Import Product Categories** is a WordPress plugin that extends WooCommerce to provide batch export/import functionality for both product categories and products, along with category manipulation tools.
 
-**Current Version:** 1.6.3
+**Current Version:** 1.6.4
 
 ## Requirements
 

+ 1 - 1
studiou-wc-product-cat-manage/assets/js/admin.js

@@ -19,7 +19,7 @@
     // Initialize admin scripts
     $(document).ready(function() {
         // Debug: Log that script is loaded
-        console.log('STUDIOU WC: Admin script loaded v1.6.3');
+        console.log('STUDIOU WC: Admin script loaded v1.6.4');
 
         if (typeof studiouWcpcm === 'undefined') {
             console.error('STUDIOU WC: studiouWcpcm object is NOT defined - AJAX will not work');

+ 116 - 13
studiou-wc-product-cat-manage/includes/class-studiou-wc-product-manage-product-export.php

@@ -28,6 +28,101 @@ class Studiou_WC_Product_Manage_Product_Export {
      */
     public function __construct($db) {
         $this->db = $db;
+        // v1.6.4 — gated download endpoint, mirroring the category export.
+        add_action('admin_init', array($this, 'handle_export_download'));
+    }
+
+    /**
+     * Return (and lazily create) the guarded export directory.
+     *
+     * @return string Absolute path with trailing slash.
+     */
+    private function get_export_dir() {
+        $upload_dir = wp_upload_dir();
+        $dir = trailingslashit($upload_dir['basedir']) . 'studiou-wcpcm-product-exports/';
+        if (!file_exists($dir)) {
+            wp_mkdir_p($dir);
+        }
+        // Guard against direct access. Cheap idempotent writes.
+        $idx = $dir . 'index.php';
+        if (!file_exists($idx)) {
+            @file_put_contents($idx, '<?php // Silence is golden.');
+        }
+        $ht = $dir . '.htaccess';
+        if (!file_exists($ht)) {
+            @file_put_contents($ht, "Deny from all\n");
+        }
+        return $dir;
+    }
+
+    /**
+     * Delete export files older than 24 hours. Called on every new export.
+     */
+    private function prune_old_exports() {
+        $dir = $this->get_export_dir();
+        $cutoff = time() - DAY_IN_SECONDS;
+        $files = glob($dir . 'product-export-*.csv');
+        if (!is_array($files)) { return; }
+        foreach ($files as $f) {
+            $mtime = @filemtime($f);
+            if ($mtime !== false && $mtime < $cutoff) {
+                @unlink($f);
+            }
+        }
+    }
+
+    /**
+     * v1.6.4 — gated CSV download.
+     *
+     * Hooked on admin_init. Verifies nonce + manage_woocommerce capability +
+     * that the requested file is a bare basename inside the export dir
+     * (defends against path traversal), then streams via readfile().
+     */
+    public function handle_export_download() {
+        if (!isset($_GET['page']) || $_GET['page'] !== 'studiou-product-export') {
+            return;
+        }
+        if (!isset($_GET['action']) || $_GET['action'] !== 'studiou_wcpcm_download_product_export') {
+            return;
+        }
+        if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'studiou-wcpcm-product-export-download')) {
+            return;
+        }
+
+        if (!current_user_can('manage_woocommerce')) {
+            wp_die(__('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'));
+        }
+
+        $requested = isset($_GET['file']) ? (string) $_GET['file'] : '';
+        // Bare basename only — no slashes, no .., no directory components.
+        if ($requested === '' || $requested !== basename($requested) || strpos($requested, '..') !== false) {
+            wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
+        }
+        // Only our own filename shape: product-export-<32 hex>.csv
+        if (!preg_match('/^product-export-[a-f0-9]{32}\.csv$/', $requested)) {
+            wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
+        }
+
+        $dir = $this->get_export_dir();
+        $path = $dir . $requested;
+        if (!file_exists($path)) {
+            wp_die(__('Export file not found. Please try exporting again.', 'studiou-wc-product-cat-manage'));
+        }
+
+        // Belt-and-braces realpath check to prevent symlink escape.
+        $real_dir = realpath($dir);
+        $real_path = realpath($path);
+        if ($real_dir === false || $real_path === false || strpos($real_path, $real_dir) !== 0) {
+            wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
+        }
+
+        header('Content-Type: text/csv; charset=utf-8');
+        header('Content-Disposition: attachment; filename=product-export-' . date('Y-m-d-H-i-s') . '.csv');
+        header('Content-Length: ' . filesize($real_path));
+        header('Pragma: no-cache');
+        header('Expires: 0');
+        readfile($real_path);
+        exit;
     }
 
     /**
@@ -252,17 +347,15 @@ class Studiou_WC_Product_Manage_Product_Export {
             );
         }
 
-        // Create uploads directory if it doesn't exist
-        $upload_dir = wp_upload_dir();
-        $export_dir = $upload_dir['basedir'] . '/studiou-wc-product-exports';
+        // v1.6.4 — write into the guarded export dir and prune anything older
+        // than 24 hours. Filename gets a 32-char random token so the file is
+        // unguessable even if directory listing leaks.
+        $export_dir = $this->get_export_dir();
+        $this->prune_old_exports();
 
-        if (!file_exists($export_dir)) {
-            wp_mkdir_p($export_dir);
-        }
-
-        // Generate filename with timestamp
-        $filename = 'product-export-' . date('Y-m-d-H-i-s') . '.csv';
-        $file_path = $export_dir . '/' . $filename;
+        $token = bin2hex(random_bytes(16)); // 32 hex chars
+        $filename = 'product-export-' . $token . '.csv';
+        $file_path = $export_dir . $filename;
 
         // Write CSV content to file with UTF-8 BOM so Excel double-click
         // opens it with the correct encoding (Policy A, v1.6.2 — match the
@@ -276,14 +369,24 @@ class Studiou_WC_Product_Manage_Product_Export {
             );
         }
 
-        // Generate download URL
-        $download_url = $upload_dir['baseurl'] . '/studiou-wc-product-exports/' . $filename;
+        // Return an admin URL routed through handle_export_download(),
+        // which checks the nonce + capability + basename. The raw public
+        // uploads URL is no longer exposed.
+        $download_url = add_query_arg(
+            array(
+                'page'     => 'studiou-product-export',
+                'action'   => 'studiou_wcpcm_download_product_export',
+                'file'     => $filename,
+                '_wpnonce' => wp_create_nonce('studiou-wcpcm-product-export-download'),
+            ),
+            admin_url('edit.php?post_type=product')
+        );
 
         return array(
             'success' => true,
             'message' => sprintf(
                 __('Export successful! %d products exported.', 'studiou-wc-product-cat-manage'),
-                substr_count($csv_content, "\n") - 1 // Subtract header row
+                substr_count($csv_content, "\n") - 1 // Subtract header row (note: L2 fixes the multi-line case in v1.7.1)
             ),
             'download_url' => $download_url
         );

+ 8 - 1
studiou-wc-product-cat-manage/readme.md

@@ -1,6 +1,6 @@
 # QDR - Studiou WC Export/Import Product Categories
 
-**Version: 1.6.3**
+**Version: 1.6.4**
 
 A WordPress plugin that extends WooCommerce to provide batch export and import functionality for both product categories and products, along with category manipulation tools.
 
@@ -352,6 +352,13 @@ For support, please visit [https://www.quadarax.com/plugins/studiou-wc-product-c
 
 ## Changelog
 
+### Version 1.6.4
+- **Security: gated product-export download.** Previously the product exporter dropped `product-export-YYYY-MM-DD-H-i-s.csv` into `wp-content/uploads/studiou-wc-product-exports/` and returned the raw public URL — anyone who knew or guessed the timestamped name could download the file, and the directory was never cleaned. Now mirrors the category exporter:
+  - Export files live in `wp-content/uploads/studiou-wcpcm-product-exports/` with `index.php` and `.htaccess` guards.
+  - Filenames embed a 32-character random hex token (`product-export-<32hex>.csv`) so the path is unguessable even if directory listing leaks.
+  - Downloads now route through `admin-init` with a nonce check, a `manage_woocommerce` capability check, a strict regex on the requested basename, and a `realpath` confinement check that defends against path traversal and symlink escape.
+  - Every new export prunes files older than 24 hours so the directory doesn't grow without bound.
+
 ### Version 1.6.3
 - **Product import: correct round-trip for taxonomy and custom attributes.** The importer now reads the `Vlastnost 1 globální` column (previously written by the exporter but never consumed) and branches on it:
   - **Global / taxonomy attribute** (`globální = 1`): the importer registers the taxonomy, resolves each `Hodnota(y) 1 vlastnosti` value to its term (creating the term via `wp_insert_term` if it doesn't exist), passes the term IDs to `WC_Product_Attribute::set_options()`, and links the terms to the product via `wp_set_object_terms` after save. For variations the importer stores the term **slug** (not the raw name) under the `attribute_pa_*` meta key, so WooCommerce can match the customer's selection on the storefront. Previously the importer passed raw names to `set_options()` and stored raw names in variation meta, which meant taxonomy variations were unreachable ("No matching variation").

+ 2 - 2
studiou-wc-product-cat-manage/studiou-wc-product-cat-manage.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Export/Import Product Categories
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-product-cat-manage
  * Description: Allows batch import and export WooCommerce product categories and products
- * Version: 1.6.3
+ * Version: 1.6.4
  * Requires at least: 6.8.1
  * Requires PHP: 8.2
  * Author: Dalibor Votruba
@@ -23,7 +23,7 @@ if (!defined('WPINC')) {
 }
 
 // Define plugin constants
-if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.6.3');
+if (!defined('STUDIOU_WCPCM_VERSION')) define('STUDIOU_WCPCM_VERSION', '1.6.4');
 if (!defined('STUDIOU_WCPCM_PLUGIN_DIR')) define('STUDIOU_WCPCM_PLUGIN_DIR', plugin_dir_path(__FILE__));
 if (!defined('STUDIOU_WCPCM_PLUGIN_URL')) define('STUDIOU_WCPCM_PLUGIN_URL', plugin_dir_url(__FILE__));
 if (!defined('STUDIOU_WCPCM_PLUGIN_BASENAME')) define('STUDIOU_WCPCM_PLUGIN_BASENAME', plugin_basename(__FILE__));