前言
在開發短網址服務時,我們發現了一個有趣的現象:某些包含中文的 URL 可以正常處理,但有些卻無法通過驗證。經過深入研究,我們發現這涉及到國際化域名 (IDN)、URL 編碼、以及 PHP 函數的特定行為。本文將完整記錄我們的問題發現、分析與解決過程。
問題發現
我們在測試時發現以下奇怪的現象:
- ✅
https://stancode.tw/stancode_%E5%A4%A7%E5%AE%89%E5%BA%97/- 已編碼的中文路徑可以處理 - ❌
https://stancode.tw/stancode_大安店- 未編碼的中文路徑無法處理 - ❌
https://例子.台灣- 中文域名完全無法處理
這個問題引發了我們對 URL 處理機制的深入研究。
根本原因分析
1. PHP filter_var() 的限制
我們的原始 URL 驗證代碼使用了 filter_var($string, FILTER_VALIDATE_URL),但這個函數對於包含非 ASCII 字元的 URL 會直接驗證失敗。
public static function is_https_url($string) {
if (!filter_var($string, FILTER_VALIDATE_URL)) {
return false; // 中文 URL 在這裡就被拒絕了
}
// ...
}
2. 國際化域名 (IDN) 的挑戰
中文域名如 例子.台灣 需要轉換為 Punycode 格式才能在網路上使用:
例子.台灣 → xn--fsqu00a.xn--kpry57d
測試.中國 → xn--g6w251d.xn--fiqz9s
這個轉換需要使用 PHP 的 intl 擴展中的 idn_to_ascii() 函數。
3. parse_url() 對中文域名的破壞性
更嚴重的是,PHP 的 parse_url() 函數在處理未編碼的中文域名時會產生亂碼:
$url = 'https://例子.台灣';
$parsed = parse_url($url);
print_r($parsed);
// 輸出:
// Array ( [scheme] => https [host] => �_�__.�_��_� )
原因: parse_url() 期望接收的是已經編碼過的 URL。
解決方案設計
整體架構
我們設計了一個三層驗證機制:
原始 URL
↓
1. encodeUrl() - 智能編碼處理
↓
2. filter_var() - PHP 標準驗證
↓
3. isValidHost() - 自定義域名驗證
↓
驗證通過 ✓
核心策略
- 先編碼再驗證: 在驗證前先將 URL 標準化
- 域名優先處理: 使用正則提取域名,避免 parse_url() 的問題
- IDN 轉換: 將中文域名轉換為 Punycode
- 路徑編碼: 對 URL 路徑中的非 ASCII 字元進行 URL 編碼
- Punycode 支援: 更新正則表達式以支援 xn-- 開頭的域名
實作細節
1. 安裝 intl 擴展
Docker 環境 (Dockerfile):
FROM php:8.4-fpm
RUN apt-get update && apt-get install -y \
libicu-dev \
&& docker-php-ext-install intl \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
Ubuntu/Debian:
sudo apt install php8.4-intl
sudo systemctl restart php8.4-fpm
2. 實作智能 URL 編碼函數
/**
* 對 URL 中的非 ASCII 字元進行編碼
*/
private static function encodeUrl($url) {
// 使用正則提取 URL 各部分,避免 parse_url 對中文域名的誤判
$pattern = '/^(https?):\/\/([^\/\?#:]+)(?::(\d+))?(\/[^\?#]*)?(\?[^#]*)?(#.*)?$/u';
if (preg_match($pattern, $url, $matches)) {
$scheme = $matches[1];
$host = $matches[2];
$port = $matches[3] ?? '';
$path = $matches[4] ?? '';
$query = $matches[5] ?? '';
$fragment = $matches[6] ?? '';
// 對 host 進行 IDN 編碼
if (function_exists('idn_to_ascii')) {
$ascii_host = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
if ($ascii_host) {
$host = $ascii_host;
}
}
// 對 path 進行編碼
if ($path) {
$path_parts = explode('/', $path);
$encoded_parts = array_map(function($part) {
// 先解碼再編碼,避免重複編碼
return rawurlencode(rawurldecode($part));
}, $path_parts);
$path = implode('/', $encoded_parts);
}
// 重組 URL
$encoded = $scheme . '://' . $host;
if ($port) {
$encoded .= ':' . $port;
}
$encoded .= $path . $query . $fragment;
return $encoded;
}
return $url;
}
關鍵設計決策:
- 使用正則而非 parse_url(): 避免中文域名被破壞
- 先解碼再編碼:
rawurlencode(rawurldecode($part))避免重複編碼 - 條件式 IDN 轉換: 檢查 idn_to_ascii 函數是否存在
- 保留原始結構: 保持 query 和 fragment 不變
3. 更新 URL 驗證函數
public static function is_https_url($string) {
// 1. 基本長度檢查 (防止 DoS)
if (strlen($string) > 2048) {
return false;
}
// 2. 對 URL 進行編碼處理
$encoded_url = self::encodeUrl($string);
if (!filter_var($encoded_url, FILTER_VALIDATE_URL)) {
return false;
}
$parsed_url = parse_url($encoded_url);
// 3. 檢查必要組件
if (!$parsed_url || !isset($parsed_url['scheme']) || !isset($parsed_url['host'])) {
return false;
}
// 4. 只允許 http/https 協議 (防止 JavaScript 注入)
$scheme = strtolower($parsed_url['scheme']);
if ($scheme !== 'https' && $scheme !== 'http') {
return false;
}
// 5. 防止開放重定向攻擊
if (strpos($parsed_url['host'], '@') !== false) {
return false;
}
// 6. 驗證 host 是否為有效的域名或 IP
if (!self::isValidHost($parsed_url['host'])) {
return false;
}
return true;
}
4. 實作域名驗證函數
private static function isValidHost($host) {
// 檢查是否為有效 IP (IPv4/IPv6)
if (filter_var($host, FILTER_VALIDATE_IP)) {
return true;
}
// 檢查標準 ASCII 域名 (包含 Punycode)
if (preg_match('/^([a-z0-9]+([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]+([a-z0-9-]*[a-z0-9])?$/i', $host)) {
return true;
}
// 支援包含非 ASCII 字元的域名
if (preg_match('/^[^\s.]+(\.[^\s.]+)+$/u', $host) &&
!preg_match('/^\.|\.$|', $host) &&
strlen($host) <= 253) {
if (function_exists('idn_to_ascii')) {
$ascii_host = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
if ($ascii_host && preg_match('/^([a-z0-9]+([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]+([a-z0-9-]*[a-z0-9])?$/i', $ascii_host)) {
return true;
}
return false;
}
return true; // 沒有 intl 擴展時使用寬鬆驗證
}
return false;
}
常見陷阱與解決方法
陷阱 1: parse_url() 破壞中文域名
問題: 直接使用 parse_url('https://例子.台灣') 會導致域名變成亂碼。
解決方法: 使用正則表達式先提取域名,再進行 IDN 轉換。
陷阱 2: Punycode 域名無法通過正則驗證
問題: Punycode 的 xn--fsqu00a 格式包含連字符,可能被誤判為無效。
解決方法: 更新正則允許中間包含連字符: [a-z0-9]+([a-z0-9-]*[a-z0-9])?
陷阱 3: 重複編碼問題
問題: 已編碼的 URL 被再次編碼,導致 %20 變成 %2520。
解決方法: 先解碼再編碼: rawurlencode(rawurldecode($part))
陷阱 4: Docker 環境缺少 intl 擴展
診斷方法: 創建測試頁面檢查 intl 擴展是否可用。
echo extension_loaded('intl') ? 'YES ✓' : 'NO ✗';
解決方法: 在 Dockerfile 中安裝 libicu-dev 和 intl 擴展。
陷阱 5: CLI 與 Web 環境不一致
問題: CLI 測試通過,但網頁請求失敗。
原因: CLI 和 PHP-FPM 使用不同的 php.ini 配置。
解決方法: 確保兩個環境都安裝了相同的擴展。
測試與驗證
單元測試
$test_urls = [
'https://例子.台灣',
'https://例子.台灣/測試路徑',
'https://stancode.tw/stancode_大安店',
'https://google.com',
'https://xn--fsqu00a.xn--kpry57d',
];
foreach ($test_urls as $url) {
$result = Helper::is_https_url($url);
echo ($result ? '[✓] ' : '[✗] ') . $url . "\n";
}
預期輸出: 所有 URL 都應顯示 [✓]
IDN 轉換測試
php -r "echo idn_to_ascii('例子.台灣', IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);"
# 輸出: xn--fsqu00a.xn--kpry57d
總結
關鍵技術要點
- 國際化域名 (IDN): 使用 idn_to_ascii() 轉換為 Punycode
- URL 編碼: 使用 rawurlencode() 處理路徑中的非 ASCII 字元
- 避免 parse_url() 陷阱: 使用正則表達式先提取域名
- 防止重複編碼: 先 rawurldecode() 再 rawurlencode()
- 環境一致性: 確保 CLI、Web、Docker 都安裝 intl 擴展
支援的 URL 格式
- ✅
https://例子.台灣 - ✅
https://例子.台灣/測試/路徑 - ✅
https://example.com/中文路徑 - ✅
https://example.com/path?query=中文參數 - ✅
https://xn--fsqu00a.xn--kpry57d(Punycode) - ✅
https://192.168.1.1/path - ✅
https://[2001:db8::1]/path(IPv6)
安全性提升
除了支援中文 URL,我們的實作還加入了:
- ✅ URL 長度限制 (防止 DoS)
- ✅ 協議白名單 (防止 JavaScript 注入)
- ✅ 開放重定向防護 (拒絕 @ 符號)
- ✅ 嚴格的域名驗證 (防止無效域名)
參考資料
- RFC 3490 - Internationalizing Domain Names in Applications (IDN)
- PHP intl Extension
- Punycode - Wikipedia
- RFC 3986 - Uniform Resource Identifier (URI)
本文記錄了我們在實作短網址服務國際化 URL 支援時的完整過程,希望能幫助其他開發者避免相同的陷阱。
