Security: SSRF Protection & Capabilities

TableCrafter fetches data from arbitrary URLs, so every remote request and AJAX endpoint is hardened with SSRF validation, WordPress capability checks, nonce verification, rate limiting, and strict output escaping. This page documents the real guards in the plugin so you can audit and extend them safely.

SSRF GuardCapability ChecksNonce VerificationXSS EscapingRate Limiting

Security model at a glance

TableCrafter's server-side proxy exists to bypass browser CORS limits, which means your WordPress server makes the outbound request on the visitor's behalf. That power is constrained by several layered checks that run on every request:

The dedicated TC_Security singleton in includes/class-tc-security.php centralizes SSRF checks, rate limiting, IP resolution, nonce handling, and token encryption. The main plugin class mirrors several of these helpers for the data-fetch path.

SSRF protection on remote fetches

Before TableCrafter issues any outbound HTTP request, the target URL is passed through is_safe_url(). This delegates to WordPress core's wp_http_validate_url(), which rejects private IP ranges, reserved ranges, and localhost normalization. If core's function is unavailable on very old WordPress versions, a fallback parses the host and rejects localhost, 127.0.0.1, [::1], and any IP that falls in a private or reserved range.

// includes/class-tc-security.php — primary SSRF gate
public function is_safe_url(string $url): bool {
    if (function_exists('wp_http_validate_url')) {
        return (bool) wp_http_validate_url($url);
    }
    // fallback blocks localhost + private/reserved IPs
}

The check is enforced inside fetch_data_from_source() right before the cURL call. A blocked URL never reaches the network; instead the fetcher returns a WP_Error and the attempt is logged:

// tablecrafter.php — enforced before any remote request
if (!$this->is_safe_url($url)) {
    $this->log_error('Security Block', ['url' => $url]);
    return new WP_Error('security_error',
        'The provided URL is blocked for safety (Local/Private IP).');
}
⚠️

The SSRF guard is a host/IP allowlist check, not a redirect tracer. The remote fetch uses CURLOPT_FOLLOWLOCATION, so a public URL that 302-redirects to a private host is a known limitation of URL-level validation. Only point tables at sources you trust.

Transport hardening

Remote JSON fetches use cURL with TLS verification fully enabled to defend against man-in-the-middle attacks (added in 3.4.0):

Local file resolution and directory traversal

When a source URL points back at your own site (matching site_url(), home_url(), or the plugin URL), TableCrafter reads the file directly instead of looping back over HTTP. That shortcut is constrained by a realpath allowlist so it can never be abused to read arbitrary files:

Capability checks per endpoint

Every privileged AJAX action calls current_user_can() and rejects unauthorized users with wp_send_json_error(). The required capability differs by endpoint based on its sensitivity. Note that several actions are also registered on wp_ajax_nopriv_* for logged-out flows, but the in-handler capability check is what actually authorizes the privileged work.

AJAX actionHandlerRequired capability
tc_proxy_fetchajax_proxy_fetchedit_posts OR manage_options
tc_elementor_previewajax_elementor_previewedit_posts OR manage_options
tc_export_dataajax_export_dataread
tc_save_airtable_tokenajax_save_airtable_tokenmanage_options
tc_download_exportajax_download_exportNonce + one-time transient (see below)
tc_subscribe_leadhandle_lead_subscriptionNonce + email validation only

The proxy handler is representative of the pattern: nonce first, capability second, rate limit third, then sanitized input:

// tablecrafter.php — ajax_proxy_fetch()
check_ajax_referer('tc_proxy_nonce', 'nonce');

if (!current_user_can('edit_posts') && !current_user_can('manage_options')) {
    wp_send_json_error(__('Unauthorized: You do not have permission to fetch remote data.',
        'tablecrafter-wp-data-tables'));
}

if ($this->is_rate_limited()) {
    status_header(429);
    wp_send_json_error(/* ... */, 429);
}

$url = isset($_POST['url']) ? esc_url_raw(wp_unslash($_POST['url'])) : '';
ℹ️

The proxy intentionally allows both edit_posts and manage_options so editors can preview tables in the block editor and Elementor while admins use the dashboard builder. The export endpoint only needs read because it operates on data the user is already viewing.

Nonce verification

