31 Commits

Author SHA1 Message Date
ada976a224 feat: enable BLOCK_PRIVATE_IPS in docker-compose for enhanced security
All checks were successful
Build Container / docker (push) Successful in 5m19s
2026-04-21 12:53:08 +02:00
6973522c45 fix: block non-http(s) schemes, sanitize API_KEY backslash, improve viewport error message
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 12:48:18 +02:00
6dc0001f9d fix: block 0.0.0.0/8 in isPrivateIP to prevent loopback bypass 2026-04-21 12:35:31 +02:00
4ab30bcc1d feat: opt-in SSRF protection via BLOCK_PRIVATE_IPS env var 2026-04-21 12:26:54 +02:00
9295115742 fix: use hash_equals for API key comparison and update config documentation
- Replace direct API key comparison with hash_equals() to prevent timing oracle attacks
- Update CLAUDE.md to document all config options (URL, API_KEY, BLOCK_PRIVATE_IPS)
- Add placeholder defines to src/config.inc.php for local dev (not committed due to .gitignore)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 12:17:34 +02:00
efc9e6510c feat: optional API key auth via X-API-Key header or ?key= param 2026-04-21 12:11:23 +02:00
e7924f462e fix: reject zero-dimension viewport values 2026-04-21 12:04:05 +02:00
8590465c6a fix: viewport before page load, 60ms->60s timeout, viewport cap, generic errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:49:49 +02:00
cc30d2288e fix: sanitize API_KEY and BLOCK_PRIVATE_IPS in config generation
- Fix Issue 1: Normalize BLOCK_PRIVATE_IPS to safe boolean (true/false) using shell case statement to prevent PHP injection from non-boolean values like 'yes'
- Fix Issue 2: Strip single quotes from API_KEY to prevent PHP string injection if the value contains quotes
- Update docker-compose-dev.yml to document these configuration options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 21:50:59 +02:00
3ab7c1334f feat: add API_KEY and BLOCK_PRIVATE_IPS config vars 2026-04-20 21:47:09 +02:00
75ead2f5ad feat: add isPrivateIP helper, fix getUserIP and addToLog 2026-04-20 21:44:32 +02:00
15720489ba test: add failing tests for helpers functions
Add comprehensive tests for isPrivateIP() and getUserIP() functions.
These tests currently fail as the functions are not yet implemented.
Tests cover:
- isPrivateIP: loopback, private ranges (10/172/192), AWS metadata, public IPs
- getUserIP: REMOTE_ADDR fallback, X-Forwarded-For parsing and trimming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 21:43:47 +02:00
73118498c9 docs: add security hardening implementation plan 2026-04-20 09:44:48 +02:00
7f9a752b57 docs: add security hardening design spec 2026-04-20 09:40:21 +02:00
7323eed789 fix: enhance error handling and improve URL decoding in http2pic class and index.php 2026-04-20 07:59:06 +02:00
7616dee994 fix: improve error handling and streamline screenshot response in index.php 2026-04-19 21:58:00 +02:00
4a548f50e7 fix: update URL format in docker-compose and enhance error handling in http2pic class 2026-04-19 21:27:04 +02:00
faea2b0899 fix: correct URL format in docker-compose files and improve viewport handling in index.php
All checks were successful
Build Container / docker (push) Successful in 28s
2026-02-15 19:58:43 +01:00
427fa24565 clarify 2026-02-15 19:29:55 +01:00
086e7c7a77 full path 2025-06-10 12:00:35 +02:00
181bed4449 config corrections 2025-06-10 11:57:16 +02:00
6e0795bbdf url 2025-06-10 11:55:13 +02:00
5df5a0ad7a added logging
All checks were successful
Build Container / docker (push) Successful in 2m7s
2025-06-10 11:50:24 +02:00
63b49dd282 url showing 2025-06-10 11:25:27 +02:00
5e8f4e33e3 ups 2025-06-10 11:23:45 +02:00
a140a35448 symlink 2025-06-10 11:21:11 +02:00
a0765efc3c ok 2025-06-10 11:16:25 +02:00
543e44abc8 fine, I'll do it myself
All checks were successful
Build Container / docker (push) Successful in 1m6s
2025-06-10 11:09:14 +02:00
1443cfee12 ok only docker hub
Some checks failed
ci / docker (push) Failing after 9m27s
2025-06-10 11:06:45 +02:00
0d17b5d474 write permissions for packages
Some checks failed
ci / docker (push) Failing after 11m14s
2025-06-10 11:02:01 +02:00
3eed66b9a9 testing
Some checks failed
ci / docker (push) Failing after 11m17s
2025-06-10 10:54:13 +02:00
54 changed files with 1111 additions and 119 deletions

0
.devcontainer/Caddyfile Normal file → Executable file
View File

0
.devcontainer/Dockerfile Normal file → Executable file
View File

0
.devcontainer/devcontainer.json Normal file → Executable file
View File

0
.devcontainer/start.sh Normal file → Executable file
View File

View File

@@ -0,0 +1,49 @@
name: Build Container
run-name: ${{ gitea.actor }} is pushing
on:
push:
tags:
- "v*.*.*"
env:
REGISTRY: gitea.haschek.at
IMAGE_NAME: ${{ gitea.repository }}
jobs:
docker:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 Building ${{ gitea.repository }} because of a ${{ gitea.event_name }} event."
- name: Checkout
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.BUILD_TOKEN}}
- name: Build
uses: docker/build-push-action@v3
env:
ACTIONS_RUNTIME_TOKEN: ''
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

View File

@@ -1,52 +0,0 @@
name: ci
on:
push:
tags:
- "v*.*.*"
pull_request:
branches:
- "master"
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
ghcr.io/hascheksolutions/http2pic
# generate Docker tags based on the following events/attributes
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_PAT }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

