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

151 lines
6.4 KiB
Markdown

---
title: "PHP — display_errors Leaking Warnings into JSON API Responses"
aliases: [php-display-errors, php-json-api-errors, php-unexpected-token, php-ini-set-override]
tags: [php, debugging, api, json, backend, production]
sources:
- "daily/2026-04-27.md"
created: 2026-04-27
updated: 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
```php
// 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)**
```ini
; php.ini
display_errors = Off
log_errors = On
error_log = /var/log/php/errors.log
```
```apache
; .htaccess
php_flag display_errors Off
```
This applies regardless of what application code does.
**Option 2: Set in config.php before any other code**
```php
// 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)**
```php
// 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:
```php
// ❌ 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()`:
```php
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`:
```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');
```
## Related Concepts
- [[wiki/concepts/shell-static-deploy-patterns]] — deploy script patterns; similar "safe defaults in committed config" principle applies
- [[wiki/concepts/monorepo-deploy-script-pitfall]] — another class of "silent failure from config oversight"
- [[wiki/tech-patterns/nodejs-vanilla-proxy]] — Node.js API alternative where this class of PHP error doesn't apply
## 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