# 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
<?php
if (!defined('ABSPATH')) {
    exit;
}
```

---

## Silence is Golden Files

Create `index.php` in EVERY directory:

```php
<?php
// Silence is golden.
if (!defined('ABSPATH')) {
    exit;
}
```

**Required locations:**
- `/maple-local-fonts/index.php`
- `/maple-local-fonts/includes/index.php`
- `/maple-local-fonts/assets/index.php`
- `/maple-local-fonts/languages/index.php`

---

## OWASP Compliance

### A1 - Injection Prevention

**SQL Injection:**
```php
// NEVER do this
$wpdb->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 '<input value="' . esc_attr($font_name) . '">';

// URLs
echo '<a href="' . esc_url($url) . '">';

// 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 '<div>' . $_POST['font_name'] . '</div>';

// RIGHT - sanitize input, escape output
$font_name = sanitize_text_field($_POST['font_name']);
echo '<div>' . esc_html($font_name) . '</div>';
```

### 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
<?php
if (!defined('ABSPATH')) {
    exit;
}

class MLF_Ajax_Handler {

    public function __construct() {
        add_action('wp_ajax_mlf_download_font', [$this, 'handle_download']);
        add_action('wp_ajax_mlf_delete_font', [$this, 'handle_delete']);
        // NEVER add wp_ajax_nopriv_ - admin only functionality
    }

    /**
     * Handle font download AJAX request.
     */
    public function handle_download() {
        // 1. NONCE CHECK
        if (!check_ajax_referer('mlf_download_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_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
