mirror of
https://github.com/Evil0ctal/Douyin_TikTok_Download_API.git
synced 2026-10-22 08:27:07 +08:00
31adaadb05
Every endpoint now takes ?proxy=, and every instance refuses it until an operator says otherwise. The two kinds of proxy in this codebase carry opposite trust, and the difference is the whole feature. An operator-configured proxy is a row someone with admin access typed, and admin.proxy_urls deliberately lets it point at loopback - a local SOCKS listener is an ordinary egress for a self-hosted install. A caller-supplied one is a string from whoever holds an API key, and "dial this address for me" is request forgery in its plainest form: an instance on a public server sits inside a network its callers cannot otherwise reach, so an unchecked value turns the deployment into a probe for its operator's own intranet - metadata on 169.254.169.254, a database on 10.x, an admin panel on 127.0.0.1. security.request_proxy is deny / public / any, defaulting to deny, because the safe failure of this feature is not having it. An unrecognised value is treated as deny rather than as a permissive default: a typo in a configuration file must not be the thing that opens a network. Disabled REFUSES rather than ignores. Silently dropping the parameter would send the request from the instance's own address while the caller believed it went through theirs, and they would only learn otherwise from whoever received it. Verified against the running instance: deny answers INVALID_PARAM with reason=request_proxy_disabled; under public, 127.0.0.1, 169.254.169.254 and 10.0.0.5 are all refused host_not_public while a public host is queued. - request_proxy: the validator, with 32 adversarial tests written from the attacker's side - metadata endpoint, decimal and hex address spellings, IPv4-mapped IPv6, trailing dots, scheme confusion, and a check that a rejected value with credentials in it is never echoed back. - fetch: FetchContext carries it, and applying it logs that an identity was sent from an exit it was not minted behind. That is a real cost, not a free option - a jar that disagrees with its exit is the incoherence docs 02 and 04 avoid - and it is offered because a deployment with no proxies has no other way to give a caller an egress. - operations: the proxy joins the stored params, so two callers asking for the same post through different proxies are not coalesced onto one task. - registry: "proxy" is an envelope parameter, never forwarded upstream - sending it on would leak the proxy, credentials and all, to the platform.
147 lines
5.4 KiB
Python
147 lines
5.4 KiB
Python
"""The caller-supplied egress, which is request forgery if it is got wrong.
|
|
|
|
An operator-configured proxy may point at loopback; a caller-supplied one may
|
|
not, and the two are separate code paths for exactly that reason. These tests
|
|
are written from the attacker's side: every case is a way to name an address
|
|
inside the instance's own network and have it dialled.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from dtk.api.request_proxy import MAX_LENGTH, RequestProxyMode, normalize
|
|
from dtk.core.errors import InvalidParam
|
|
|
|
PUBLIC = RequestProxyMode.PUBLIC
|
|
|
|
|
|
def _reason(excinfo: pytest.ExceptionInfo[Exception]) -> str:
|
|
"""The stable machine-readable half of the refusal."""
|
|
return str(excinfo.value.details.get("reason")) # type: ignore[attr-defined]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The default is off
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_the_parameter_is_refused_by_default() -> None:
|
|
with pytest.raises(InvalidParam) as excinfo:
|
|
normalize("http://proxy.example:8080", mode=RequestProxyMode.DENY)
|
|
assert _reason(excinfo) == "request_proxy_disabled"
|
|
|
|
|
|
def test_a_disabled_feature_refuses_rather_than_ignores() -> None:
|
|
"""Silently dropping it is worse than an error.
|
|
|
|
The request would leave from the instance's own address while the caller
|
|
believed it went through theirs, and they would only learn otherwise from
|
|
whoever received it.
|
|
"""
|
|
with pytest.raises(InvalidParam):
|
|
normalize("http://proxy.example:8080", mode=RequestProxyMode.DENY)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", list(RequestProxyMode))
|
|
def test_no_proxy_is_always_fine(mode: RequestProxyMode) -> None:
|
|
assert normalize(None, mode=mode) is None
|
|
assert normalize(" ", mode=mode) is None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Reaching the instance's own network
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
[
|
|
"http://127.0.0.1:8080",
|
|
"http://localhost:8080",
|
|
"http://[::1]:8080",
|
|
"http://10.0.0.5:3128",
|
|
"http://192.168.1.1:3128",
|
|
"http://172.16.0.1:3128",
|
|
# Cloud instance metadata, the highest-value target of the lot.
|
|
"http://169.254.169.254:80",
|
|
# A bare label is an intranet name however innocent it looks.
|
|
"http://intranet:3128",
|
|
# Spellings that exist only to get past a string comparison.
|
|
"http://2130706433:8080",
|
|
"http://0x7f000001:8080",
|
|
"http://127.0.0.1.:8080",
|
|
"http://[::ffff:127.0.0.1]:8080",
|
|
],
|
|
)
|
|
def test_public_mode_refuses_everything_inside_the_perimeter(target: str) -> None:
|
|
with pytest.raises(InvalidParam) as excinfo:
|
|
normalize(target, mode=PUBLIC)
|
|
assert _reason(excinfo) == "host_not_public"
|
|
|
|
|
|
def test_any_mode_is_the_operators_explicit_choice() -> None:
|
|
"""The escape hatch exists, and it is spelled out rather than implied."""
|
|
assert normalize("http://127.0.0.1:8080", mode=RequestProxyMode.ANY) == "http://127.0.0.1:8080"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Shape
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_a_public_proxy_is_returned_unchanged() -> None:
|
|
assert normalize("http://proxy.example.com:8080", mode=PUBLIC) == (
|
|
"http://proxy.example.com:8080"
|
|
)
|
|
|
|
|
|
def test_credentials_survive_because_providers_require_them() -> None:
|
|
url = "http://user:secret@proxy.example.com:8080"
|
|
assert normalize(url, mode=PUBLIC) == url
|
|
|
|
|
|
@pytest.mark.parametrize("scheme", ["http", "https", "socks5", "socks5h"])
|
|
def test_every_dialable_scheme_is_accepted(scheme: str) -> None:
|
|
assert normalize(f"{scheme}://proxy.example.com:8080", mode=PUBLIC)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"target",
|
|
["file:///etc/passwd", "gopher://proxy.example.com:70", "ftp://proxy.example.com:21"],
|
|
)
|
|
def test_other_schemes_are_refused(target: str) -> None:
|
|
with pytest.raises(InvalidParam) as excinfo:
|
|
normalize(target, mode=PUBLIC)
|
|
assert _reason(excinfo) == "scheme_not_supported"
|
|
|
|
|
|
def test_a_bare_host_and_port_is_refused_rather_than_guessed() -> None:
|
|
"""Assuming a scheme here would mean assuming one for `localhost:8080` too."""
|
|
with pytest.raises(InvalidParam) as excinfo:
|
|
normalize("proxy.example.com:8080", mode=PUBLIC)
|
|
assert _reason(excinfo) == "scheme_missing"
|
|
|
|
|
|
def test_an_absurd_length_is_refused_before_parsing() -> None:
|
|
with pytest.raises(InvalidParam) as excinfo:
|
|
normalize("http://" + "a" * MAX_LENGTH + ".example.com:8080", mode=PUBLIC)
|
|
assert _reason(excinfo) == "too_long"
|
|
|
|
|
|
@pytest.mark.parametrize("target", ["http://proxy.example.com:0", "http://proxy.example.com:99999"])
|
|
def test_a_port_outside_the_range_is_refused(target: str) -> None:
|
|
with pytest.raises(InvalidParam) as excinfo:
|
|
normalize(target, mode=PUBLIC)
|
|
assert _reason(excinfo) == "port_invalid"
|
|
|
|
|
|
def test_the_rejected_value_is_never_echoed_back() -> None:
|
|
"""A rejection is when someone is most likely to have pasted a real one."""
|
|
secret = "http://user:hunter2@127.0.0.1:8080"
|
|
with pytest.raises(InvalidParam) as excinfo:
|
|
normalize(secret, mode=PUBLIC)
|
|
rendered = str(excinfo.value) + repr(excinfo.value.details)
|
|
assert "hunter2" not in rendered
|
|
assert secret not in rendered
|