Skip to content

Commit 7ab1a66

Browse files
perf(security): make image proxy signature stable to enable client caching (#5835)
1 parent d57deb1 commit 7ab1a66

2 files changed

Lines changed: 65 additions & 35 deletions

File tree

app/utils/security.py

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import ipaddress
44
import socket
55
import threading
6-
import time
76
from hashlib import sha256
87
from pathlib import Path
98
from typing import Dict, Iterable, List, Optional, Set, Union
@@ -57,7 +56,6 @@ def _resolve_addrinfo_to_ips(
5756

5857
class SecurityUtils:
5958
_SIGNED_URL_PURPOSE = "image-proxy"
60-
_SIGNED_URL_EXPIRE_SECONDS = 86400
6159

6260
@staticmethod
6361
def is_safe_path(base_path: Path, user_path: Path,
@@ -377,23 +375,26 @@ async def _is_allowed_private_hostname_async(
377375
)
378376

379377
@staticmethod
380-
def _url_signature_payload(url: str, expires_at: int, purpose: str) -> bytes:
378+
def _url_signature_payload(url: str, purpose: str) -> bytes:
381379
"""
382380
构造 URL 签名载荷。
383381
384-
签名覆盖用途、过期时间和完整 URL,确保同一个签名不能挪用到其它
385-
内网地址或其它代理用途。
382+
签名覆盖用途与完整 URL,确保同一个签名不能挪用到其它代理用途或其它 URL。
386383
"""
387-
return f"{purpose}\n{expires_at}\n{url}".encode("utf-8")
384+
return f"{purpose}\n{url}".encode("utf-8")
388385

389386
@staticmethod
390-
def _sign_url_payload(url: str, expires_at: int, purpose: str) -> str:
387+
def _sign_url_payload(url: str, purpose: str) -> str:
391388
"""
392389
使用 RESOURCE_SECRET_KEY 对 URL 签名载荷生成 HMAC。
390+
391+
相同 `(url, purpose, RESOURCE_SECRET_KEY)` 组合在进程生命周期内输出
392+
完全一致;签名的失效边界绑定在 `RESOURCE_SECRET_KEY` 上,进程重启
393+
或显式轮换密钥时所有旧签名一起作废。
393394
"""
394395
return hmac.new(
395396
settings.RESOURCE_SECRET_KEY.encode("utf-8"),
396-
SecurityUtils._url_signature_payload(url, expires_at, purpose),
397+
SecurityUtils._url_signature_payload(url, purpose),
397398
sha256,
398399
).hexdigest()
399400

@@ -413,26 +414,29 @@ def strip_url_signature(url: str) -> str:
413414
@staticmethod
414415
def sign_url(
415416
url: str,
416-
expires_in: int = _SIGNED_URL_EXPIRE_SECONDS,
417417
purpose: str = _SIGNED_URL_PURPOSE,
418418
) -> str:
419419
"""
420-
给服务端返回的资源 URL 添加临时签名。
420+
给服务端返回的资源 URL 添加稳定签名。
421+
422+
签名作为 `/system/img` 代理放行私网图片 URL 的能力凭证:图片代理默认
423+
拒绝解析到非公网地址的 URL(防 SSRF),合法媒体服务器 URL 必须由后端
424+
预先签名后才能跳过该限制。
421425
422-
该签名用于允许 `/system/img` 代理访问服务端已经确认过的私网图片 URL,
423-
避免代理端点重新依赖媒体服务器的具体路径规则。
426+
签名为 `(url, purpose, RESOURCE_SECRET_KEY)` 的确定性 HMAC,**不带
427+
过期时间**:相同 URL 多次调用结果完全一致,让浏览器与 Service Worker
428+
的缓存能稳定命中;失效边界由 `RESOURCE_SECRET_KEY` 控制——进程重启
429+
自动重生成、或者运维显式轮换后所有历史签名一起作废。
424430
"""
425431
if not url:
426432
return url
427433
parsed_url = urlparse(url)
428434
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
429435
return url
430436
clean_url = SecurityUtils.strip_url_signature(url)
431-
expires_at = int(time.time() + expires_in)
432-
signature = SecurityUtils._sign_url_payload(clean_url, expires_at, purpose)
437+
signature = SecurityUtils._sign_url_payload(clean_url, purpose)
433438
fragment = urlencode(
434439
{
435-
"mp_exp": str(expires_at),
436440
"mp_sig": signature,
437441
"mp_purpose": purpose,
438442
}
@@ -446,29 +450,23 @@ def verify_signed_url(
446450
) -> Optional[str]:
447451
"""
448452
验证 URL fragment 中的代理签名,成功时返回去签名后的真实 URL。
453+
454+
签名只校验 `(url, purpose, RESOURCE_SECRET_KEY)`,密钥轮换/进程重启
455+
后旧签名自动失效。
449456
"""
450457
if not url:
451458
return None
452459
parsed_url = urlparse(url)
453460
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
454461
return None
455462
fragment_params = dict(parse_qsl(parsed_url.fragment, keep_blank_values=True))
456-
expires_at = fragment_params.get("mp_exp")
457463
signature = fragment_params.get("mp_sig")
458464
signed_purpose = fragment_params.get("mp_purpose")
459-
if not expires_at or not signature or signed_purpose != purpose:
460-
return None
461-
try:
462-
expires_at_int = int(expires_at)
463-
except ValueError:
464-
return None
465-
if expires_at_int < int(time.time()):
465+
if not signature or signed_purpose != purpose:
466466
return None
467467

468468
clean_url = SecurityUtils.strip_url_signature(url)
469-
expected_signature = SecurityUtils._sign_url_payload(
470-
clean_url, expires_at_int, purpose
471-
)
469+
expected_signature = SecurityUtils._sign_url_payload(clean_url, purpose)
472470
if not hmac.compare_digest(signature, expected_signature):
473471
return None
474472
return clean_url

tests/test_security_utils.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ def test_signed_url_roundtrip_returns_clean_url(self):
2727

2828
signed_url = SecurityUtils.sign_url(url)
2929

30-
self.assertIn("#mp_exp=", signed_url)
30+
self.assertIn("#mp_sig=", signed_url)
31+
self.assertIn("mp_purpose=image-proxy", signed_url)
3132
self.assertEqual(SecurityUtils.verify_signed_url(signed_url), url)
3233
self.assertEqual(SecurityUtils.strip_url_signature(signed_url), url)
3334

@@ -45,19 +46,50 @@ def test_signed_url_rejects_tampered_url(self):
4546

4647
self.assertIsNone(SecurityUtils.verify_signed_url(tampered_url))
4748

48-
def test_signed_url_rejects_expired_signature(self):
49+
def test_signed_url_is_deterministic_for_same_inputs(self):
4950
"""
50-
已过期签名不能继续放行私网图片代理请求。
51+
相同 URL 与 RESOURCE_SECRET_KEY 多次签名结果必须完全一致,
52+
保证浏览器 / Service Worker 缓存能稳定命中。
5153
"""
52-
with patch("app.utils.security.time.time", return_value=1000):
53-
signed_url = SecurityUtils.sign_url(
54-
"http://192.168.1.50:8096/Items/abc/Images/Primary",
55-
expires_in=10,
56-
)
54+
url = "http://192.168.1.50:8096/Items/abc/Images/Primary"
55+
56+
first = SecurityUtils.sign_url(url)
57+
second = SecurityUtils.sign_url(url)
58+
59+
self.assertEqual(first, second)
60+
self.assertEqual(SecurityUtils.verify_signed_url(first), url)
61+
62+
def test_signed_url_invalidated_after_secret_rotation(self):
63+
"""
64+
`RESOURCE_SECRET_KEY` 变更(进程重启或运维显式轮换)后旧签名必须作废,
65+
作为签名长期有效模型的失效兜底。
66+
"""
67+
url = "http://192.168.1.50:8096/Items/abc/Images/Primary"
68+
69+
with patch(
70+
"app.utils.security.settings.RESOURCE_SECRET_KEY",
71+
"old-secret-value-aaaaaaaaaaaaaaaaaaaaaaaa",
72+
):
73+
signed_url = SecurityUtils.sign_url(url)
74+
self.assertEqual(SecurityUtils.verify_signed_url(signed_url), url)
5775

58-
with patch("app.utils.security.time.time", return_value=1011):
76+
with patch(
77+
"app.utils.security.settings.RESOURCE_SECRET_KEY",
78+
"new-secret-value-bbbbbbbbbbbbbbbbbbbbbbbb",
79+
):
5980
self.assertIsNone(SecurityUtils.verify_signed_url(signed_url))
6081

82+
def test_signed_url_rejects_other_purpose(self):
83+
"""
84+
签名绑定 `purpose`,挪用到其它签名用途必须被拒绝。
85+
"""
86+
url = "http://192.168.1.50:8096/Items/abc/Images/Primary"
87+
signed_url = SecurityUtils.sign_url(url)
88+
89+
self.assertIsNone(
90+
SecurityUtils.verify_signed_url(signed_url, purpose="other-purpose")
91+
)
92+
6193
def test_is_safe_url_keeps_default_allowlist_behavior(self):
6294
"""
6395
默认 URL 校验保持历史 allowlist 行为,避免影响非代理调用方。

0 commit comments

Comments
 (0)