0
.vscode/launch.json vendored Normal file → Executable file
View File

76
CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
http2pic - PHP website renderer that takes screenshots of URLs and returns them as images. Live at https://http2pic.haschek.at/
## Architecture
**Entry point:** `web/index.php` - PSR-4 router with two paths:
- `/api` - Chrome/Chromium screenshot via Selenium WebDriver (php-webdriver). Takes `url`, `viewport` (WIDTHxHEIGHT), `js` (true/false) params. Connects to `localhost:4444` ChromeDriver, sets window size, disables scrollbars, takes screenshot as PNG.
- default - renders `src/templates/index.html.php` (landing page).
**Legacy class:** `src/http2pic.class.php` - Old `wkhtmltoimage`-based renderer (deprecated, not used in production). Supports PNG/JPG, viewport, resize, URL reachability check, file caching.
**Helpers:** `src/helpers.php` - `renderTemplate()`, `addToLog()`, `getUserIP()`.
**Config:** `src/config.inc.php` - set at build time by `start.sh` from `URL` env var.
## Running
**Docker (production):**
```
docker compose up -d
```
Services: Caddy (:80), PHP-FPM, ChromeDriver (:4444). Volumes: `./cache` and `./logs`. Configured via `URL` env var.
**Dev container:** `.devcontainer/` - same stack, run `./devcontainer/start.sh`. Forward port 8080.
**Quick test:**
```
php -S localhost:8080 -t web/
# Then visit http://localhost:8080/api?url=<target>
```
## Key files
| File | Purpose |
|------|---------|
| `web/index.php` | API + template router |
| `src/http2pic.class.php` | Legacy wkhtmltoimage renderer |
| `src/helpers.php` | Template render, logging, IP helper |
| `src/config.inc.php` | Runtime config (URL, API_KEY, BLOCK_PRIVATE_IPS) |
| `docker/Caddyfile` | Reverse proxy, PHP-FPM, file server |
| `docker/start.sh` | Boots PHP-FPM, ChromeDriver, writes config |
| `docker-compose.yml` | Production compose |
## API Key
Set `API_KEY` env var in docker-compose to require authentication on all `/api` requests.
Leave unset (default) for open access.
```bash
# via header (preferred — not logged in access logs)
curl -H "X-API-Key: your-secret-key" "http://host/api?url=https://example.com"
# via query param
curl "http://host/api?key=your-secret-key&url=https://example.com"
```
## SSRF Protection
Set `BLOCK_PRIVATE_IPS=true` to reject requests to LAN, loopback, and cloud metadata IPs.
Recommended when hosting publicly. Default is off (allows local/LAN addresses).
Note: DNS rebinding attacks can bypass this protection (attacker-controlled DNS can return a public IP
during validation and a private IP when Chrome actually connects). Full protection requires a network-level
egress firewall.
## Caveats
- `web/index.php` has a `var_dump($cmd)` debug statement left in `http2pic.class.php:181` - remove before shipping.
- Legacy `http2pic.class.php` has a variable scoping bug: line 109 uses `$url` instead of `$this->params['url']`.
- Cache dir permissions must be `777` (set by `start.sh`).
- ChromeDriver must be running on `localhost:4444` for the API to work.

0
LICENSE.md Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

0
cache/.gitignore vendored Normal file → Executable file
View File

3
docker-compose-dev.yml Normal file → Executable file
View File

@@ -1,4 +1,3 @@
version: '3.3'
services: services:
http2pic: http2pic:
build: build:
@@ -14,5 +13,7 @@ services:
environment: environment:
- URL=http://localhost:8080 - 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 (recommended for public hosting)
ports: ports:
- 8080:80 - 8080:80

9
docker-compose.yml Normal file → Executable file
View File

@@ -1,10 +1,13 @@
version: '3.3'
services: services:
http2pic: http2pic:
image: ghcr.io/hascheksolutions/http2pic:2 image: gitea.haschek.at/haschek-solutions/http2pic:2
restart: unless-stopped restart: unless-stopped
volumes:
- ./cache:/srv/cache
- ./logs:/srv/logs
environment: environment:
- URL=http://localhost:8080 - 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 (recommended for public hosting)
ports: ports:
- 8080:80 - 8080:80

View File

@@ -12,6 +12,9 @@ RUN echo "error_log = /srv/logs/php_errors.log" >> /etc/php84/php.ini
RUN apk add --no-cache php84-ctype php84-dom php84-fileinfo php84-gd php84-iconv php84-simplexml php84-xml php84-xmlreader php84-xmlwriter php84-zip php84-phar php84-openssl RUN apk add --no-cache php84-ctype php84-dom php84-fileinfo php84-gd php84-iconv php84-simplexml php84-xml php84-xmlreader php84-xmlwriter php84-zip php84-phar php84-openssl
RUN curl -sS https://getcomposer.org/installer | /usr/bin/php84 -- --install-dir=/usr/bin --filename=composer RUN curl -sS https://getcomposer.org/installer | /usr/bin/php84 -- --install-dir=/usr/bin --filename=composer
# add symlink for php
RUN ln -s /usr/bin/php84 /usr/bin/php
ADD docker/start.sh /start.sh ADD docker/start.sh /start.sh
RUN chmod +x /start.sh RUN chmod +x /start.sh

22
docker/dokploy-compose.yml Executable file
View File

@@ -0,0 +1,22 @@
services:
http2pic:
build:
context: ../
dockerfile: docker/Dockerfile
restart: unless-stopped
ports:
- 80
environment:
- URL=https://http2pic.haschek.at
- BLOCK_PRIVATE_IPS=true
networks:
- dokploy-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.http2pic.rule=Host(`http2pic.haschek.at`)"
- "traefik.http.routers.http2pic.entrypoints=websecure"
- "traefik.http.routers.http2pic.tls.certResolver=letsencrypt"
- "traefik.http.services.http2pic.loadbalancer.server.port=80"
networks:
dokploy-network:
external: true

