Compare commits
33 Commits
v2.0.0
...
ada976a224
| Author | SHA1 | Date | |
|---|---|---|---|
| ada976a224 | |||
| 6973522c45 | |||
| 6dc0001f9d | |||
| 4ab30bcc1d | |||
| 9295115742 | |||
| efc9e6510c | |||
| e7924f462e | |||
| 8590465c6a | |||
| cc30d2288e | |||
| 3ab7c1334f | |||
| 75ead2f5ad | |||
| 15720489ba | |||
| 73118498c9 | |||
| 7f9a752b57 | |||
| 7323eed789 | |||
| 7616dee994 | |||
| 4a548f50e7 | |||
| faea2b0899 | |||
| 427fa24565 | |||
| 086e7c7a77 | |||
| 181bed4449 | |||
| 6e0795bbdf | |||
| 5df5a0ad7a | |||
| 63b49dd282 | |||
| 5e8f4e33e3 | |||
| a140a35448 | |||
| a0765efc3c | |||
| 543e44abc8 | |||
| 1443cfee12 | |||
| 0d17b5d474 | |||
| 3eed66b9a9 | |||
| 184e673277 | |||
| 83926b0f9a |
0
.devcontainer/Caddyfile
Normal file → Executable file
0
.devcontainer/Dockerfile
Normal file → Executable file
0
.devcontainer/devcontainer.json
Normal file → Executable file
0
.devcontainer/start.sh
Normal file → Executable file
49
.gitea/workflows/build-docker.yml
Executable 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 }}
|
||||||
|
|
||||||
59
.github/workflows/build-docker.yml
vendored
@@ -1,59 +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: |
|
|
||||||
hascheksolutions/http2pic
|
|
||||||
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 Docker Hub
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
- 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.GITHUB_TOKEN }}
|
|
||||||
- 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
76
CLAUDE.md
Normal 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
0
cache/.gitignore
vendored
Normal file → Executable file
3
docker-compose-dev.yml
Normal file → Executable 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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
564
docs/superpowers/plans/2026-04-20-security-hardening.md
Normal 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;
|
||||||
|
```
|
||||||
169
docs/superpowers/specs/2026-04-20-security-hardening-design.md
Normal 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 |
|
||||||
3
src/.gitignore
vendored
Normal file → Executable file
@@ -1 +1,2 @@
|
|||||||
vendor/
|
vendor/
|
||||||
|
config.inc.php
|
||||||
0
src/composer.json
Normal file → Executable file
0
src/composer.lock
generated
Normal file → Executable file
3
src/config.inc.php.php
Executable 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
52
src/helpers.php
Normal file → Executable file
@@ -1,16 +1,50 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
function renderTemplate($templatename,$variables=[],$basepath=ROOT.'/src')
|
function renderTemplate($templatename, $variables = [], $basepath = ROOT . '/src')
|
||||||
{
|
{
|
||||||
ob_start();
|
ob_start();
|
||||||
if(is_array($variables))
|
if (is_array($variables))
|
||||||
extract($variables);
|
extract($variables);
|
||||||
if(file_exists($basepath.DS.'templates'.DS.$templatename.'.php'))
|
if (file_exists($basepath . DS . 'templates' . DS . $templatename . '.php'))
|
||||||
include($basepath.DS.'templates'.DS.$templatename.'.php');
|
include($basepath . DS . 'templates' . DS . $templatename . '.php');
|
||||||
else if(file_exists($basepath.DS.'templates'.DS.$templatename))
|
else if (file_exists($basepath . DS . 'templates' . DS . $templatename))
|
||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
28
src/http2pic.class.php
Normal file → Executable 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,19 +171,25 @@ 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();
|
||||||
|
|
||||||
if(DEBUG)
|
if(DEBUG)
|
||||||
@@ -271,8 +277,10 @@ class http2pic
|
|||||||
*/
|
*/
|
||||||
function isURLReachable($url)
|
function isURLReachable($url)
|
||||||
{
|
{
|
||||||
$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
@@ -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>&<span style="color:#C73C49">[OPTIONS]</span></h2></div><hr/><br/>
|
<div class="well"><h2 ><?=URL?>/api/?url=<span style="color:#1e90ff">[WEBSITE_URL]</span>&<span style="color:#C73C49">[OPTIONS]</span></h2></div><hr/><br/>
|
||||||
<div >
|
<div >
|
||||||
<div>
|
<div>
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
50
tests/test_helpers.php
Normal 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
0
web/css/bootstrap.min.css
vendored
Normal file → Executable file
0
web/css/clean-blog.css
Normal file → Executable file
0
web/css/clean-blog.min.css
vendored
Normal file → Executable file
0
web/css/http2pic.css
Normal file → Executable file
0
web/fonts/glyphicons-halflings-regular.eot
Normal file → Executable file
0
web/fonts/glyphicons-halflings-regular.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
0
web/fonts/glyphicons-halflings-regular.ttf
Normal file → Executable file
0
web/fonts/glyphicons-halflings-regular.woff
Normal file → Executable file
0
web/fonts/glyphicons-halflings-regular.woff2
Normal file → Executable file
0
web/img/domainfailed.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
0
web/img/failed.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
0
web/img/home-bg.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 169 KiB |
0
web/img/hs_logo.png
Normal file → Executable file
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
0
web/img/loading.gif
Normal file → Executable file
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
0
web/img/pagefailed.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
131
web/index.php
Normal file → Executable file
@@ -1,75 +1,126 @@
|
|||||||
<?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 . '..');
|
||||||
|
|
||||||
require_once(ROOT.DS.'src'.DS.'config.inc.php');
|
// increase PHP timeout - rendering can take 60s+
|
||||||
require_once(ROOT.DS.'src'.DS.'helpers.php');
|
set_time_limit(120);
|
||||||
require_once(ROOT.DS.'src'.DS.'http2pic.class.php');
|
ignore_user_abort(true);
|
||||||
require_once(ROOT.DS.'src'.DS.'vendor'.DS.'autoload.php');
|
|
||||||
|
|
||||||
$url = array_filter(explode('/',ltrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),'/')));
|
require_once(ROOT . DS . 'src' . DS . 'config.inc.php');
|
||||||
|
require_once(ROOT . DS . 'src' . DS . 'helpers.php');
|
||||||
|
require_once(ROOT . DS . 'src' . DS . 'http2pic.class.php');
|
||||||
|
require_once(ROOT . DS . 'src' . DS . 'vendor' . DS . 'autoload.php');
|
||||||
|
|
||||||
|
$url = array_filter(explode('/', ltrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/')));
|
||||||
|
|
||||||
//check for integrated server
|
//check for integrated server
|
||||||
if(php_sapi_name()=='cli-server' && file_exists(ROOT.DS.'web'.DS.implode('/',$url)) && !is_dir(ROOT.DS.'web'.DS.implode('/',$url)))
|
if (php_sapi_name() == 'cli-server' && file_exists(ROOT . DS . 'web' . DS . implode('/', $url)) && !is_dir(ROOT . DS . 'web' . DS . implode('/', $url)))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|
||||||
switch($url[0])
|
switch ($url[0]) {
|
||||||
{
|
|
||||||
case 'api':
|
case 'api':
|
||||||
$target = substr($_SERVER['REQUEST_URI'],5);
|
if (defined('API_KEY') && API_KEY !== '') {
|
||||||
if(!$target || !filter_var($target, FILTER_VALIDATE_URL))
|
$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);
|
||||||
|
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) ?? '');
|
||||||
$js = $_REQUEST['js']=='false'?false:true;
|
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;
|
||||||
|
|
||||||
|
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']);
|
||||||
|
|
||||||
$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->executeScript('document.body.style.overflow = "hidden";');
|
||||||
|
|
||||||
$driver->get($target);
|
addToLog($ip . ' Requested ' . $target . ' viewport=' . $viewport . ' js=' . ($js ? 'enabled' : 'disabled'));
|
||||||
|
|
||||||
//hide scroll bars
|
$screenshot = $driver->takeScreenshot();
|
||||||
$driver->executeScript('document.body.style.overflow = "hidden";');
|
header('Content-Type: image/png');
|
||||||
|
header('Content-Length: ' . strlen($screenshot));
|
||||||
//set screenshot size to 1920x1080
|
echo $screenshot;
|
||||||
//$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension(1024, 768));
|
} catch (Exception $e) {
|
||||||
//if $viewport is set, set window size
|
$error = $e->getMessage();
|
||||||
if($viewport)
|
addToLog($ip . ' Error requesting ' . $target . ': ' . $error);
|
||||||
{
|
} finally {
|
||||||
$viewport = explode('x',$viewport);
|
if ($driver instanceof RemoteWebDriver) {
|
||||||
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($viewport[0], $viewport[1]));
|
try { $driver->quit(); } catch (Exception $q) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
if ($error !== null) {
|
||||||
{
|
header('HTTP/1.0 500 Internal Server Error');
|
||||||
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension(1024, 768));
|
echo 'Screenshot failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
// take screenshot and save to file
|
break;
|
||||||
//header for png
|
|
||||||
header('Content-Type: image/png');
|
|
||||||
echo $driver->takeScreenshot();
|
|
||||||
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
echo renderTemplate('index.html.php');
|
echo renderTemplate('index.html.php');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||