When migrating a web application between servers, the primary risk isn't the data transfer itself-it's validating the new environment without modifying public DNS or forcing the entire team to manually edit their local hosts files.
In this case study, I implemented a custom proxy layer in PHP to compare the legacy environment (andamento) and the target environment (fortis) side by side. This allows for the validation of VirtualHost configurations, session persistence, form submissions, and static resource loading under the application's actual logic host before any DNS cutover occurs.
1) The real-world migration problem to solve#
While the production URL was fully operational, the new environment could not yet receive public traffic. I needed to:
- Test exactly the same domain on the new backend;
- Avoid changing global DNS before complete validation;
- Avoid manual
hostsfile adjustments on each local workstation; - Compare legacy vs new responses at the exact same moment.
This is where the smart proxy comes in: it connects to the target IP but forces Host: domain.com in the cURL request headers so that the destination Apache server resolves the correct VirtualHost.
2) The multi-tiered architecture (config.php + dev.php + proxy.php)#
I structured the tool into three distinct components to maintain modularity and security:
config.php: Stores IPs, ports, environment constants, and basic network sanity checks.dev.php: The visual front-end featuring a split-pane grid with two iframes side by side.proxy.php: The routing engine that validates authentication, rate limits request rates, executes cURL requests with cookie forwarding, logs activities, and sanitizes output.
The Request Lifecycle:
- The operator enters the domain and path in the
dev.phpdashboard; - The dashboard generates two requests calling the
proxy.phpscript; proxy.phpverifies the API key/credentials, rate limiting status, validates the URI format, and maps the server IP;- The cURL library establishes a connection to the target IP, forwarding the original
Hostheader and client cookies; - The target web server matches the request to the correct
VirtualHostand outputs the content; - The proxy forwards the payload (injecting a
<base>path for relative assets) and renders the result in the corresponding iframe.
3) Complete supporting code#
3.1 config.php#
<?php
// Comparison environments
const ANDAMENTO_IP = '10.10.10.11';
const FORTIS_IP = '10.10.10.12';
// Optional: different ports per environment
const DEFAULT_HTTP_PORT = 80;
const DEFAULT_HTTPS_PORT = 443;
// Security validation to guarantee valid structured IP addresses
function validateIP(string $ip): bool {
return filter_var($ip, FILTER_VALIDATE_IP) !== false;
}
if (!validateIP(ANDAMENTO_IP) || !validateIP(FORTIS_IP)) {
http_response_code(500);
die('Configuration Error: Legacy or Target IPs are invalid.');
}
3.2 dev.php (comparative dashboard)#
<?php
$uri = $_GET['uri'] ?? 'domain.com/';
$uri = trim($uri);
$key = $_GET['key'] ?? '';
$key = preg_replace('/[^a-zA-Z0-9\-]/', '', $key); // Sanitize API key string
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Migration Comparator</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background-color: #f5f5f5; }
.bar { padding: 12px; border-bottom: 1px solid #ddd; background-color: #fff; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; height: calc(100vh - 70px); }
iframe { width: 100%; height: 100%; border: 0; background: #fff; }
input { width: 60%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
button { padding: 8px 12px; background-color: #007bff; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
button:hover { background-color: #0056b3; }
</style>
</head>
<body>
<div class="bar">
<form method="get">
<label>URI (domain + path):</label>
<input name="uri" value="<?= htmlspecialchars($uri, ENT_QUOTES, 'UTF-8') ?>" placeholder="example.com/path">
<?php if (!empty($key)): ?>
<input type="hidden" name="key" value="<?= htmlspecialchars($key, ENT_QUOTES, 'UTF-8') ?>">
<?php endif; ?>
<button type="submit">Compare</button>
</form>
</div>
<div class="grid">
<iframe src="proxy.php?server=andamento&uri=<?= urlencode($uri) ?><?= $key ? '&key=' . urlencode($key) : '' ?>"></iframe>
<iframe src="proxy.php?server=fortis&uri=<?= urlencode($uri) ?><?= $key ? '&key=' . urlencode($key) : '' ?>"></iframe>
</div>
</body>
</html>
3.3 proxy.php (secured field version)#
<?php
// Global security directives and signature hiding
ini_set('display_errors', 0);
ini_set('log_errors', 1);
header_remove('X-Powered-By');
// Hardening and Security Headers (CSP removed to match actual target vhost)
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
header('X-XSS-Protection: 1; mode=block');
header('Referrer-Policy: strict-origin-when-cross-origin');
require 'config.php';
// 1) Mandatory Proxy Authentication (Without Insecure Fallbacks)
$API_KEY = $_SERVER['HTTP_X_API_KEY'] ?? $_GET['key'] ?? '';
$VALID_KEY = getenv('PROXY_API_KEY');
if (empty($VALID_KEY)) {
http_response_code(500);
die('Configuration Error: PROXY_API_KEY not defined in environment.');
}
if ($API_KEY !== $VALID_KEY) {
// Fallback to HTTP Basic Auth
$USER = $_SERVER['PHP_AUTH_USER'] ?? '';
$PASS = $_SERVER['PHP_AUTH_PASS'] ?? '';
$VALID_PASS = getenv('PROXY_PASSWORD');
if (empty($VALID_PASS) || $USER !== 'admin' || $PASS !== $VALID_PASS) {
http_response_code(401);
header('WWW-Authenticate: Basic realm="Proxy de Migração"');
die('Unauthorized access: invalid or missing credentials.');
}
}
// 2) Robust Session or IP-based Rate Limiting (APCu with session fallback)
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$rateKey = "proxy_rate_limit:" . md5($ip);
$limit = 100; // Max requests per hour
$window = 3600; // 1-hour window
$isLimited = false;
if (function_exists('apcu_fetch')) {
$requests = apcu_fetch($rateKey) ?: 0;
if ($requests >= $limit) {
$isLimited = true;
} else {
apcu_store($rateKey, $requests + 1, $window);
}
} else {
session_start();
$_SESSION['requests'] = $_SESSION['requests'] ?? [];
$_SESSION['requests'] = array_filter($_SESSION['requests'], function($t) use ($window) {
return $t > time() - $window;
});
if (count($_SESSION['requests']) >= $limit) {
$isLimited = true;
} else {
$_SESSION['requests'][] = time();
}
}
if ($isLimited) {
http_response_code(429);
die('Request limit exceeded. Please try again later.');
}
// 3) Validate input arguments and block SSRF
$server = $_GET['server'] ?? '';
$uri = $_GET['uri'] ?? '';
$servers = [
'andamento' => ['ip' => ANDAMENTO_IP, 'http_port' => DEFAULT_HTTP_PORT, 'https_port' => DEFAULT_HTTPS_PORT],
'fortis' => ['ip' => FORTIS_IP, 'http_port' => DEFAULT_HTTP_PORT, 'https_port' => DEFAULT_HTTPS_PORT],
];
if (!isset($servers[$server])) {
http_response_code(400);
die('Invalid target server.');
}
$normalizedUri = trim($uri);
if (strpos($normalizedUri, '://') === false) {
$normalizedUri = 'http://' . $normalizedUri;
}
if (!filter_var($normalizedUri, FILTER_VALIDATE_URL)) {
http_response_code(400);
die('Invalid URI.');
}
$parsed = parse_url($normalizedUri);
$domain = $parsed['host'] ?? '';
$path = $parsed['path'] ?? '/';
if (!empty($parsed['query'])) {
$path .= '?' . $parsed['query'];
}
// Mitigate SSRF by blocking direct IPs and loopback addresses
if (empty($domain) || filter_var($domain, FILTER_VALIDATE_IP)) {
http_response_code(400);
die('Invalid or forbidden target domain.');
}
$srv = $servers[$server];
// 4) Define response size limits
define('MAX_RESPONSE_SIZE', 10 * 1024 * 1024); // 10MB limit
function executeCurl(string $url, string $domain, array &$responseCookies, ?string &$locationHeader): array
{
$ch = curl_init();
$client_cookies = [];
foreach ($_COOKIE as $name => $value) {
$client_cookies[] = "$name=$value";
}
$cookie_string = implode('; ', $client_cookies);
$opts = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false, // Disabled to prevent redirect-based SSRF
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => 'migration-proxy/1.0',
CURLOPT_HTTPHEADER => [
'Host: ' . $domain,
'Accept: */*',
'Accept-Encoding: gzip, deflate',
'Connection: keep-alive',
'Cache-Control: no-cache',
'Pragma: no-cache',
],
CURLOPT_ENCODING => 'gzip, deflate',
CURLOPT_HEADERFUNCTION => function($ch, $headerLine) use (&$responseCookies, &$locationHeader) {
$len = strlen($headerLine);
$parts = explode(':', $headerLine, 2);
if (count($parts) === 2) {
$name = strtolower(trim($parts[0]));
$value = trim($parts[1]);
if ($name === 'set-cookie') {
$responseCookies[] = $value;
} elseif ($name === 'location') {
$locationHeader = $value;
}
}
return $len;
}
];
if (!empty($cookie_string)) {
$opts[CURLOPT_COOKIE] = $cookie_string;
}
if (getenv('APP_ENV') === 'development') {
$opts[CURLOPT_SSL_VERIFYPEER] = false;
$opts[CURLOPT_SSL_VERIFYHOST] = false;
} else {
$opts[CURLOPT_SSL_VERIFYPEER] = true;
$opts[CURLOPT_SSL_VERIFYHOST] = 2;
$caPath = '/etc/ssl/certs/ca-certificates.crt';
if (file_exists($caPath)) {
$opts[CURLOPT_CAINFO] = $caPath;
}
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$error = curl_error($ch);
$errno = curl_errno($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: 'text/html';
curl_close($ch);
return [
'body' => $response,
'error' => $error,
'errno' => $errno,
'code' => $code,
'type' => $type,
];
}
function rewriteRedirectUrl(string $location, string $server, string $domain, string $apiKey): string {
$parsedLocation = parse_url($location);
$locationHost = $parsedLocation['host'] ?? '';
if (empty($locationHost) || strcasecmp($locationHost, $domain) === 0) {
$path = $parsedLocation['path'] ?? '/';
if (!empty($parsedLocation['query'])) {
$path .= '?' . $parsedLocation['query'];
}
$newUri = $domain . $path;
$params = ['server' => $server, 'uri' => $newUri];
if (!empty($apiKey)) {
$params['key'] = $apiKey;
}
return 'proxy.php?' . http_build_query($params);
}
return $location;
}
// 1) Try HTTP first
$scheme = 'http';
$target = $scheme . '://' . $srv['ip'] . ':' . $srv['http_port'] . $path;
$responseCookies = [];
$locationHeader = null;
$result = executeCurl($target, $domain, $responseCookies, $locationHeader);
// 2) Fallback to HTTPS if network error or HTTP status >= 400
if ($result['errno'] !== 0 || $result['code'] >= 400) {
$scheme = 'https';
$target = $scheme . '://' . $srv['ip'] . ':' . $srv['https_port'] . $path;
$responseCookies = [];
$locationHeader = null;
$result = executeCurl($target, $domain, $responseCookies, $locationHeader);
}
// 5) Logging request data for auditing (exclusively locks log file)
$log_entry = [
'timestamp' => date('c'),
'client_ip' => $ip,
'server' => $server,
'uri' => $uri,
'status' => $result['code'],
'error_code' => $result['errno'],
];
@file_put_contents(
'/var/log/proxy-migration.log',
json_encode($log_entry) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
if ($result['body'] === false || $result['body'] === null || $result['errno'] !== 0) {
http_response_code(502);
header('Content-Type: text/plain; charset=utf-8');
echo "Proxy failed to fetch content\n";
echo "target: {$target}\n";
echo "curl_errno: {$result['errno']}\n";
echo "curl_error: {$result['error']}\n";
exit;
}
$response = $result['body'];
$contentType = $result['type'];
// Validate Response Size Limits
if (strlen($response) > MAX_RESPONSE_SIZE) {
http_response_code(413);
die('Response size exceeds maximum allowed limit (10MB).');
}
// Forward cookies
foreach ($responseCookies as $cookie) {
header("Set-Cookie: $cookie", false);
}
// Safe routing for redirects
if ($locationHeader && $result['code'] >= 300 && $result['code'] < 400) {
$redirectUrl = rewriteRedirectUrl($locationHeader, $server, $domain, $API_KEY);
header("Location: $redirectUrl", true, $result['code']);
exit;
}
// Plain text files bypass base tag mutation (e.g. teste.txt)
if (stripos($contentType, 'text/plain') !== false) {
header('Content-Type: text/plain; charset=utf-8');
echo $response;
exit;
}
// Safe base tag injection using DOMDocument to avoid uppercase/space/attribute parser issues
$dom = new DOMDocument();
$libxml_disable = false;
if (\PHP_VERSION_ID < 80000 && function_exists('libxml_disable_entity_loader')) {
$libxml_disable = libxml_disable_entity_loader(true);
}
$htmlToLoad = mb_convert_encoding($response, 'HTML-ENTITIES', 'UTF-8');
@$dom->loadHTML($htmlToLoad, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING);
if ($libxml_disable) {
libxml_disable_entity_loader(false);
}
$base = $scheme . '://' . $srv['ip'] . '/';
$head = $dom->getElementsByTagName('head')->item(0);
if ($head) {
$baseElement = $dom->createElement('base');
$baseElement->setAttribute('href', $base);
$head->insertBefore($baseElement, $head->firstChild);
}
$response = $dom->saveHTML();
header('Content-Type: text/html; charset=utf-8');
echo $response;
4) Debugging and field validation commands#
Before assuming a proxy bug, always validate the backend VirtualHosts and the HTTP/HTTPS routing.
4.1 auditing Apache virtualhost mapping#
# List all active VirtualHost configurations
apachectl -S
# Verify syntax integrity of configuration files
apachectl -t
4.2 validating the host header manually#
# Test the legacy node (andamento)
curl -sv "http://10.10.10.11/" -H "Host: domain.com" -o /dev/null
# Test the target node (fortis)
curl -sv "http://10.10.10.12/" -H "Host: domain.com" -o /dev/null
4.3 checking the sentinel file created only on the new server#
curl -sv "http://10.10.10.12/teste.txt" -H "Host: domain.com"
If teste.txt does not show up in the proxy panel for the new host, the issue is not public DNS: it is vhost mapping, docroot, or a local directory rule on the backend.
5) Real-world errors i caught and how i fixed them#
5.1 "webserver is functioning normally"#
- Symptom: New server returned the default Apache placeholder page.
- Root Cause: The request arrived via IP, but Apache could not find a matching
ServerNameorServerAliasdirective for the domain supplied in theHostheader. - Fix:
- Adjust the vhost block with the correct
ServerNameand alias definitions; - Ensure the
DocumentRootpoints to the correct absolute directory; - Reload Apache and validate with
curl -H "Host: ...".
5.2 broken assets (css/js/images)#
- Symptom: HTML page loaded, but stylesheet and script links pointed to the public domain and not to the staging backend.
- Fix:
- Inject
<base href="http://TARGET_IP/">inside the<head>section; - Re-validate paths with relative patterns.
5.3 plain text with incorrect behavior#
- Symptom: The
/teste.txtendpoint was rendered as HTML. - Fix:
- Detect
Content-Type: text/plainprogrammatically; - Return the raw response directly, bypassing HTML tag modifications.
6) Secure proxy deployment and server configuration#
To put the migration comparator to work safely, isolate it from your main public directory, lock down permissions, and restrict access at the web server layer.
6.1 directory provisioning and permissions hardening#
Create a dedicated server directory outside the public web root and change its ownership to the webserver runner (e.g. www-data on Debian-based systems):
# Create the secure directory path
sudo mkdir -p /var/www/proxy-migration
sudo chown www-data:www-data /var/www/proxy-migration
# Copy the proxy files into place
sudo cp config.php dev.php proxy.php /var/www/proxy-migration/
# Enforce strict read-only permissions (640) for files
sudo chmod 640 /var/www/proxy-migration/*.php
sudo chown www-data:www-data /var/www/proxy-migration/*.php
6.2 Apache virtualhost configuration for the proxy#
Create a dedicated virtual host configuration file (e.g. /etc/apache2/sites-available/proxy-migration.conf). This setup limits exposure to internal networks/VPNs and sets up Basic Auth constraints:
<VirtualHost *:80>
ServerName proxy.empresa.com
DocumentRoot /var/www/proxy-migration
<Directory /var/www/proxy-migration>
AllowOverride None
# Restrict entry exclusively to trusted development subnets
Require ip 10.0.0.0/8 192.168.0.0/16
</Directory>
# Enforce basic auth verification
<Directory /var/www/proxy-migration>
AuthType Basic
AuthName "Proxy de Migracao"
AuthUserFile /etc/apache2/.htpasswd
Require valid-user
</Directory>
</VirtualHost>
Commands to enable the site and provision credentials:
# Provision a password file for the admin user
sudo htpasswd -c /etc/apache2/.htpasswd admin
# Enable the VirtualHost site and reload Apache
sudo a2ensite proxy-migration.conf
sudo apachectl -t && sudo systemctl reload apache2
7) Hardening and security best practices#
An open migration proxy exposes internal routing pathways. Implement the following strategies to prevent resource exploitation:
- SSL/TLS Peer Validation (MITM Protection): Bypassing peer verification (
CURLOPT_SSL_VERIFYPEER => false) exposes transactions to Man-in-the-Middle attacks. Enforce full certificate checks in production environments, using conditional configurations to bypass validation exclusively when the environment variable matches development (APP_ENV === 'development'). - Signature Removal: Remove
X-Powered-Byheaders and disable error output to the browser screen (display_errors = 0) to prevent fingerprinting. - Input Sanitization: Apply strict regular expressions to validate incoming URI parameters.
- Abuse Prevention: Set memory bounds on proxy responses (
MAX_RESPONSE_SIZE) and implement basic rate limiting using PHP sessions.
8) Verification and proxy testing instructions#
Validate your proxy setup and backend routing by executing the following tests:
# 1. Test basic connectivity and verify authentication is required
curl -I http://proxy.empresa.com/dev.php
# 2. Test development API key authentication
curl -H "X-API-Key: minha-chave-secreta" http://proxy.empresa.com/dev.php?uri=domain.com/
# 3. Test HTTP Basic Auth authentication
curl -u admin:senha http://proxy.empresa.com/dev.php?uri=domain.com/
# 4. Monitor operational and audit logs
tail -f /var/log/proxy-migration.log
9) Checklist: migration proxy security audit#
Ensure the following checkpoints are resolved before distributing the tool to the testing/QA team:
- [ ] Authentication: Access is constrained by Basic Auth or an active API Key check.
- [ ] Secrets: API keys and passwords are loaded via env vars, never hardcoded.
- [ ] Networking: Requests are limited to trusted IP subnets via firewall or Apache rules.
- [ ] Cryptography: Transport is restricted to HTTPS with SSL validation active (
CURLOPT_SSL_VERIFYPEER => true). - [ ] Rate Limiting: Session rate-limiting is configured to block automated scraping/DOS.
- [ ] Input Sanitization: Input
urimatches a strict domain regex pattern. - [ ] Auditing: Request logging compiles metrics to a secure directory (
/var/log/proxy-migration.log). - [ ] Permissions: PHP files are restricted to
640and owned by the webserver user (www-data).
10) Validation runbook before DNS cutover#
- Create a sentinel endpoint only on the new host (
/teste.txt); - Validate both legacy and new layouts side-by-side using the comparison dashboard;
- Validate user authentication, session storage, and logout flows (repassing cookies);
- Validate critical business paths (forms, contact, checkouts);
- Validate file upload and download behaviors;
- Validate all 301/302 redirects and custom 404 pages;
- Validate resource load states and external JS/CSS dependencies;
- Document discrepancies and fix them directly on the target host;
- Iterate until total functional parity is reached;
- Only then approve the final DNS cutover.
11) Operational result#
With the smart proxy structured under proper security controls, staging validation ceased to rely on manual workstation configuration and became a centralized, reproducible, and auditable process.
The DNS cutover is executed with technical proof of functional parity, reducing post-migration incidents to zero.
Was this article helpful?
Leave a quick reaction to help prioritize future technical guides:
This post is licensed under CC BY-NC.



Comments
Join the discussion below.
0 comments