Files
http2pic/docs/superpowers/specs/2026-04-20-security-hardening-design.md

4.8 KiB
Raw Blame History

http2pic Security Hardening & Bug Fixes

Date: 2026-04-20 Status: Approved

Summary

Harden http2pic against abuse and fix two correctness bugs. All changes target web/index.php, src/helpers.php, docker/start.sh, and docker-compose.yml. No new dependencies.


1. Config & Environment

Two new optional env vars. Both default to off so existing deployments are unaffected.

Var Default Effect
API_KEY "" If non-empty, all /api requests must supply it
BLOCK_PRIVATE_IPS false If true, rejects URLs that resolve to private/loopback/metadata IPs

docker/start.sh writes both into src/config.inc.php alongside the existing URL define:

define('API_KEY', '${API_KEY:-}');
define('BLOCK_PRIVATE_IPS', ${BLOCK_PRIVATE_IPS:-false});

docker-compose.yml gets commented-out examples:

environment:
  - URL=http://localhost:8080
  # - API_KEY=your-secret-key       # if set, all /api requests must provide it
  # - BLOCK_PRIVATE_IPS=true        # block LAN/loopback/metadata IPs (safe default for public hosting)

2. API Key Authentication

Checked at the top of case 'api':, before any Chrome work.

Logic: if API_KEY is defined and non-empty, require the key. Accept it from either:

  • HTTP header: X-API-Key: <key>
  • Query param: ?key=<key>

Header takes priority. Returns 401 Unauthorized if missing or wrong.

if (defined('API_KEY') && API_KEY !== '') {
    $provided = $_SERVER['HTTP_X_API_KEY'] ?? $_REQUEST['key'] ?? '';
    if ($provided !== API_KEY) {
        header('HTTP/1.0 401 Unauthorized');
        echo 'Invalid or missing API key';
        exit;
    }
}

Usage examples (to add to README):

# via header (preferred — not logged in access logs)
curl -H "X-API-Key: secret" "http://host/api?url=https://example.com"

# via query param (simpler for browser use)
curl "http://host/api?key=secret&url=https://example.com"

3. SSRF Protection (opt-in)

Active only when BLOCK_PRIVATE_IPS=true. Runs after URL format validation, before spawning Chrome.

Flow:

  1. Extract hostname from target URL via parse_url()
  2. Resolve to IP via gethostbyname()
  3. If resolution fails (returns same string) or IP is private → 403 Forbidden

Blocked ranges: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (AWS/cloud metadata).

Implementation uses ip2long() + bitmask checks — no regex.

A new isPrivateIP(string $ip): bool helper lives in src/helpers.php.

Returns 403 Forbidden with body URL not allowed (no detail about why, to avoid enumeration).


4. Bug Fixes

4a. RemoteWebDriver request timeout (60ms → 60s)

// before
RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60);
// after
RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60000);

4th param is milliseconds. 60 = 60ms, effectively zero. Fix to 60000.

4b. Viewport set before page load

Move window()->setSize() to before driver->get() so the page loads at the correct viewport from the start. This ensures responsive layouts and media queries fire at the intended dimensions.


5. Security Hardening (always applied)

5a. Viewport dimension cap

After format validation, reject dimensions exceeding 3840×2160:

$parts = explode('x', $viewport);
if ((int)$parts[0] > 3840 || (int)$parts[1] > 2160) {
    header('HTTP/1.0 400 Bad Request');
    echo 'Viewport exceeds maximum (3840x2160)';
    exit;
}

Prevents memory exhaustion via 99999x99999 requests.

5b. Generic error responses

Catch block logs full exception message but returns only:

Screenshot failed

Stops ChromeDriver internals (internal hostnames, paths, stack traces) leaking to clients.

5c. Log injection fix

addToLog() strips \n, \r, \t from data before writing:

$data = str_replace(["\n", "\r", "\t"], ' ', $data);

Prevents crafted URLs/IPs from injecting fake log entries.

5d. getUserIP() fix

Drop HTTP_CLIENT_IP (client-controlled, untrustworthy). Take only the first IP from X-Forwarded-For (Caddy appends subsequent hops; first entry is the real client):

function getUserIP() {
    if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
        return trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
    return $_SERVER['REMOTE_ADDR'];
}

Files Changed

File Changes
web/index.php API key check, SSRF check, viewport cap, viewport-before-get, timeout fix, generic errors
src/helpers.php isPrivateIP() helper, log injection fix, getUserIP() fix
docker/start.sh Write API_KEY and BLOCK_PRIVATE_IPS into config
docker-compose.yml Commented env var examples
src/config.inc.php (generated) gets two new defines