# SECURITY.md — Maple Local Fonts Security Requirements ## Overview This document covers all security requirements for the Maple Local Fonts plugin. Reference this when writing ANY PHP code. --- ## ABSPATH Check (Every PHP File) Every PHP file MUST start with this check. No exceptions. ```php query("SELECT * FROM table WHERE id = " . $_POST['id']); // ALWAYS do this $wpdb->get_results($wpdb->prepare( "SELECT * FROM %i WHERE id = %d", $table_name, absint($_POST['id']) )); ``` **Note:** This plugin should rarely need direct SQL. Use WordPress APIs (`get_posts`, `wp_insert_post`, etc.) which handle escaping internally. ### A2 - Authentication All admin actions require capability check: ```php if (!current_user_can('edit_theme_options')) { wp_die('Unauthorized', 'Error', ['response' => 403]); } ``` ### A3 - Sensitive Data - No API keys (Google Fonts CSS2 API is public) - No user credentials stored - No PII collected ### A5 - Broken Access Control **Order of checks for ALL AJAX handlers:** ```php public function handle_ajax_action() { // 1. Nonce verification FIRST if (!check_ajax_referer('mlf_action_name', 'nonce', false)) { wp_send_json_error(['message' => 'Security check failed'], 403); } // 2. Capability check SECOND if (!current_user_can('edit_theme_options')) { wp_send_json_error(['message' => 'Unauthorized'], 403); } // 3. Input validation THIRD // ... validate all inputs ... // 4. Process request // ... actual logic ... } ``` ### A7 - Cross-Site Scripting (XSS) **Escape ALL output:** ```php // HTML content echo esc_html($font_name); // HTML attributes echo ''; // URLs echo ''; // JavaScript data wp_localize_script('mlf-admin', 'mlfData', [ 'fontName' => esc_js($font_name), // Or let wp_localize_script handle it ]); // Translatable strings with variables printf( esc_html__('Installed: %s', 'maple-local-fonts'), esc_html($font_name) ); ``` **Never trust input for output:** ```php // WRONG - XSS vulnerability echo '
' . $_POST['font_name'] . '
'; // RIGHT - sanitize input, escape output $font_name = sanitize_text_field($_POST['font_name']); echo '
' . esc_html($font_name) . '
'; ``` ### A8 - Insecure Deserialization ```php // NEVER use unserialize() on external data $data = unserialize($_POST['data']); // DANGEROUS // Use JSON instead $data = json_decode(sanitize_text_field($_POST['data']), true); if (json_last_error() !== JSON_ERROR_NONE) { wp_send_json_error(['message' => 'Invalid data format']); } ``` ### A9 - Vulnerable Components - No external PHP libraries - Use only WordPress core functions - Keep dependencies to zero --- ## Nonce Implementation ### Creating Nonces **In admin page form:** ```php wp_nonce_field('mlf_download_font', 'mlf_nonce'); ``` **For AJAX (via wp_localize_script):** ```php wp_localize_script('mlf-admin', 'mlfData', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('mlf_download_font'), ]); ``` ### Verifying Nonces **AJAX handler:** ```php // Returns false on failure, doesn't die (we handle response ourselves) if (!check_ajax_referer('mlf_download_font', 'nonce', false)) { wp_send_json_error(['message' => 'Security check failed'], 403); } ``` **Form submission:** ```php if (!wp_verify_nonce($_POST['mlf_nonce'], 'mlf_download_font')) { wp_die('Security check failed'); } ``` ### Nonce Names Use consistent, descriptive nonce action names: | Action | Nonce Name | |--------|------------| | Download font | `mlf_download_font` | | Delete font | `mlf_delete_font` | | Update settings | `mlf_update_settings` | --- ## Input Validation ### Font Name Validation ```php $font_name = isset($_POST['font_name']) ? sanitize_text_field($_POST['font_name']) : ''; // Strict allowlist pattern - alphanumeric, spaces, hyphens only if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) { wp_send_json_error(['message' => 'Invalid font name: only letters, numbers, spaces, and hyphens allowed']); } // Length limit if (strlen($font_name) > 100) { wp_send_json_error(['message' => 'Font name too long']); } // Not empty if (empty($font_name)) { wp_send_json_error(['message' => 'Font name required']); } ``` ### Weight Validation ```php $weights = isset($_POST['weights']) ? (array) $_POST['weights'] : []; // Convert to integers $weights = array_map('absint', $weights); // Strict allowlist $allowed_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900]; $weights = array_intersect($weights, $allowed_weights); // Must have at least one if (empty($weights)) { wp_send_json_error(['message' => 'At least one weight required']); } ``` ### Style Validation ```php $styles = isset($_POST['styles']) ? (array) $_POST['styles'] : []; // Strict allowlist - only these two values ever $allowed_styles = ['normal', 'italic']; $styles = array_filter($styles, function($style) use ($allowed_styles) { return in_array($style, $allowed_styles, true); }); // Must have at least one if (empty($styles)) { wp_send_json_error(['message' => 'At least one style required']); } ``` ### Font Family ID Validation (for delete) ```php $font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0; if ($font_id < 1) { wp_send_json_error(['message' => 'Invalid font ID']); } // Verify it exists and is a font family $font = get_post($font_id); if (!$font || $font->post_type !== 'wp_font_family') { wp_send_json_error(['message' => 'Font not found']); } // Verify it's one we imported (not a theme font) if (get_post_meta($font_id, '_mlf_imported', true) !== '1') { wp_send_json_error(['message' => 'Cannot delete theme fonts']); } ``` --- ## File Operation Security ### Path Traversal Prevention ```php /** * Validate that a path is within the WordPress fonts directory. * Prevents path traversal attacks. * * @param string $path Full path to validate * @return bool True if path is safe, false otherwise */ function mlf_validate_font_path($path) { $font_dir = wp_get_font_dir(); $fonts_path = wp_normalize_path(trailingslashit($font_dir['path'])); // Resolve to real path (handles ../ etc) $real_path = realpath($path); // If realpath fails, file doesn't exist yet - validate the directory if ($real_path === false) { $dir = dirname($path); $real_dir = realpath($dir); if ($real_dir === false) { return false; } $real_path = wp_normalize_path($real_dir . '/' . basename($path)); } else { $real_path = wp_normalize_path($real_path); } // Must be within fonts directory return strpos($real_path, $fonts_path) === 0; } ``` ### Filename Sanitization ```php /** * Sanitize and validate a font filename. * * @param string $filename The filename to validate * @return string|false Sanitized filename or false if invalid */ function mlf_sanitize_font_filename($filename) { // WordPress sanitization first $filename = sanitize_file_name($filename); // Must have .woff2 extension if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') { return false; } // No path components if ($filename !== basename($filename)) { return false; } // Reasonable length if (strlen($filename) > 200) { return false; } return $filename; } ``` ### Safe File Writing ```php /** * Safely write a font file to the fonts directory. * * @param string $filename Sanitized filename * @param string $content File content * @return string|WP_Error File path on success, WP_Error on failure */ function mlf_write_font_file($filename, $content) { // Validate filename $safe_filename = mlf_sanitize_font_filename($filename); if ($safe_filename === false) { return new WP_Error('invalid_filename', 'Invalid filename'); } // Get fonts directory $font_dir = wp_get_font_dir(); $destination = trailingslashit($font_dir['path']) . $safe_filename; // Validate path if (!mlf_validate_font_path($destination)) { return new WP_Error('invalid_path', 'Invalid file path'); } // Ensure directory exists if (!wp_mkdir_p($font_dir['path'])) { return new WP_Error('mkdir_failed', 'Could not create fonts directory'); } // Write file global $wp_filesystem; if (empty($wp_filesystem)) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if (!$wp_filesystem->put_contents($destination, $content, FS_CHMOD_FILE)) { return new WP_Error('write_failed', 'Could not write font file'); } return $destination; } ``` ### Safe File Deletion ```php /** * Safely delete a font file. * * @param string $path Full path to the file * @return bool True on success, false on failure */ function mlf_delete_font_file($path) { // Validate path is within fonts directory if (!mlf_validate_font_path($path)) { return false; } // Must be a .woff2 file if (pathinfo($path, PATHINFO_EXTENSION) !== 'woff2') { return false; } // File must exist if (!file_exists($path)) { return true; // Already gone, that's fine } return wp_delete_file($path); } ``` --- ## HTTP Request Security ### Outbound Requests (Google Fonts) ```php $response = wp_remote_get($url, [ 'timeout' => 15, 'sslverify' => true, // Always verify SSL 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', ]); // Check for errors if (is_wp_error($response)) { // Log error, return gracefully error_log('MLF: Google Fonts request failed - ' . $response->get_error_message()); return new WP_Error('request_failed', 'Could not connect to Google Fonts'); } // Check HTTP status $status = wp_remote_retrieve_response_code($response); if ($status !== 200) { return new WP_Error('http_error', 'Google Fonts returned status ' . $status); } // Get body $body = wp_remote_retrieve_body($response); if (empty($body)) { return new WP_Error('empty_response', 'Empty response from Google Fonts'); } ``` ### URL Validation (Google Fonts only) ```php /** * Validate that a URL is a legitimate Google Fonts URL. * * @param string $url URL to validate * @return bool True if valid Google Fonts URL */ function mlf_is_valid_google_fonts_url($url) { $parsed = wp_parse_url($url); if (!$parsed || !isset($parsed['host'])) { return false; } // Only allow Google Fonts domains $allowed_hosts = [ 'fonts.googleapis.com', 'fonts.gstatic.com', ]; return in_array($parsed['host'], $allowed_hosts, true); } ``` --- ## AJAX Handler Complete Template ```php 'Security check failed'], 403); } // 2. CAPABILITY CHECK if (!current_user_can('edit_theme_options')) { wp_send_json_error(['message' => 'Unauthorized'], 403); } // 3. INPUT VALIDATION $font_name = isset($_POST['font_name']) ? sanitize_text_field($_POST['font_name']) : ''; if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name) || strlen($font_name) > 100) { wp_send_json_error(['message' => 'Invalid font name']); } $weights = isset($_POST['weights']) ? array_map('absint', (array) $_POST['weights']) : []; $weights = array_intersect($weights, [100, 200, 300, 400, 500, 600, 700, 800, 900]); if (empty($weights)) { wp_send_json_error(['message' => 'At least one weight required']); } $styles = isset($_POST['styles']) ? (array) $_POST['styles'] : []; $styles = array_intersect($styles, ['normal', 'italic']); if (empty($styles)) { wp_send_json_error(['message' => 'At least one style required']); } // 4. PROCESS REQUEST try { $downloader = new MLF_Font_Downloader(); $result = $downloader->download($font_name, $weights, $styles); if (is_wp_error($result)) { wp_send_json_error(['message' => $result->get_error_message()]); } wp_send_json_success([ 'message' => sprintf('Successfully installed %s', esc_html($font_name)), 'font_id' => $result, ]); } catch (Exception $e) { error_log('MLF Download Error: ' . $e->getMessage()); wp_send_json_error(['message' => 'An unexpected error occurred']); } } /** * Handle font deletion AJAX request. */ public function handle_delete() { // 1. NONCE CHECK if (!check_ajax_referer('mlf_delete_font', 'nonce', false)) { wp_send_json_error(['message' => 'Security check failed'], 403); } // 2. CAPABILITY CHECK if (!current_user_can('edit_theme_options')) { wp_send_json_error(['message' => 'Unauthorized'], 403); } // 3. INPUT VALIDATION $font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0; if ($font_id < 1) { wp_send_json_error(['message' => 'Invalid font ID']); } // Verify font exists and is ours $font = get_post($font_id); if (!$font || $font->post_type !== 'wp_font_family') { wp_send_json_error(['message' => 'Font not found']); } if (get_post_meta($font_id, '_mlf_imported', true) !== '1') { wp_send_json_error(['message' => 'Cannot delete theme fonts']); } // 4. PROCESS REQUEST try { $registry = new MLF_Font_Registry(); $result = $registry->delete_font($font_id); if (is_wp_error($result)) { wp_send_json_error(['message' => $result->get_error_message()]); } wp_send_json_success(['message' => 'Font deleted successfully']); } catch (Exception $e) { error_log('MLF Delete Error: ' . $e->getMessage()); wp_send_json_error(['message' => 'An unexpected error occurred']); } } } ``` --- ## Security Checklist Before committing any code: - [ ] ABSPATH check at top of every PHP file - [ ] index.php exists in every directory - [ ] All AJAX handlers verify nonce first - [ ] All AJAX handlers check capability second - [ ] All user input sanitized with appropriate function - [ ] All user input validated against allowlists where applicable - [ ] All output escaped with appropriate function - [ ] File paths validated to prevent traversal - [ ] No direct SQL queries (use WordPress APIs) - [ ] No `unserialize()` on user input - [ ] No `eval()` or similar dynamic execution - [ ] External URLs validated before use - [ ] Error messages don't expose sensitive info