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.
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:
- SSRF validation blocks private, reserved, and loopback addresses before any remote fetch.
- Capability checks require an authenticated WordPress role for every privileged AJAX action.
- Nonce verification protects every endpoint against CSRF.
- Rate limiting caps abuse of the proxy at 30 requests per minute per client.
- Output escaping and wp_kses neutralize XSS in rendered table cells.
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):
- CURLOPT_SSL_VERIFYPEER is true and CURLOPT_SSL_VERIFYHOST is 2.
- WordPress's bundled CA bundle (
wp-includes/certificates/ca-bundle.crt) is used when present. - Connect timeout is 10s and total timeout is 30s, with a plugin-identifying user agent.
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:
- The resolved realpath() must begin with ABSPATH, WP_CONTENT_DIR, or the plugin directory.
- Only .json and .csv extensions are read (defense in depth).
- Anything outside the whitelist is skipped. This closed the arbitrary file-read issue fixed in 2.3.1.
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 action | Handler | Required capability |
|---|---|---|
| tc_proxy_fetch | ajax_proxy_fetch | edit_posts OR manage_options |
| tc_elementor_preview | ajax_elementor_preview | edit_posts OR manage_options |
| tc_export_data | ajax_export_data | read |
| tc_save_airtable_token | ajax_save_airtable_token | manage_options |
| tc_download_export | ajax_download_export | Nonce + one-time transient (see below) |
| tc_subscribe_lead | handle_lead_subscription | Nonce + 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 action | Created for | Verified in |
|---|---|---|
| tc_proxy_nonce | Proxy fetch, Elementor preview, admin/block builders | ajax_proxy_fetch, ajax_elementor_preview, ajax_save_airtable_token |
| tc_export_nonce | Export requests | ajax_export_data |
| tc_download_nonce | Generated download URLs | ajax_download_export |
| tc_lead_nonce | Welcome-screen lead form | handle_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:
- Images pass is_safe_image_url() (allowed extensions or
data:imagebase64;javascript:,vbscript:, and SVG data URLs are rejected) and are emitted with esc_url() and esc_attr(). - Emails are validated with FILTER_VALIDATE_EMAIL and rendered as
mailto:links escaped via esc_attr() / esc_html(). - Dates must match a strict ISO pattern in is_valid_date_string() before being wrapped in a
<time>element. - Links pass is_safe_display_url(), which permits only
http/httpsand blocksjavascript:,vbscript:,data:,file:, andftp:schemes. Links render withrel="noopener noreferrer". - Arrays and objects are flattened into escaped tag pills with item and length caps to prevent DOM bloat.
- Everything else falls through to esc_html().
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
- Keep WordPress current so wp_http_validate_url() provides the strongest SSRF coverage; the legacy fallback is less comprehensive.
- Point tables only at trusted sources, since URL-level validation cannot follow public-to-private redirects.
- Leave tablecrafter_trusted_ip_headers empty unless your stack genuinely terminates behind the matching proxy.
- Ensure your salts (AUTH_KEY) are unique and non-default before saving integration tokens.
- 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.