Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ Update the version string in **all** of these:
The SDK `User-Agent` derives from `WEBDECOY_VERSION` automatically — nothing to
bump there.

## Two distribution channels / two builds

There are two builds from one codebase:

- **CDN / self-hosted** — `./build.sh <version>` → `dist/webdecoy-<version>.zip`. Includes the self-hosted updater (`includes/class-webdecoy-updater.php`) and the bundled clearance client (`public/js/webdecoy-clearance.js`). This is what `release.sh` builds and uploads to the CDN.
- **WordPress.org** — `./build.sh <version> --org` → `dist/webdecoy-<version>-wporg.zip`. Strips the self-updater (the directory forbids self-updating plugins) and the minified clearance client, and drops CDN release tooling. This is what you submit to / commit to WordPress.org SVN. Chart.js is bundled locally in both builds.

## 2. Build + prepare CDN metadata

```bash
./release.sh <version> # e.g. ./release.sh 2.2.0
./release.sh <version> # e.g. ./release.sh 2.2.1 (CDN build)
```

This runs `build.sh`, produces `dist/webdecoy-<version>.zip`, and regenerates
Expand Down
20 changes: 20 additions & 0 deletions admin/js/vendor/chart.umd.min.js

Large diffs are not rendered by default.

58 changes: 45 additions & 13 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,36 @@
# Creates a distributable ZIP file for the WordPress plugin
# that can be uploaded to a CDN or WordPress marketplace.
#
# Usage: ./build.sh [version]
# Example: ./build.sh 1.0.0
# Usage: ./build.sh [version] [--org]
# Example: ./build.sh 2.2.0 # CDN / self-hosted build (includes self-updater + clearance client)
# ./build.sh 2.2.0 --org # WordPress.org build (strips both — .org guideline compliance)
#

set -e

# Configuration
PLUGIN_SLUG="webdecoy"
VERSION="${1:-1.0.0}"
BUILD_DIR="./build"
DIST_DIR="./dist"

echo "Building WebDecoy WordPress Plugin v${VERSION}"
# Parse args: a --org flag anywhere selects the WordPress.org variant; the
# remaining positional arg is the version.
ORG_BUILD=0
VERSION="1.0.0"
for arg in "$@"; do
case "$arg" in
--org) ORG_BUILD=1 ;;
*) VERSION="$arg" ;;
esac
done

if [ "$ORG_BUILD" = "1" ]; then
ARTIFACT="${PLUGIN_SLUG}-${VERSION}-wporg"
echo "Building WebDecoy WordPress Plugin v${VERSION} (WordPress.org variant)"
else
ARTIFACT="${PLUGIN_SLUG}-${VERSION}"
echo "Building WebDecoy WordPress Plugin v${VERSION}"
fi
echo "================================================"

# Clean previous builds
Expand Down Expand Up @@ -56,30 +73,45 @@ find "${BUILD_DIR}" -name "phpcs.xml*" -exec rm -f {} + 2>/dev/null || true
find "${BUILD_DIR}" -name ".phpcs*" -exec rm -f {} + 2>/dev/null || true
find "${BUILD_DIR}" -name "tests" -type d -exec rm -rf {} + 2>/dev/null || true

# Create ZIP file
# WordPress.org variant: strip the self-hosted update mechanism (forbidden on
# .org) and the bundled minified clearance client, plus CDN release tooling.
if [ "$ORG_BUILD" = "1" ]; then
echo "Applying WordPress.org adjustments (removing self-updater + clearance client + CDN tooling)..."
rm -f "${BUILD_DIR}/${PLUGIN_SLUG}/includes/class-webdecoy-updater.php"
rm -f "${BUILD_DIR}/${PLUGIN_SLUG}/public/js/webdecoy-clearance.js"
rm -rf "${BUILD_DIR}/${PLUGIN_SLUG}/cdn-files"
rm -f "${BUILD_DIR}/${PLUGIN_SLUG}/release.sh"
rm -rf "${BUILD_DIR}/${PLUGIN_SLUG}/bin"
fi

