You've already forked wc-licensed-product
Release v0.2.0 - Security and integrity features
- Add REST API response signing using HMAC-SHA256 - Add SHA256 hash validation for version file uploads - Add ResponseSigner class for automatic API response signing - Add file_hash column to database schema - Remove external URL support from version uploads - Update translations with all fuzzy strings resolved Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -98,11 +98,11 @@ final class VersionAdminController
|
||||
<p class="description"><?php esc_html_e('Upload or select a file from the media library. Version will be auto-detected from filename (e.g., plugin-v1.2.3.zip).', 'wc-licensed-product'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="new_download_url"><?php esc_html_e('Or External URL', 'wc-licensed-product'); ?></label></th>
|
||||
<tr id="sha256-hash-row" style="display: none;">
|
||||
<th><label for="new_file_hash"><?php esc_html_e('SHA256 Hash', 'wc-licensed-product'); ?></label></th>
|
||||
<td>
|
||||
<input type="url" id="new_download_url" name="new_download_url" class="large-text" placeholder="https://" />
|
||||
<p class="description"><?php esc_html_e('Alternative: Enter an external download URL instead of uploading a file.', 'wc-licensed-product'); ?></p>
|
||||
<input type="text" id="new_file_hash" name="new_file_hash" class="large-text" placeholder="<?php esc_attr_e('Enter SHA256 checksum...', 'wc-licensed-product'); ?>" pattern="[a-fA-F0-9]{64}" />
|
||||
<p class="description"><?php esc_html_e('SHA256 checksum of the uploaded file (optional but recommended for integrity verification).', 'wc-licensed-product'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -242,9 +242,9 @@ final class VersionAdminController
|
||||
|
||||
$productId = absint($_POST['product_id'] ?? 0);
|
||||
$version = sanitize_text_field($_POST['version'] ?? '');
|
||||
$downloadUrl = esc_url_raw($_POST['download_url'] ?? '');
|
||||
$releaseNotes = sanitize_textarea_field($_POST['release_notes'] ?? '');
|
||||
$attachmentId = absint($_POST['attachment_id'] ?? 0);
|
||||
$fileHash = sanitize_text_field($_POST['file_hash'] ?? '');
|
||||
|
||||
if (!$productId || !$version) {
|
||||
wp_send_json_error(['message' => __('Product ID and version are required.', 'wc-licensed-product')]);
|
||||
@@ -270,13 +270,17 @@ final class VersionAdminController
|
||||
wp_send_json_error(['message' => __('This product is not a licensed product.', 'wc-licensed-product')]);
|
||||
}
|
||||
|
||||
$newVersion = $this->versionManager->createVersion(
|
||||
$productId,
|
||||
$version,
|
||||
$releaseNotes ?: null,
|
||||
$downloadUrl ?: null,
|
||||
$attachmentId ?: null
|
||||
);
|
||||
try {
|
||||
$newVersion = $this->versionManager->createVersion(
|
||||
$productId,
|
||||
$version,
|
||||
$releaseNotes ?: null,
|
||||
$attachmentId ?: null,
|
||||
$fileHash ?: null
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
wp_send_json_error(['message' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
if (!$newVersion) {
|
||||
global $wpdb;
|
||||
|
||||
128
src/Api/ResponseSigner.php
Normal file
128
src/Api/ResponseSigner.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
/**
|
||||
* Response Signer
|
||||
*
|
||||
* Signs REST API responses to prevent tampering and replay attacks.
|
||||
*
|
||||
* @package Jeremias\WcLicensedProduct\Api
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Jeremias\WcLicensedProduct\Api;
|
||||
|
||||
/**
|
||||
* Signs license API responses using HMAC-SHA256
|
||||
*
|
||||
* The security model:
|
||||
* 1. Server generates a unique signature for each response using HMAC-SHA256
|
||||
* 2. Signature includes a timestamp to prevent replay attacks
|
||||
* 3. Client verifies the signature using a shared secret
|
||||
* 4. Invalid signatures cause the client to reject the response
|
||||
*/
|
||||
final class ResponseSigner
|
||||
{
|
||||
private string $serverSecret;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->serverSecret = defined('WC_LICENSE_SERVER_SECRET')
|
||||
? WC_LICENSE_SERVER_SECRET
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register WordPress hooks
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
add_filter('rest_post_dispatch', [$this, 'signResponse'], 10, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign REST API response
|
||||
*
|
||||
* @param \WP_REST_Response $response The response object
|
||||
* @param \WP_REST_Server $server The REST server
|
||||
* @param \WP_REST_Request $request The request object
|
||||
* @return \WP_REST_Response
|
||||
*/
|
||||
public function signResponse($response, $server, $request)
|
||||
{
|
||||
// Only sign license API responses
|
||||
if (!$this->shouldSign($request)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$data = $response->get_data();
|
||||
$licenseKey = $request->get_param('license_key');
|
||||
|
||||
if (empty($licenseKey) || !is_array($data) || empty($this->serverSecret)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$headers = $this->createSignatureHeaders($data, $licenseKey);
|
||||
|
||||
foreach ($headers as $name => $value) {
|
||||
$response->header($name, $value);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request should be signed
|
||||
*/
|
||||
private function shouldSign(\WP_REST_Request $request): bool
|
||||
{
|
||||
$route = $request->get_route();
|
||||
|
||||
return str_starts_with($route, '/wc-licensed-product/v1/validate')
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/status')
|
||||
|| str_starts_with($route, '/wc-licensed-product/v1/activate');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create signature headers for response
|
||||
*
|
||||
* @param array $data The response data
|
||||
* @param string $licenseKey The license key from the request
|
||||
* @return array Associative array of headers
|
||||
*/
|
||||
private function createSignatureHeaders(array $data, string $licenseKey): array
|
||||
{
|
||||
$timestamp = time();
|
||||
$signingKey = $this->deriveKey($licenseKey);
|
||||
|
||||
// Sort keys for consistent ordering
|
||||
ksort($data);
|
||||
|
||||
// Build signature payload
|
||||
$payload = $timestamp . ':' . json_encode(
|
||||
$data,
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
|
||||
return [
|
||||
'X-License-Signature' => hash_hmac('sha256', $payload, $signingKey),
|
||||
'X-License-Timestamp' => (string) $timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a unique signing key for a license
|
||||
*
|
||||
* Uses HKDF-like key derivation to create a unique signing key
|
||||
* for each license key, preventing cross-license signature attacks.
|
||||
*
|
||||
* @param string $licenseKey The license key
|
||||
* @return string The derived signing key (hex encoded)
|
||||
*/
|
||||
private function deriveKey(string $licenseKey): string
|
||||
{
|
||||
// HKDF-like key derivation
|
||||
$prk = hash_hmac('sha256', $licenseKey, $this->serverSecret, true);
|
||||
|
||||
return hash_hmac('sha256', $prk . "\x01", $this->serverSecret);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ final class Installer
|
||||
release_notes TEXT DEFAULT NULL,
|
||||
download_url VARCHAR(512) DEFAULT NULL,
|
||||
attachment_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
file_hash VARCHAR(64) DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
released_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
@@ -13,6 +13,7 @@ use Jeremias\WcLicensedProduct\Admin\AdminController;
|
||||
use Jeremias\WcLicensedProduct\Admin\OrderLicenseController;
|
||||
use Jeremias\WcLicensedProduct\Admin\SettingsController;
|
||||
use Jeremias\WcLicensedProduct\Admin\VersionAdminController;
|
||||
use Jeremias\WcLicensedProduct\Api\ResponseSigner;
|
||||
use Jeremias\WcLicensedProduct\Api\RestApiController;
|
||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutBlocksIntegration;
|
||||
use Jeremias\WcLicensedProduct\Checkout\CheckoutController;
|
||||
@@ -128,6 +129,11 @@ final class Plugin
|
||||
new RestApiController($this->licenseManager);
|
||||
new LicenseEmailController($this->licenseManager);
|
||||
|
||||
// Initialize response signing if server secret is configured
|
||||
if (defined('WC_LICENSE_SERVER_SECRET') && WC_LICENSE_SERVER_SECRET !== '') {
|
||||
(new ResponseSigner())->register();
|
||||
}
|
||||
|
||||
if (is_admin()) {
|
||||
new AdminController($this->twig, $this->licenseManager);
|
||||
new VersionAdminController($this->versionManager);
|
||||
|
||||
@@ -23,6 +23,7 @@ class ProductVersion
|
||||
private ?string $releaseNotes;
|
||||
private ?string $downloadUrl;
|
||||
private ?int $attachmentId;
|
||||
private ?string $fileHash;
|
||||
private bool $isActive;
|
||||
private \DateTimeInterface $releasedAt;
|
||||
private \DateTimeInterface $createdAt;
|
||||
@@ -42,6 +43,7 @@ class ProductVersion
|
||||
$version->releaseNotes = $data['release_notes'] ?: null;
|
||||
$version->downloadUrl = $data['download_url'] ?: null;
|
||||
$version->attachmentId = !empty($data['attachment_id']) ? (int) $data['attachment_id'] : null;
|
||||
$version->fileHash = $data['file_hash'] ?? null;
|
||||
$version->isActive = (bool) $data['is_active'];
|
||||
$version->releasedAt = new \DateTimeImmutable($data['released_at']);
|
||||
$version->createdAt = new \DateTimeImmutable($data['created_at']);
|
||||
@@ -137,15 +139,20 @@ class ProductVersion
|
||||
return $this->attachmentId;
|
||||
}
|
||||
|
||||
public function getFileHash(): ?string
|
||||
{
|
||||
return $this->fileHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective download URL (from attachment or direct URL)
|
||||
* Get the download URL from attachment
|
||||
*/
|
||||
public function getEffectiveDownloadUrl(): ?string
|
||||
{
|
||||
if ($this->attachmentId) {
|
||||
return wp_get_attachment_url($this->attachmentId) ?: null;
|
||||
}
|
||||
return $this->downloadUrl;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,9 +163,6 @@ class ProductVersion
|
||||
if ($this->attachmentId) {
|
||||
return wp_basename(get_attached_file($this->attachmentId) ?: '');
|
||||
}
|
||||
if ($this->downloadUrl) {
|
||||
return wp_basename($this->downloadUrl);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -192,6 +196,7 @@ class ProductVersion
|
||||
'release_notes' => $this->releaseNotes,
|
||||
'download_url' => $this->downloadUrl,
|
||||
'attachment_id' => $this->attachmentId,
|
||||
'file_hash' => $this->fileHash,
|
||||
'is_active' => $this->isActive,
|
||||
'released_at' => $this->releasedAt->format('Y-m-d H:i:s'),
|
||||
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
|
||||
|
||||
@@ -92,16 +92,27 @@ class VersionManager
|
||||
|
||||
/**
|
||||
* Create a new version
|
||||
*
|
||||
* @throws \InvalidArgumentException If file hash validation fails
|
||||
*/
|
||||
public function createVersion(
|
||||
int $productId,
|
||||
string $version,
|
||||
?string $releaseNotes = null,
|
||||
?string $downloadUrl = null,
|
||||
?int $attachmentId = null
|
||||
?int $attachmentId = null,
|
||||
?string $fileHash = null
|
||||
): ?ProductVersion {
|
||||
global $wpdb;
|
||||
|
||||
// Validate file hash if both attachment and hash are provided
|
||||
if ($attachmentId !== null && $attachmentId > 0 && $fileHash !== null && $fileHash !== '') {
|
||||
$validatedHash = $this->validateFileHash($attachmentId, $fileHash);
|
||||
if ($validatedHash === false) {
|
||||
return null;
|
||||
}
|
||||
$fileHash = $validatedHash;
|
||||
}
|
||||
|
||||
$parsed = ProductVersion::parseVersion($version);
|
||||
|
||||
$tableName = Installer::getVersionsTable();
|
||||
@@ -114,10 +125,9 @@ class VersionManager
|
||||
'minor_version' => $parsed['minor'],
|
||||
'patch_version' => $parsed['patch'],
|
||||
'release_notes' => $releaseNotes,
|
||||
'download_url' => $downloadUrl,
|
||||
'is_active' => 1,
|
||||
];
|
||||
$formats = ['%d', '%s', '%d', '%d', '%d', '%s', '%s', '%d'];
|
||||
$formats = ['%d', '%s', '%d', '%d', '%d', '%s', '%d'];
|
||||
|
||||
// Only include attachment_id if it's set
|
||||
if ($attachmentId !== null && $attachmentId > 0) {
|
||||
@@ -125,6 +135,12 @@ class VersionManager
|
||||
$formats[] = '%d';
|
||||
}
|
||||
|
||||
// Only include file_hash if it's set
|
||||
if ($fileHash !== null && $fileHash !== '') {
|
||||
$data['file_hash'] = $fileHash;
|
||||
$formats[] = '%s';
|
||||
}
|
||||
|
||||
$result = $wpdb->insert($tableName, $data, $formats);
|
||||
|
||||
if ($result === false) {
|
||||
@@ -136,13 +152,44 @@ class VersionManager
|
||||
return $this->getVersionById((int) $wpdb->insert_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file hash against attachment
|
||||
*
|
||||
* @return string|false The validated hash (lowercase) or false on mismatch
|
||||
* @throws \InvalidArgumentException If hash doesn't match
|
||||
*/
|
||||
private function validateFileHash(int $attachmentId, string $providedHash): string|false
|
||||
{
|
||||
$filePath = get_attached_file($attachmentId);
|
||||
if (!$filePath || !file_exists($filePath)) {
|
||||
throw new \InvalidArgumentException(
|
||||
__('Attachment file not found.', 'wc-licensed-product')
|
||||
);
|
||||
}
|
||||
|
||||
$calculatedHash = hash_file('sha256', $filePath);
|
||||
$providedHash = strtolower(trim($providedHash));
|
||||
|
||||
if (!hash_equals($calculatedHash, $providedHash)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf(
|
||||
/* translators: 1: provided hash, 2: calculated hash */
|
||||
__('File checksum does not match. Expected: %1$s, Got: %2$s', 'wc-licensed-product'),
|
||||
$providedHash,
|
||||
$calculatedHash
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $calculatedHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a version
|
||||
*/
|
||||
public function updateVersion(
|
||||
int $versionId,
|
||||
?string $releaseNotes = null,
|
||||
?string $downloadUrl = null,
|
||||
?bool $isActive = null,
|
||||
?int $attachmentId = null
|
||||
): bool {
|
||||
@@ -156,11 +203,6 @@ class VersionManager
|
||||
$formats[] = '%s';
|
||||
}
|
||||
|
||||
if ($downloadUrl !== null) {
|
||||
$data['download_url'] = $downloadUrl;
|
||||
$formats[] = '%s';
|
||||
}
|
||||
|
||||
if ($isActive !== null) {
|
||||
$data['is_active'] = $isActive ? 1 : 0;
|
||||
$formats[] = '%d';
|
||||
|
||||
Reference in New Issue
Block a user