TableCrafter generates a distinct nonce per concern and verifies it on the matching handler. Nonces are created with wp_create_nonce() and delivered to the browser via wp_localize_script() on the tablecrafterData object (and the admin/block equivalents).

Nonce actionCreated forVerified in
tc_proxy_nonceProxy fetch, Elementor preview, admin/block buildersajax_proxy_fetch, ajax_elementor_preview, ajax_save_airtable_token
tc_export_nonceExport requestsajax_export_data
tc_download_nonceGenerated download URLsajax_download_export
tc_lead_nonceWelcome-screen lead formhandle_lead_subscription

Handlers that read the nonce manually sanitize before verifying, hardening against malformed input (the nonce-hardening pass in 3.4.0):

// tablecrafter.php — ajax_export_data()
$nonce = isset($_POST['nonce'])
    ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
if (empty($nonce) || !wp_verify_nonce($nonce, 'tc_export_nonce')) {
    wp_send_json_error('Invalid nonce');
    return;
}

The download endpoint pairs its nonce with a one-time transient. ajax_export_data() stores the generated file under a unique tc_export_ transient (5-minute TTL) and builds a download URL carrying a fresh tc_download_nonce. When the file is served, ajax_download_export() verifies the nonce, confirms the file's realpath, streams it, then deletes the temp file and transient so the link cannot be replayed.

Rate limiting and client identification

The proxy is throttled to 30 requests per 60 seconds per client (constants RATE_LIMIT_MAX_REQUESTS and RATE_LIMIT_WINDOW_SECONDS). State lives in a transient keyed tc_rate_<md5(identifier)>. The identifier is the logged-in user ID when available, otherwise the resolved client IP. Exceeding the cap returns HTTP 429.

Client IP resolution is deliberately spoofing-resistant. By default only REMOTE_ADDR is trusted; proxy headers such as CF-Connecting-IP, X-Forwarded-For, and X-Real-IP are only consulted when you explicitly opt in via the tablecrafter_trusted_ip_headers filter, and even then the candidate IP is validated against private/reserved ranges.

# Trust Cloudflare's connecting-IP header behind a known CDN
add_filter('tablecrafter_trusted_ip_headers', function () {
    return ['cloudflare']; # also: 'forwarded', 'real_ip'
});
⚠️

Only enable tablecrafter_trusted_ip_headers if your site actually sits behind the matching proxy or CDN. On a directly exposed server, trusting forwarded headers lets attackers spoof their identifier and bypass rate limiting.

Output escaping & XSS protection

Because table content comes from untrusted external sources, every value is escaped on render. TableCrafter never echoes raw API data. The cell renderer classifies each value and emits a safe representation:

As a final boundary, table HTML assembled for output is filtered through wp_kses() against an explicit allowlist of tags, attributes, and protocols. Only http, https, and mailto protocols are permitted (the mailto support added in 3.3.0 so email links survive sanitization), and tags are limited to table structure plus a, img, span, time, div, and basic text formatting.

Server-side exports apply the same discipline: the XLSX generator escapes every header and cell with htmlspecialchars() inside the OOXML, and filenames are run through sanitize_file_name().

Secrets at rest

Optional integration tokens (for example the Airtable token saved via tc_save_airtable_token, which requires manage_options) are never stored in plaintext. TC_Security::encrypt_token() encrypts with AES-256-CBC using a random IV, deriving the key from your site's AUTH_KEY salt via SHA-256. decrypt_token() reverses it.

Because the encryption key is derived from AUTH_KEY, rotating your WordPress salts invalidates previously stored tokens. Re-save any integration tokens after a salt rotation.

Hardening checklist

  1. Keep WordPress current so wp_http_validate_url() provides the strongest SSRF coverage; the legacy fallback is less comprehensive.
  2. Point tables only at trusted sources, since URL-level validation cannot follow public-to-private redirects.
  3. Leave tablecrafter_trusted_ip_headers empty unless your stack genuinely terminates behind the matching proxy.
  4. Ensure your salts (AUTH_KEY) are unique and non-default before saving integration tokens.
  5. Run with WP_DEBUG enabled in staging to surface the plugin's security logs (blocked URLs, rate-limit hits).

Next, see data-sources.html for how the validated fetch path resolves JSON, CSV, and Google Sheets sources, and shortcode-reference.html for the full [tablecrafter] attribute list that drives these requests.