View File

@@ -3,7 +3,7 @@ echo ' [+] Starting php'
php-fpm84 php-fpm84
cd /srv/src cd /srv/src
composer install composer install --no-dev --optimize-autoloader
echo ' [+] Starting Chrome' echo ' [+] Starting Chrome'
chromedriver --port=4444 & chromedriver --port=4444 &
@@ -14,12 +14,22 @@ chmod 777 /srv/logs
echo ' [+] Building config' echo ' [+] Building config'
_buildConfig() { _buildConfig() {
local block_private api_key
case "${BLOCK_PRIVATE_IPS:-false}" in
true|1|yes) block_private=true ;;
*) block_private=false ;;
esac
api_key="${API_KEY:-}"
api_key="${api_key//\\/}"
api_key="${api_key//\'/}"
echo "<?php" echo "<?php"
echo "date_default_timezone_set('Europe/Vienna');" echo "date_default_timezone_set('Europe/Vienna');"
echo "define('URL','${URL:-http://localhost:8080}');" echo "define('URL','${URL:-http://localhost:8080}');"
echo "define('API_KEY','${api_key}');"
echo "define('BLOCK_PRIVATE_IPS',${block_private});"
echo "" echo ""
} }
_buildConfig > src/inc/config.inc.php _buildConfig > /srv/src/config.inc.php
caddy run --config /etc/caddy/Caddyfile caddy run --config /etc/caddy/Caddyfile

View File