# Create ZIP file (folder inside is always the slug, as WordPress requires)
echo "Creating ZIP archive..."
cd "${BUILD_DIR}"
zip -r "../${DIST_DIR}/${PLUGIN_SLUG}-${VERSION}.zip" "${PLUGIN_SLUG}" -x "*.DS_Store" -x "*__MACOSX*"
zip -r "../${DIST_DIR}/${ARTIFACT}.zip" "${PLUGIN_SLUG}" -x "*.DS_Store" -x "*__MACOSX*"
cd - > /dev/null

# Generate checksums
echo "Generating checksums..."
cd "${DIST_DIR}"
shasum -a 256 "${PLUGIN_SLUG}-${VERSION}.zip" > "${PLUGIN_SLUG}-${VERSION}.zip.sha256"
md5 -q "${PLUGIN_SLUG}-${VERSION}.zip" > "${PLUGIN_SLUG}-${VERSION}.zip.md5"
shasum -a 256 "${ARTIFACT}.zip" > "${ARTIFACT}.zip.sha256"
md5 -q "${ARTIFACT}.zip" > "${ARTIFACT}.zip.md5"
cd - > /dev/null

# Calculate file size
FILE_SIZE=$(ls -lh "${DIST_DIR}/${PLUGIN_SLUG}-${VERSION}.zip" | awk '{print $5}')
FILE_SIZE=$(ls -lh "${DIST_DIR}/${ARTIFACT}.zip" | awk '{print $5}')

