obsidian/wiki/concepts/php-display-errors-json-leak.md
2026-04-28 22:21:29 +01:00

6.4 KiB

title aliases tags sources created updated
PHP — display_errors Leaking Warnings into JSON API Responses
php-display-errors
php-json-api-errors
php-unexpected-token
php-ini-set-override
php
debugging
api
json
backend
production
daily/2026-04-27.md
2026-04-27 2026-04-27

PHP — display_errors Leaking Warnings into JSON API Responses

When PHP's display_errors is enabled, any warning, notice, or error emitted before the JSON response body is prepended to the output buffer. The API client receives something like <br/><b>Warning</b>: ...{"result": "..."} — valid JSON is now invalid because it's prefixed with HTML. JavaScript's JSON.parse() throws "Unexpected token '<'", which is the reliable diagnostic signal.

Key Points

  • "Unexpected token '<'" in JSON.parse = PHP warning or error is being emitted before the JSON body — not a data problem, a display_errors problem
  • ini_set('display_errors', 0) in api.php does NOT override display_errors = 1 in an included config file loaded after it — PHP processes ini_set sequentially; a config file with display_errors = 1 loaded by require_once('config.php') after the ini_set undoes the suppression
  • config.example.php committed with display_errors = 1 is a common footgun — developers copy it to config.php on the server without changing the value
  • The fix: set display_errors = 0 directly in php.ini or .htaccess, or ensure the config file is loaded BEFORE the ini_set call, or use an output buffer (ob_start) to catch stray output before sending JSON
  • Probabilistic/intermittent bugs (10% of requests) that delete required directories are especially dangerous — they interact with display_errors to produce sporadic failures

Details

How the Leak Happens

// api.php — attempts to suppress errors
ini_set('display_errors', 0);

// Later in the request lifecycle:
require_once('config.php');   // sets display_errors = 1 — overrides the ini_set above

// Later still, something triggers a warning:
file_put_contents('/path/that/was/deleted', $data);
// PHP emits: "<br/><b>Warning</b>: file_put_contents(...)..."

// api.php then sends JSON:
header('Content-Type: application/json');
echo json_encode($result);
// Output: "<br/><b>Warning</b>: file_put_contents(...)... {\"key\":\"value\"}"

The client receives this mixed output, JSON.parse() sees < as the first character, and throws:

SyntaxError: Unexpected token '<', "<br><b>War"... is not valid JSON

Diagnosing the Root Cause

When a PHP API returns "Unexpected token '<'":

  1. Check display_errors in php.ini: php -r "echo ini_get('display_errors');" — should be 0 in production
  2. Check included config files: grep -r "display_errors" /path/to/project/ — find every location it's set
  3. Check load order: if config.php is loaded after ini_set('display_errors', 0), the config wins
  4. Test with curl to see raw output: curl -i https://yourapi.com/endpoint — the HTML warning appears before the JSON

The Fix: Three Options

Option 1: Fix in php.ini or .htaccess (recommended)

; php.ini
display_errors = Off
log_errors = On
error_log = /var/log/php/errors.log
; .htaccess
php_flag display_errors Off

This applies regardless of what application code does.

Option 2: Set in config.php before any other code

// config.php — must be the FIRST setting, before any require_once
ini_set('display_errors', 0);
error_reporting(E_ALL);
ini_set('log_errors', 1);
ini_set('error_log', '/var/log/php/errors.log');

Option 3: Output buffer (belt-and-suspenders)

// api.php
ob_start();                            // catch any stray output
require_once('config.php');
// ... application code ...
$output = ob_get_clean();              // clear the buffer
if (strpos($output, '<') !== false) {
    error_log("Stray HTML in API output: " . substr($output, 0, 200));
}
echo json_encode($result);            // only the JSON goes to the client

The Probabilistic Cleanup Bug (2026-04-27 Incident)

The actual bug that triggered the display_errors issue:

// ❌ DANGEROUS — auto-cleanup deletes the images/ directory itself, not just old files
function autoCleanupExpiredImages() {
    if (rand(1, 10) === 1) {              // 10% chance per request
        $files = glob('images/*');
        if (count($files) > 50) {
            array_map('unlink', $files);
            rmdir('images/');            // deletes the directory!
        }
    }
}

The cleanup ran in the same request as saveImage(), deleted the images/ directory, then saveImage() tried to write there and emitted a PHP warning. With display_errors = 1, that warning prepended the JSON response.

The defensive fix in saveImage():

function saveImage($filename, $data) {
    if (!is_dir('images/')) {
        mkdir('images/', 0755, true);   // recreate if missing
    }
    file_put_contents('images/' . $filename, $data);
}

The deeper fix: cleanup functions should delete file contents, not the parent directory.

config.example.php as a Footgun

Any config.example.php committed to version control with display_errors = 1 becomes the template for production deployments. Developers copy it to config.php and never change the value. This is how display_errors = 1 ends up in production.

Correct config.example.php:

// config.example.php — safe defaults for production
define('DISPLAY_ERRORS', false);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/logs/errors.log');

// API Keys — replace with actual values
define('GEMINI_API_KEY', 'YOUR_KEY_HERE');

Sources

  • daily/2026-04-27.md — Lux Studio (AI Cinematography): JSON.parse "Unexpected token '<'" error; root cause was two bugs interacting: autoCleanupExpiredImages() deleting images/ directory (10% chance) + config.php having display_errors = 1 from config.example.php template; fixes: saveImage() recreates directory + display_errors = 0 in config