@@ -0,0 +1,564 @@
# Security Hardening & Bug Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Harden http2pic with optional API key auth, opt-in SSRF protection, viewport cap, and fix two correctness bugs (60ms timeout, viewport set after page load).
**Architecture:** All PHP changes stay in `web/index.php` and `src/helpers.php`. Config vars flow from `docker-compose.yml` env → `docker/start.sh``src/config.inc.php` defines. No new dependencies.
**Tech Stack:** PHP 8.4, php-webdriver, Caddy, Docker
---
## File Map
| File | What changes |
|------|-------------|
| `tests/test_helpers.php` | New — unit tests for `isPrivateIP()` and `getUserIP()` |
| `src/helpers.php` | Add `isPrivateIP()`, fix `getUserIP()`, fix `addToLog()` log injection |
| `docker/start.sh` | Write `API_KEY` and `BLOCK_PRIVATE_IPS` defines into config |
| `docker-compose.yml` | Add commented env var examples |
| `web/index.php` | API key check, SSRF check, viewport cap, viewport-before-get, timeout fix, generic errors |
---
## Task 1: Write failing tests for helpers
**Files:**
- Create: `tests/test_helpers.php`
- [ ] **Step 1: Create test file**
```php
<?php
define('DS', DIRECTORY_SEPARATOR);
define('ROOT', dirname(__FILE__) . '/..');
require_once ROOT . '/src/helpers.php';
$passed = 0;
$failed = 0;
function check(bool $condition, string $label): void {
global $passed, $failed;
if ($condition) { echo "PASS: $label\n"; $passed++; }
else { echo "FAIL: $label\n"; $failed++; }
}
// --- isPrivateIP ---
check(isPrivateIP('127.0.0.1'), 'loopback 127.0.0.1');
check(isPrivateIP('127.255.255.255'), 'loopback 127.255.255.255');
check(isPrivateIP('10.0.0.1'), '10.x private');
check(isPrivateIP('10.255.255.255'), '10.255.x private');
check(isPrivateIP('172.16.0.1'), '172.16.x private');
check(isPrivateIP('172.31.255.255'), '172.31.x private');
check(!isPrivateIP('172.15.255.255'), '172.15.x is public');
check(!isPrivateIP('172.32.0.0'), '172.32.x is public');
check(isPrivateIP('192.168.0.1'), '192.168.x private');
check(isPrivateIP('169.254.169.254'), 'AWS metadata IP');
check(!isPrivateIP('8.8.8.8'), 'Google DNS is public');
check(!isPrivateIP('93.184.216.34'), 'example.com IP is public');
check(isPrivateIP('not-an-ip'), 'unparseable IP blocked');
// --- getUserIP ---
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
$_SERVER['REMOTE_ADDR'] = '5.6.7.8';
check(getUserIP() === '5.6.7.8', 'getUserIP falls back to REMOTE_ADDR');
$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.2.3.4, 10.0.0.1, 172.16.0.5';
check(getUserIP() === '1.2.3.4', 'getUserIP takes first X-Forwarded-For IP');
$_SERVER['HTTP_X_FORWARDED_FOR'] = ' 9.9.9.9 ';
check(getUserIP() === '9.9.9.9', 'getUserIP trims whitespace');
echo "\n$passed passed, $failed failed\n";
exit($failed > 0 ? 1 : 0);
```
- [ ] **Step 2: Run tests — expect failures**
```bash
php tests/test_helpers.php
```
Expected: several `FAIL:` lines (functions don't exist yet or have wrong logic). PHP fatal error on `isPrivateIP` is fine.
---
## Task 2: Implement helpers changes
**Files:**
- Modify: `src/helpers.php`
- [ ] **Step 1: Replace `getUserIP()` and fix `addToLog()`, add `isPrivateIP()`**
Replace the entire contents of `src/helpers.php` with:
```php
<?php
function renderTemplate($templatename, $variables = [], $basepath = ROOT . '/src')
{
ob_start();
if (is_array($variables))
extract($variables);
if (file_exists($basepath . DS . 'templates' . DS . $templatename . '.php'))
include($basepath . DS . 'templates' . DS . $templatename . '.php');
else if (file_exists($basepath . DS . 'templates' . DS . $templatename))
include($basepath . DS . 'templates' . DS . $templatename);
$rendered = ob_get_contents();
ob_end_clean();
return $rendered;
}
function addToLog(string $data): void
{
$data = str_replace(["\n", "\r", "\t"], ' ', $data);
$fp = fopen(ROOT . DS . 'logs' . DS . 'app.log', 'a');
fwrite($fp, date("d.m.y H:i") . "\t" . $data . "\n");
fclose($fp);
}
function getUserIP(): string
{
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
return trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
return $_SERVER['REMOTE_ADDR'];
}
function isPrivateIP(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP) === false) return true;
$long = ip2long($ip);
if ($long === false) return true;
foreach ([
[ip2long('127.0.0.0'), 0xFF000000], // 127.0.0.0/8 loopback
[ip2long('10.0.0.0'), 0xFF000000], // 10.0.0.0/8 RFC1918
[ip2long('172.16.0.0'), 0xFFF00000], // 172.16.0.0/12 RFC1918
[ip2long('192.168.0.0'), 0xFFFF0000], // 192.168.0.0/16 RFC1918
[ip2long('169.254.0.0'), 0xFFFF0000], // 169.254.0.0/16 link-local/metadata
] as [$base, $mask]) {
if (($long & $mask) === ($base & $mask)) return true;
}
return false;
}
```
- [ ] **Step 2: Run tests — expect all pass**
```bash
php tests/test_helpers.php
```
Expected output ends with: `N passed, 0 failed`
- [ ] **Step 3: Commit**
```bash
git add src/helpers.php tests/test_helpers.php
git commit -m "feat: add isPrivateIP helper, fix getUserIP and addToLog"
```
---
## Task 3: Update config generation
**Files:**
- Modify: `docker/start.sh`
- Modify: `docker-compose.yml`
- [ ] **Step 1: Update `_buildConfig` in `docker/start.sh`**
Replace the `_buildConfig` function:
```bash
_buildConfig() {
echo "<?php"
echo "date_default_timezone_set('Europe/Vienna');"
echo "define('URL','${URL:-http://localhost:8080}');"
echo "define('API_KEY','${API_KEY:-}');"
echo "define('BLOCK_PRIVATE_IPS',${BLOCK_PRIVATE_IPS:-false});"
echo ""
}
```
- [ ] **Step 2: Add commented env var examples to `docker-compose.yml`**
Replace the `environment:` block:
```yaml
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 (recommended for public hosting)
```
- [ ] **Step 3: Verify config generation manually**
```bash
URL=http://localhost:8080 API_KEY=test BLOCK_PRIVATE_IPS=true bash -c '
_buildConfig() {
echo "<?php"
echo "date_default_timezone_set('"'"'Europe/Vienna'"'"');"
echo "define('"'"'URL'"'"','"'"'${URL:-http://localhost:8080}'"'"');"
echo "define('"'"'API_KEY'"'"','"'"'${API_KEY:-}'"'"');"
echo "define('"'"'BLOCK_PRIVATE_IPS'"'"',${BLOCK_PRIVATE_IPS:-false});"
echo ""
}
_buildConfig'
```
Expected output:
```
<?php
date_default_timezone_set('Europe/Vienna');
define('URL','http://localhost:8080');
define('API_KEY','test');
define('BLOCK_PRIVATE_IPS',true);
```
- [ ] **Step 4: Commit**
```bash
git add docker/start.sh docker-compose.yml
git commit -m "feat: add API_KEY and BLOCK_PRIVATE_IPS config vars"
```
---
## Task 4: Fix index.php bugs + viewport cap + generic errors
**Files:**
- Modify: `web/index.php`
This task rewrites the `case 'api':` block. Read the current state of `web/index.php` first, then replace the entire `case 'api':` block (from `case 'api':` through `break;`) with the following.
- [ ] **Step 1: Replace `case 'api':` block**
```php
case 'api':
$target = substr($_SERVER['REQUEST_URI'], 5);
if (!$target || !filter_var($target, FILTER_VALIDATE_URL))
$target = $_REQUEST['url'];
if (!filter_var($target, FILTER_VALIDATE_URL)) {
header('HTTP/1.0 400 Bad Request');
echo 'Invalid URL';
exit;
}
$ip = getUserIP();
$viewport = $_REQUEST['viewport'] ?: '1024x768';
if (!preg_match('/^\d+x\d+$/', $viewport)) {
header('HTTP/1.0 400 Bad Request');
echo 'Invalid viewport format. Use WIDTHxHEIGHT (e.g., 1024x768)';
exit;
}
$vpParts = array_map('intval', explode('x', $viewport));
if ($vpParts[0] > 3840 || $vpParts[1] > 2160) {
header('HTTP/1.0 400 Bad Request');
echo 'Viewport exceeds maximum (3840x2160)';
exit;
}
$js = $_REQUEST['js'] == 'false' ? false : true;
$serverUrl = 'http://localhost:4444';
$options = new \Facebook\WebDriver\Chrome\ChromeOptions();
$options->addArguments(['--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']);
$capabilities = DesiredCapabilities::chrome();
$capabilities->setCapability(\Facebook\WebDriver\Chrome\ChromeOptions::CAPABILITY, $options);
if (!$js)
$capabilities->setCapability('javascriptEnabled', false);
$driver = null;
$error = null;
try {
$driver = RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60000);
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($vpParts[0], $vpParts[1]));
$driver->get($target);
$driver->executeScript('document.body.style.overflow = "hidden";');
addToLog("$ip\tRequested $target with viewport $viewport and js " . ($js ? 'enabled' : 'disabled'));
$screenshot = $driver->takeScreenshot();
header('Content-Type: image/png');
header('Content-Length: ' . strlen($screenshot));
echo $screenshot;
} catch (Exception $e) {
$error = $e->getMessage();
addToLog("$ip\tRequested $target but resulted in error:\t" . $error);
} finally {
if ($driver instanceof RemoteWebDriver) {
try { $driver->quit(); } catch (Exception $q) {}
}
}
if ($error !== null) {
header('HTTP/1.0 500 Internal Server Error');
echo 'Screenshot failed';
}
break;
```
- [ ] **Step 2: Test viewport cap with curl**
```bash
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com&viewport=99999x99999"
```
Expected: `400`
```bash
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com&viewport=1024x768"
```
Expected: `200`
- [ ] **Step 3: Commit**
```bash
git add web/index.php
git commit -m "fix: viewport before page load, 60ms→60s timeout, viewport cap, generic errors"
```
---
## Task 5: Add API key authentication
**Files:**
- Modify: `web/index.php`
- [ ] **Step 1: Add API key check at top of `case 'api':` block**
Insert immediately after `case 'api':` (before the `$target` line):
```php
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;
}
}
```
- [ ] **Step 2: Test without key (no API_KEY configured)**
```bash
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com"
```
Expected: `200` (API_KEY not set in local config → open access)
- [ ] **Step 3: Test with key via header**
Temporarily set `API_KEY` in `src/config.inc.php` to `'testkey'`, then:
```bash
# missing key → 401
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com"
# header → 200
curl -s -o /dev/null -w "%{http_code}" -H "X-API-Key: testkey" "http://localhost:8080/api?url=https://example.com"
# query param → 200
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?key=testkey&url=https://example.com"
# wrong key → 401
curl -s -o /dev/null -w "%{http_code}" -H "X-API-Key: wrongkey" "http://localhost:8080/api?url=https://example.com"
```
Expected: `401`, `200`, `200`, `401`
Revert the manual edit to `src/config.inc.php` after testing (it gets regenerated by `start.sh` in Docker).
- [ ] **Step 4: Document API key usage in CLAUDE.md**
In `CLAUDE.md`, add a new section under the existing content:
```markdown
## API Key
Set `API_KEY` env var in docker-compose to require authentication on all `/api` requests.
Leave unset (default) for open access.
```bash
# via header (preferred — not logged in access logs)
curl -H "X-API-Key: your-secret-key" "http://host/api?url=https://example.com"
# via query param
curl "http://host/api?key=your-secret-key&url=https://example.com"
```
## SSRF Protection
Set `BLOCK_PRIVATE_IPS=true` to reject requests to LAN, loopback, and cloud metadata IPs.
Recommended when hosting publicly. Default is off (allows local/LAN addresses).
```
- [ ] **Step 5: Commit**
```bash
git add web/index.php CLAUDE.md
git commit -m "feat: optional API key auth via X-API-Key header or ?key= param"
```
---
## Task 6: Add SSRF protection
**Files:**
- Modify: `web/index.php`
- [ ] **Step 1: Add SSRF check after viewport validation**
Insert after the `$js = ...` line (before `$serverUrl = ...`):
```php
if (defined('BLOCK_PRIVATE_IPS') && BLOCK_PRIVATE_IPS) {
$host = parse_url($target, PHP_URL_HOST);
if (filter_var($host, FILTER_VALIDATE_IP)) {
$resolvedIp = $host;
} else {
$resolvedIp = gethostbyname($host);
if ($resolvedIp === $host) {
header('HTTP/1.0 403 Forbidden');
echo 'URL not allowed';
exit;
}
}
if (isPrivateIP($resolvedIp)) {
header('HTTP/1.0 403 Forbidden');
echo 'URL not allowed';
exit;
}
}
```
- [ ] **Step 2: Test SSRF protection**
Temporarily set `BLOCK_PRIVATE_IPS` to `true` in `src/config.inc.php`, then:
```bash
# private IP → 403
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=http://192.168.1.1/"
# loopback → 403
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=http://127.0.0.1/"
# metadata → 403
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=http://169.254.169.254/"
# public URL → 200
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com"
```
Expected: `403`, `403`, `403`, `200`
Revert the manual edit to `src/config.inc.php` after testing.
- [ ] **Step 3: Commit**
```bash
git add web/index.php
git commit -m "feat: opt-in SSRF protection via BLOCK_PRIVATE_IPS env var"
```
---
## Final state of `web/index.php` `case 'api':` block
For reference, the complete block after all tasks:
```php
case 'api':
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;
}
}
$target = substr($_SERVER['REQUEST_URI'], 5);
if (!$target || !filter_var($target, FILTER_VALIDATE_URL))
$target = $_REQUEST['url'];
if (!filter_var($target, FILTER_VALIDATE_URL)) {
header('HTTP/1.0 400 Bad Request');
echo 'Invalid URL';
exit;
}
$ip = getUserIP();
$viewport = $_REQUEST['viewport'] ?: '1024x768';
if (!preg_match('/^\d+x\d+$/', $viewport)) {
header('HTTP/1.0 400 Bad Request');
echo 'Invalid viewport format. Use WIDTHxHEIGHT (e.g., 1024x768)';
exit;
}
$vpParts = array_map('intval', explode('x', $viewport));
if ($vpParts[0] > 3840 || $vpParts[1] > 2160) {
header('HTTP/1.0 400 Bad Request');
echo 'Viewport exceeds maximum (3840x2160)';
exit;
}
$js = $_REQUEST['js'] == 'false' ? false : true;
if (defined('BLOCK_PRIVATE_IPS') && BLOCK_PRIVATE_IPS) {
$host = parse_url($target, PHP_URL_HOST);
if (filter_var($host, FILTER_VALIDATE_IP)) {
$resolvedIp = $host;
} else {
$resolvedIp = gethostbyname($host);
if ($resolvedIp === $host) {
header('HTTP/1.0 403 Forbidden');
echo 'URL not allowed';
exit;
}
}
if (isPrivateIP($resolvedIp)) {
header('HTTP/1.0 403 Forbidden');
echo 'URL not allowed';
exit;
}
}
$serverUrl = 'http://localhost:4444';
$options = new \Facebook\WebDriver\Chrome\ChromeOptions();
$options->addArguments(['--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']);
$capabilities = DesiredCapabilities::chrome();
$capabilities->setCapability(\Facebook\WebDriver\Chrome\ChromeOptions::CAPABILITY, $options);
if (!$js)
$capabilities->setCapability('javascriptEnabled', false);
$driver = null;
$error = null;
try {
$driver = RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60000);
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($vpParts[0], $vpParts[1]));
$driver->get($target);
$driver->executeScript('document.body.style.overflow = "hidden";');
addToLog("$ip\tRequested $target with viewport $viewport and js " . ($js ? 'enabled' : 'disabled'));
$screenshot = $driver->takeScreenshot();
header('Content-Type: image/png');
header('Content-Length: ' . strlen($screenshot));
echo $screenshot;
} catch (Exception $e) {
$error = $e->getMessage();
addToLog("$ip\tRequested $target but resulted in error:\t" . $error);
} finally {
if ($driver instanceof RemoteWebDriver) {
try { $driver->quit(); } catch (Exception $q) {}
}
}
if ($error !== null) {
header('HTTP/1.0 500 Internal Server Error');
echo 'Screenshot failed';
}
break;
```

View File

@@ -0,0 +1,169 @@
# 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:
```php
define('API_KEY', '${API_KEY:-}');
define('BLOCK_PRIVATE_IPS', ${BLOCK_PRIVATE_IPS:-false});
```
`docker-compose.yml` gets commented-out examples:
```yaml
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.
```php
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):**
```bash
# 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)
```php
// 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:
```php
$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:
```php
$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):
```php
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 |

1
src/.gitignore vendored Normal file → Executable file
View File

@@ -1 +1,2 @@
vendor/ vendor/
config.inc.php

0
src/composer.json Normal file → Executable file
View File

0
src/composer.lock generated Normal file → Executable file
View File

3
src/config.inc.php.php Executable file
View File

@@ -0,0 +1,3 @@
<?php
define('URL','http://localhost:8080'); //no trailing slash

0
src/config.inc.php → src/example.config.inc.php Normal file → Executable file
View File

36
src/helpers.php Normal file → Executable file
View File

@@ -11,6 +11,40 @@ function renderTemplate($templatename,$variables=[],$basepath=ROOT.'/src')
include($basepath . DS . 'templates' . DS . $templatename); include($basepath . DS . 'templates' . DS . $templatename);
$rendered = ob_get_contents(); $rendered = ob_get_contents();
ob_end_clean(); ob_end_clean();
return $rendered; return $rendered;
} }
function addToLog(string $data): void
{
$data = str_replace(["\n", "\r", "\t"], ' ', $data);
$fp = fopen(ROOT . DS . 'logs' . DS . 'app.log', 'a');
fwrite($fp, date("d.m.y H:i") . "\t" . $data . "\n");
fclose($fp);
}
function getUserIP(): string
{
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
return trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
return $_SERVER['REMOTE_ADDR'];
}
function isPrivateIP(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP) === false) return true;
$long = ip2long($ip);
if ($long === false) return true;
foreach ([
[ip2long('0.0.0.0'), 0xFF000000], // 0.0.0.0/8 unspecified (routes to loopback on Linux)
[ip2long('127.0.0.0'), 0xFF000000], // 127.0.0.0/8 loopback
[ip2long('10.0.0.0'), 0xFF000000], // 10.0.0.0/8 RFC1918
[ip2long('172.16.0.0'), 0xFFF00000], // 172.16.0.0/12 RFC1918
[ip2long('192.168.0.0'), 0xFFFF0000], // 192.168.0.0/16 RFC1918
[ip2long('169.254.0.0'), 0xFFFF0000], // 169.254.0.0/16 link-local/metadata
] as [$base, $mask]) {
if (($long & $mask) === ($base & $mask)) return true;
}
return false;
}

20
src/http2pic.class.php Normal file → Executable file
View File

@@ -104,7 +104,7 @@ class http2pic
//validate URL and check if exists //validate URL and check if exists
if ($this->isBase64($this->params['url'])) if ($this->isBase64($this->params['url']))
$this->params['url'] = base64_decode($url); $this->params['url'] = base64_decode($this->params['url']);
else else
$this->params['url'] = rawurldecode($_GET['url']); $this->params['url'] = rawurldecode($_GET['url']);
@@ -171,18 +171,24 @@ class http2pic
$cmd.=' -f png'; $cmd.=' -f png';
//add url to cmd //add url to cmd
$cmd.=' \''.addslashes($this->params['url']).'\''; $cmd.=' '.escapeshellarg($this->params['url']);
//add storage path to cmd //add storage path to cmd
$cmd.=' '.escapeshellarg($this->params['file']); $cmd.=' '.escapeshellarg($this->params['file']);
$cmd.=' --wait-for-network-idle'; $cmd.=' --wait-for-network-idle';
var_dump($cmd); $output = [];
$rc = 0;
$cmd = escapeshellcmd($cmd); exec($cmd . ' 2>&1', $output, $rc);
shell_exec($cmd);
$this->params['cmd'] = $cmd; $this->params['cmd'] = $cmd;
if ($rc !== 0) {
$this->params['render_error'] = implode("\n", $output);
header('Content-Type: image/jpeg');
$result = imagecreatefromjpeg($this->params['onfail']);
imagejpeg($result, NULL, 100);
return $cmd;
}
$this->postRender(); $this->postRender();
@@ -273,6 +279,8 @@ class http2pic
{ {
$ch = curl_init($url); $ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
if(curl_exec($ch) != false){ if(curl_exec($ch) != false){
//We were able to connect to a webserver, what did it return? //We were able to connect to a webserver, what did it return?
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

2
src/templates/index.html.php Normal file → Executable file
View File

@@ -71,7 +71,7 @@
<!-- Main Content --> <!-- Main Content -->
<div class="container"> <div class="container">
<h2>How the API works</h2> <h2>How the API works</h2>
<div class="well"><h2 ><?=URL?>/api/url=<span style="color:#1e90ff">[WEBSITE_URL]</span>&amp;<span style="color:#C73C49">[OPTIONS]</span></h2></div><hr/><br/> <div class="well"><h2 ><?=URL?>/api/?url=<span style="color:#1e90ff">[WEBSITE_URL]</span>&amp;<span style="color:#C73C49">[OPTIONS]</span></h2></div><hr/><br/>
<div > <div >
<div> <div>
<section> <section>

50
tests/test_helpers.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
define('DS', DIRECTORY_SEPARATOR);
define('ROOT', dirname(__FILE__) . '/..');
require_once ROOT . '/src/helpers.php';
$passed = 0;
$failed = 0;
function check(bool $condition, string $label): void {
global $passed, $failed;
if ($condition) { echo "PASS: $label\n"; $passed++; }
else { echo "FAIL: $label\n"; $failed++; }
}
// --- isPrivateIP ---
check(isPrivateIP('127.0.0.1'), 'loopback 127.0.0.1');
check(isPrivateIP('127.255.255.255'), 'loopback 127.255.255.255');
check(isPrivateIP('10.0.0.1'), '10.x private');
check(isPrivateIP('10.255.255.255'), '10.255.x private');
check(isPrivateIP('172.16.0.1'), '172.16.x private');
check(isPrivateIP('172.31.255.255'), '172.31.x private');
check(!isPrivateIP('172.15.255.255'), '172.15.x is public');
check(!isPrivateIP('172.32.0.0'), '172.32.x is public');
check(isPrivateIP('192.168.0.1'), '192.168.x private');
check(isPrivateIP('169.254.169.254'), 'AWS metadata IP');
check(!isPrivateIP('8.8.8.8'), 'Google DNS is public');
check(!isPrivateIP('93.184.216.34'), 'example.com IP is public');
check(isPrivateIP('not-an-ip'), 'unparseable IP blocked');
check(isPrivateIP('0.0.0.0'), '0.0.0.0 blocked (routes to loopback)');
// --- getUserIP ---
unset($_SERVER['HTTP_CLIENT_IP']);
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
$_SERVER['REMOTE_ADDR'] = '5.6.7.8';
check(getUserIP() === '5.6.7.8', 'getUserIP falls back to REMOTE_ADDR');
$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.2.3.4, 10.0.0.1, 172.16.0.5';
check(getUserIP() === '1.2.3.4', 'getUserIP takes first X-Forwarded-For IP');
$_SERVER['HTTP_X_FORWARDED_FOR'] = ' 9.9.9.9 ';
check(getUserIP() === '9.9.9.9', 'getUserIP trims whitespace');
$_SERVER['HTTP_CLIENT_IP'] = '6.6.6.6';
$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.1.1.1';
check(getUserIP() === '1.1.1.1', 'HTTP_CLIENT_IP ignored, X-Forwarded-For used');
unset($_SERVER['HTTP_CLIENT_IP']);
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
echo "\n$passed passed, $failed failed\n";
exit($failed > 0 ? 1 : 0);

0
web/css/bootstrap.css vendored Normal file → Executable file
View File

0
web/css/bootstrap.min.css vendored Normal file → Executable file
View File

0
web/css/clean-blog.css Normal file → Executable file
View File

0
web/css/clean-blog.min.css vendored Normal file → Executable file
View File

0
web/css/http2pic.css Normal file → Executable file
View File

0
web/fonts/glyphicons-halflings-regular.eot Normal file → Executable file
View File

0
web/fonts/glyphicons-halflings-regular.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

0
web/fonts/glyphicons-halflings-regular.ttf Normal file → Executable file
View File

0
web/fonts/glyphicons-halflings-regular.woff Normal file → Executable file
View File

0
web/fonts/glyphicons-halflings-regular.woff2 Normal file → Executable file
View File

0
web/img/domainfailed.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

0
web/img/failed.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

0
web/img/home-bg.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

0
web/img/hs_logo.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

0
web/img/loading.gif Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

0
web/img/pagefailed.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

101
web/index.php Normal file → Executable file
View File

@@ -1,10 +1,15 @@
<?php <?php
use Facebook\WebDriver\Remote\RemoteWebDriver; use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities; use Facebook\WebDriver\Remote\DesiredCapabilities;
define('DS', DIRECTORY_SEPARATOR); define('DS', DIRECTORY_SEPARATOR);
define('ROOT', dirname(__FILE__) . DS . '..'); define('ROOT', dirname(__FILE__) . DS . '..');
// increase PHP timeout - rendering can take 60s+
set_time_limit(120);
ignore_user_abort(true);
require_once(ROOT . DS . 'src' . DS . 'config.inc.php'); require_once(ROOT . DS . 'src' . DS . 'config.inc.php');
require_once(ROOT . DS . 'src' . DS . 'helpers.php'); require_once(ROOT . DS . 'src' . DS . 'helpers.php');
require_once(ROOT . DS . 'src' . DS . 'http2pic.class.php'); require_once(ROOT . DS . 'src' . DS . 'http2pic.class.php');
@@ -17,21 +22,66 @@ if(php_sapi_name()=='cli-server' && file_exists(ROOT.DS.'web'.DS.implode('/',$ur
return false; return false;
switch($url[0]) switch ($url[0]) {
{
case 'api': case 'api':
if (defined('API_KEY') && API_KEY !== '') {
$provided = $_SERVER['HTTP_X_API_KEY'] ?? $_REQUEST['key'] ?? '';
if (!hash_equals(API_KEY, $provided)) {
header('HTTP/1.0 401 Unauthorized');
echo 'Invalid or missing API key';
exit;
}
}
$target = substr($_SERVER['REQUEST_URI'], 5); $target = substr($_SERVER['REQUEST_URI'], 5);
if (!$target || !filter_var($target, FILTER_VALIDATE_URL)) if (!$target || !filter_var($target, FILTER_VALIDATE_URL))
$target = $_REQUEST['url']; $target = $_REQUEST['url'];
if(!filter_var($target, FILTER_VALIDATE_URL)) if (!filter_var($target, FILTER_VALIDATE_URL)) {
{
header('HTTP/1.0 400 Bad Request'); header('HTTP/1.0 400 Bad Request');
echo 'Invalid URL'; echo 'Invalid URL';
exit; exit;
} }
$viewport = $_REQUEST['viewport']; $scheme = strtolower(parse_url($target, PHP_URL_SCHEME) ?? '');
if (!in_array($scheme, ['http', 'https'], true)) {
header('HTTP/1.0 400 Bad Request');
echo 'Invalid URL';
exit;
}
$ip = getUserIP();
$viewport = $_REQUEST['viewport'] ?: '1024x768';
if (!preg_match('/^\d+x\d+$/', $viewport)) {
header('HTTP/1.0 400 Bad Request');
echo 'Invalid viewport format. Use WIDTHxHEIGHT (e.g., 1024x768)';
exit;
}
$vpParts = array_map('intval', explode('x', $viewport));
if ($vpParts[0] < 1 || $vpParts[1] < 1 || $vpParts[0] > 3840 || $vpParts[1] > 2160) {
header('HTTP/1.0 400 Bad Request');
echo 'Viewport dimensions must be between 1x1 and 3840x2160';
exit;
}
$js = $_REQUEST['js'] == 'false' ? false : true; $js = $_REQUEST['js'] == 'false' ? false : true;
if (defined('BLOCK_PRIVATE_IPS') && BLOCK_PRIVATE_IPS) {
$host = parse_url($target, PHP_URL_HOST);
if (filter_var($host, FILTER_VALIDATE_IP)) {
$resolvedIp = $host;
} else {
$resolvedIp = gethostbyname($host);
if ($resolvedIp === $host) {
header('HTTP/1.0 403 Forbidden');
echo 'URL not allowed';
exit;
}
}
if (isPrivateIP($resolvedIp)) {
header('HTTP/1.0 403 Forbidden');
echo 'URL not allowed';
exit;
}
}
$serverUrl = 'http://localhost:4444'; $serverUrl = 'http://localhost:4444';
$options = new \Facebook\WebDriver\Chrome\ChromeOptions(); $options = new \Facebook\WebDriver\Chrome\ChromeOptions();
$options->addArguments(['--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']); $options->addArguments(['--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']);
@@ -39,34 +89,35 @@ switch($url[0])
$capabilities = DesiredCapabilities::chrome(); $capabilities = DesiredCapabilities::chrome();
$capabilities->setCapability(\Facebook\WebDriver\Chrome\ChromeOptions::CAPABILITY, $options); $capabilities->setCapability(\Facebook\WebDriver\Chrome\ChromeOptions::CAPABILITY, $options);
//disable javascript if $js is false
if (!$js) if (!$js)
$capabilities->setCapability('javascriptEnabled', false); $capabilities->setCapability('javascriptEnabled', false);
$driver = RemoteWebDriver::create($serverUrl, $capabilities); $driver = null;
$error = null;
try {
$driver = RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60000);
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($vpParts[0], $vpParts[1]));
$driver->get($target); $driver->get($target);
//hide scroll bars
$driver->executeScript('document.body.style.overflow = "hidden";'); $driver->executeScript('document.body.style.overflow = "hidden";');
//set screenshot size to 1920x1080 addToLog($ip . ' Requested ' . $target . ' viewport=' . $viewport . ' js=' . ($js ? 'enabled' : 'disabled'));
//$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension(1024, 768));
//if $viewport is set, set window size
if($viewport)
{
$viewport = explode('x',$viewport);
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($viewport[0], $viewport[1]));
}
else
{
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension(1024, 768));
}
// take screenshot and save to file $screenshot = $driver->takeScreenshot();
//header for png
header('Content-Type: image/png'); header('Content-Type: image/png');
echo $driver->takeScreenshot(); header('Content-Length: ' . strlen($screenshot));
echo $screenshot;
} catch (Exception $e) {
$error = $e->getMessage();
addToLog($ip . ' Error requesting ' . $target . ': ' . $error);
} finally {
if ($driver instanceof RemoteWebDriver) {
try { $driver->quit(); } catch (Exception $q) {}
}
}
if ($error !== null) {
header('HTTP/1.0 500 Internal Server Error');
echo 'Screenshot failed';
}
break; break;
default: default:

0
web/js/bootstrap.js vendored Normal file → Executable file
View File

0
web/js/bootstrap.min.js vendored Normal file → Executable file
View File

0
web/js/clean-blog.js Normal file → Executable file
View File

0
web/js/clean-blog.min.js vendored Normal file → Executable file
View File

0
web/js/http2pic.js Normal file → Executable file
View File

0
web/js/jquery.js vendored Normal file → Executable file
View File

0
web/js/jquery.min.js vendored Normal file → Executable file
View File

0
web/less/clean-blog.less Normal file → Executable file
View File

0
web/less/mixins.less Normal file → Executable file
View File

0
web/less/variables.less Normal file → Executable file
View File