echo ""
echo "Build complete!"
echo "================================================"
echo "Output: ${DIST_DIR}/${PLUGIN_SLUG}-${VERSION}.zip"
echo "Output: ${DIST_DIR}/${ARTIFACT}.zip"
echo "Size: ${FILE_SIZE}"
echo ""
echo "Checksums:"
cat "${DIST_DIR}/${PLUGIN_SLUG}-${VERSION}.zip.sha256"
echo "MD5: $(cat "${DIST_DIR}/${PLUGIN_SLUG}-${VERSION}.zip.md5")"
cat "${DIST_DIR}/${ARTIFACT}.zip.sha256"
echo "MD5: $(cat "${DIST_DIR}/${ARTIFACT}.zip.md5")"
echo ""
echo "Upload to CDN or WordPress.org for distribution"
if [ "$ORG_BUILD" = "1" ]; then
echo "WordPress.org submission ZIP — self-updater + clearance client excluded."
else
echo "Upload to CDN for self-hosted distribution."
fi
4 changes: 2 additions & 2 deletions cdn-files/plugin-info.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"name": "WebDecoy Bot Detection",
"slug": "webdecoy",
"version": "2.1.0",
"version": "2.2.1",
"author": "<a href=\"https://webdecoy.com\">WebDecoy</a>",
"author_profile": "https://webdecoy.com",
"requires": "5.6",
"tested": "6.7",
"requires_php": "7.4",
"download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.1.0.zip",
"download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.2.1.zip",
"sections": {
"description": "<p>WebDecoy provides enterprise-grade bot detection and fraud protection for WordPress websites. Unlike simple CAPTCHA solutions, WebDecoy uses a layered defense approach that analyzes visitors from multiple angles — including deterministic tripwires that catch scanners with zero false positives.</p><h4>Key Features</h4><ul><li>Deterministic tripwires (hidden honeypot paths) — zero-false-positive bot blocking</li><li>Server-side and client-side bot detection</li><li>Invisible proof-of-work challenge (no external CAPTCHA service)</li><li>Comment, login, and registration spam protection</li><li>WooCommerce carding attack prevention</li><li>60+ good bots automatically allowed</li><li>AI crawler detection and blocking</li><li>Optional WebDecoy Cloud: centralized dashboard and rotation-proof device lockouts</li></ul>",
"installation": "<ol><li>Upload the plugin files to <code>/wp-content/plugins/webdecoy</code></li><li>Activate the plugin through the Plugins menu</li><li>Tripwires and local protection are active out of the box — no API key required</li><li>Optionally go to WebDecoy &gt; Settings &gt; WebDecoy Cloud to connect for centralized monitoring and enforcement</li></ol>",
Expand Down
5 changes: 5 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
*** WebDecoy Bot Detection Changelog ***

= 2.2.1 - 2026-07-19 =
* Changed: Chart.js is now bundled with the plugin instead of loaded from a CDN — no external requests for the admin charts
* Changed: Clarified the External Services disclosure in readme
* Internal: Refactored the self-hosted updater into its own module; added a WordPress.org build variant (build.sh --org) that excludes the self-updater and the cloud clearance client for directory-guideline compliance

= 2.2.0 - 2026-07-19 =
* Added: IP allowlist — IPs or CIDR ranges that bypass all detection and blocking (Settings → Blocking). Previously the check existed internally but had no way to configure it.
* Changed: Consolidated onto a single detector path (SDK detector + WordPress-signal wrapper) across front-end, forms, and WooCommerce; removed the unused legacy forms class and a dead scheduled task. No behavior change to detection.
Expand Down
203 changes: 203 additions & 0 deletions includes/class-webdecoy-updater.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php

declare(strict_types=1);

if (!defined('ABSPATH')) {
exit;
}

/**
* Self-hosted update mechanism (CDN distribution ONLY).
*
* This lets self-hosted installs receive updates from cdn.webdecoy.com when the
* WEBDECOY_SELF_HOSTED constant is defined. It is deliberately kept in its own
* file and instantiated only under that constant.
*
* IMPORTANT: WordPress.org-distributed copies MUST NOT include this file.
* The .org plugin guidelines prohibit a plugin serving its own updates — .org
* is the sole update source there. `build.sh --org` removes this file, and the
* main plugin guards the require with file_exists(), so its absence cleanly
* disables self-hosted updating.
*/
class WebDecoy_Updater
{
public function __construct()
{
add_filter('pre_set_site_transient_update_plugins', [$this, 'check_for_updates']);
add_filter('plugins_api', [$this, 'plugin_info'], 10, 3);
// Verify the downloaded package's checksum before WordPress installs it.
add_filter('upgrader_pre_download', [$this, 'verify_update_package'], 10, 3);
}

/**
* Check for updates against the CDN update manifest.
*
* @param object $transient Update transient
* @return object Modified transient
*/
public function check_for_updates($transient)
{
if (empty($transient->checked)) {
return $transient;
}

$update_info = get_transient('webdecoy_update_info');

if ($update_info === false) {
$response = wp_remote_get('https://cdn.webdecoy.com/wordpress/update-info.json', [
'timeout' => 10,
'headers' => [
'Accept' => 'application/json',
],
]);

if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
return $transient;
}

$update_info = json_decode(wp_remote_retrieve_body($response), true);

if (!$update_info || !isset($update_info['version'])) {
return $transient;
}

set_transient('webdecoy_update_info', $update_info, 12 * HOUR_IN_SECONDS);
}

if (version_compare(WEBDECOY_VERSION, $update_info['version'], '<')) {
// Host-pin the package URL: WordPress installs whatever is at
// `package` with full privileges, so refuse any URL that is not an
// HTTPS link on our own CDN (prevents a tampered manifest → RCE).
$download_url = $update_info['download_url'] ?? '';
if (!$this->is_trusted_package_url($download_url)) {
return $transient;
}

$transient->response[WEBDECOY_PLUGIN_BASENAME] = (object) [

Check failure on line 76 in includes/class-webdecoy-updater.php

View workflow job for this annotation

GitHub Actions / Static Analysis

Access to an undefined property object::$response.
'slug' => 'webdecoy',
'plugin' => WEBDECOY_PLUGIN_BASENAME,
'new_version' => $update_info['version'],
'package' => $download_url,
'url' => $update_info['details_url'] ?? 'https://webdecoy.com/wordpress',
'icons' => $update_info['icons'] ?? [],
'banners' => $update_info['banners'] ?? [],
'tested' => $update_info['tested'] ?? '',
'requires_php' => $update_info['requires_php'] ?? '7.4',
];
}

return $transient;
}

/**
* Whether a package download URL is an HTTPS link on our own CDN host.
*/
private function is_trusted_package_url(string $url): bool
{
if ($url === '') {
return false;
}
$parts = wp_parse_url($url);
if (empty($parts['scheme']) || empty($parts['host'])) {
return false;
}
return strtolower($parts['scheme']) === 'https'
&& strtolower($parts['host']) === 'cdn.webdecoy.com';
}

/**
* Verify the integrity of the self-hosted update package before installation.
*
* @param bool|WP_Error $reply Short-circuit value (false to continue).
* @param string $package The package URL being downloaded.
* @param object $upgrader The upgrader instance.
* @return bool|string|\WP_Error
*/
public function verify_update_package($reply, $package, $upgrader)
{
if (!is_string($package) || !$this->is_trusted_package_url($package)) {
return $reply;
}

$update_info = get_transient('webdecoy_update_info');
$expected = is_array($update_info) ? ($update_info['sha256'] ?? '') : '';
$expected = is_string($expected) ? strtolower(trim($expected)) : '';

if (!preg_match('/^[a-f0-9]{64}$/', $expected)) {
return new \WP_Error(
'webdecoy_no_checksum',
__('WebDecoy update aborted: no valid checksum was provided for the package.', 'webdecoy')
);
}

if (!function_exists('download_url')) {
require_once ABSPATH . 'wp-admin/includes/file.php';
}

$tmp_file = download_url($package);
if (is_wp_error($tmp_file)) {
return $tmp_file;
}

$actual = hash_file('sha256', $tmp_file);
if (!hash_equals($expected, (string) $actual)) {
wp_delete_file($tmp_file);
return new \WP_Error(
'webdecoy_checksum_mismatch',
__('WebDecoy update aborted: package checksum did not match the expected value.', 'webdecoy')
);
}

return $tmp_file;
}

/**
* Plugin information for the update details popup.
*
* @param false|object|array $result
* @param string $action
* @param object $args
* @return false|object
*/
public function plugin_info($result, $action, $args)
{
if ($action !== 'plugin_information' || !isset($args->slug) || $args->slug !== 'webdecoy') {
return $result;
}

$response = wp_remote_get('https://cdn.webdecoy.com/wordpress/plugin-info.json', [
'timeout' => 10,
'headers' => [
'Accept' => 'application/json',
],
]);

if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
return $result;
}

$info = json_decode(wp_remote_retrieve_body($response), true);

if (!$info) {
return $result;
}

return (object) [
'name' => $info['name'] ?? 'WebDecoy Bot Detection',
'slug' => 'webdecoy',
'version' => $info['version'] ?? WEBDECOY_VERSION,
'author' => $info['author'] ?? '<a href="https://webdecoy.com">WebDecoy</a>',
'author_profile' => $info['author_profile'] ?? 'https://webdecoy.com',
'requires' => $info['requires'] ?? '5.6',
'tested' => $info['tested'] ?? '',
'requires_php' => $info['requires_php'] ?? '7.4',
'sections' => $info['sections'] ?? [
'description' => 'Protect your WordPress site from bots, spam, and carding attacks.',
'changelog' => $info['changelog'] ?? '',
],
'download_link' => $info['download_url'] ?? '',
'banners' => $info['banners'] ?? [],
'icons' => $info['icons'] ?? [],
];
}
}
Loading
Loading