Files
maigret/tests/test_activation.py
SoxojandGitHub d7f759a239 Keep runtime activation tokens out of the sites database (#2973)
* Keep runtime activation tokens out of the sites database

* Document the activation token cache
2026-08-15 12:43:30 +02:00

558 lines
18 KiB
Python

"""Maigret activation test functions"""
import inspect
import json
import os
import yarl
import aiohttp
import pytest
from unittest.mock import Mock
from tests.conftest import LOCAL_SERVER_PORT
from maigret import activation as activation_cache
from maigret.activation import (
ParsingActivator,
import_aiohttp_cookies,
load_activation_cache,
save_activation_cache,
)
from maigret.sites import MaigretDatabase, MaigretSite
COOKIES_TXT = """# HTTP Cookie File downloaded with cookies.txt by Genuinous @genuinous
# This file can be used by wget, curl, aria2c and other standard compliant tools.
# Usage Examples:
# 1) wget -x --load-cookies cookies.txt "https://xss.is/search/"
# 2) curl --cookie cookies.txt "https://xss.is/search/"
# 3) aria2c --load-cookies cookies.txt "https://xss.is/search/"
#
xss.is FALSE / TRUE 0 xf_csrf test
xss.is FALSE / TRUE 1642709308 xf_user tset
.xss.is TRUE / FALSE 0 muchacho_cache test
.xss.is TRUE / FALSE 1924905600 132_evc test
localhost FALSE / FALSE 0 a b
"""
@pytest.mark.skip("captcha")
@pytest.mark.slow
@pytest.mark.asyncio
async def test_vimeo_activation(default_db):
vimeo_site = default_db.sites_dict['Vimeo']
token1 = vimeo_site.headers['Authorization']
await ParsingActivator.vimeo(vimeo_site, Mock())
token2 = vimeo_site.headers['Authorization']
assert token1 != token2
@pytest.mark.slow
@pytest.mark.asyncio
async def test_import_aiohttp_cookies(cookie_test_server):
cookies_filename = 'cookies_test.txt'
with open(cookies_filename, 'w') as f:
f.write(COOKIES_TXT)
cookie_jar = import_aiohttp_cookies(cookies_filename)
url = f'http://localhost:{LOCAL_SERVER_PORT}/cookies'
cookies = cookie_jar.filter_cookies(yarl.URL(url))
assert cookies['a'].value == 'b'
async with aiohttp.ClientSession(cookie_jar=cookie_jar) as session:
async with session.get(url=url) as response:
result = await response.json()
print(f"Server response: {result}")
assert result == {'cookies': {'a': 'b'}}
# ---- OnlyFans signing tests (pure-compute, no network) ----
class _FakeSite:
"""Minimal stand-in for MaigretSite with the attributes onlyfans() touches."""
def __init__(self, headers=None, activation=None):
self.headers = headers or {}
self.activation = activation or {
"static_param": "jLM8LXHU1CGcuCzPMNwWX9osCScVuP4D",
"checksum_indexes": [28, 3, 16, 32, 25, 24, 23, 0, 26],
"checksum_constant": -180,
"format": "57203:{}:{:x}:69cfa6d8",
"url": "https://onlyfans.com/api2/v2/init",
}
class _FakeResponse:
def __init__(self, cookies=None, json_data=None):
self.cookies = {
key: type("Cookie", (), {"value": value})()
for key, value in (cookies or {}).items()
}
self._json_data = json_data or {}
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def json(self, content_type=None):
return self._json_data
@pytest.mark.parametrize("method", ["twitter", "vimeo", "onlyfans", "weibo", "proton"])
def test_activation_methods_are_coroutines(method):
assert inspect.iscoroutinefunction(getattr(ParsingActivator, method))
@pytest.mark.asyncio
async def test_vimeo_activation_uses_aiohttp(monkeypatch):
site = _FakeSite(
headers={"Authorization": "old-token", "User-Agent": "test"},
activation={"url": "https://vimeo.test/viewer"},
)
captured = {}
class FakeSession:
def __init__(self, **kwargs):
captured["session_kwargs"] = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def get(self, url, headers=None, timeout=None):
captured["url"] = url
captured["headers"] = dict(headers or {})
captured["timeout"] = timeout
return _FakeResponse(json_data={"jwt": "fresh"})
monkeypatch.setattr("maigret.activation.ClientSession", FakeSession)
await ParsingActivator.vimeo(site, Mock(), timeout=7)
assert captured["url"] == "https://vimeo.test/viewer"
assert captured["headers"] == {"User-Agent": "test"}
assert captured["timeout"] == 7
assert captured["session_kwargs"] == {"trust_env": True}
assert site.headers["Authorization"] == "jwt fresh"
@pytest.mark.asyncio
async def test_onlyfans_sets_xbc_when_zero(monkeypatch):
site = _FakeSite(headers={"x-bc": "0", "cookie": "existing=1"})
# Prevent any real network. If _sign path still opens a session, fail loudly.
def boom(*a, **kw): # pragma: no cover - sanity
raise AssertionError("ClientSession should not open when cookie is present")
monkeypatch.setattr("maigret.activation.ClientSession", boom)
logger = Mock()
await ParsingActivator.onlyfans(
site,
logger,
url="https://onlyfans.com/api2/v2/users/adam",
)
# x-bc must be rewritten to a non-zero hex token
assert site.headers["x-bc"] != "0"
assert len(site.headers["x-bc"]) == 40 # 20 bytes → 40 hex chars
# time / sign headers set for target URL
assert "time" in site.headers and site.headers["time"].isdigit()
assert site.headers["sign"].startswith("57203:")
@pytest.mark.asyncio
async def test_onlyfans_fetches_init_cookie_when_missing(monkeypatch):
"""When cookie header is absent, init endpoint is called and its cookies stored."""
site = _FakeSite(headers={"x-bc": "already_set_token", "user-id": "0"})
captured = {}
class FakeSession:
def __init__(self, **kwargs):
captured["session_kwargs"] = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def get(self, url, headers=None, timeout=15):
captured["url"] = url
captured["headers"] = dict(headers or {})
captured["timeout"] = timeout
return _FakeResponse(cookies={"sess": "abc123", "csrf": "xyz"})
monkeypatch.setattr("maigret.activation.ClientSession", FakeSession)
logger = Mock()
await ParsingActivator.onlyfans(
site,
logger,
url="https://onlyfans.com/api2/v2/users/adam",
)
# init request made
assert captured["url"] == site.activation["url"]
assert captured["timeout"] == 15
assert captured["session_kwargs"] == {"trust_env": True}
# headers passed to init include freshly generated time/sign
assert "time" in captured["headers"]
assert captured["headers"]["sign"].startswith("57203:")
# cookie header populated from response
assert site.headers["cookie"] == "sess=abc123; csrf=xyz"
@pytest.mark.asyncio
async def test_onlyfans_signature_is_deterministic_for_same_time(monkeypatch):
"""Two calls with patched time produce identical signatures."""
site1 = _FakeSite(headers={"x-bc": "token", "cookie": "c=1"})
site2 = _FakeSite(headers={"x-bc": "token", "cookie": "c=1"})
import maigret.activation
monkeypatch.setattr(maigret.activation, "_time", __import__("time"), raising=False)
fixed = 1_700_000_000.123
import time as time_mod
monkeypatch.setattr(time_mod, "time", lambda: fixed)
logger = Mock()
await ParsingActivator.onlyfans(
site1,
logger,
url="https://onlyfans.com/api2/v2/users/adam",
)
await ParsingActivator.onlyfans(
site2,
logger,
url="https://onlyfans.com/api2/v2/users/adam",
)
assert site1.headers["time"] == site2.headers["time"]
assert site1.headers["sign"] == site2.headers["sign"]
@pytest.mark.asyncio
async def test_onlyfans_sign_differs_per_path(monkeypatch):
"""Different target URLs must yield different signatures."""
site = _FakeSite(headers={"x-bc": "token", "cookie": "c=1"})
import time as time_mod
monkeypatch.setattr(time_mod, "time", lambda: 1_700_000_000.0)
logger = Mock()
await ParsingActivator.onlyfans(
site,
logger,
url="https://onlyfans.com/api2/v2/users/adam",
)
sig_adam = site.headers["sign"]
await ParsingActivator.onlyfans(
site,
logger,
url="https://onlyfans.com/api2/v2/users/bob",
)
sig_bob = site.headers["sign"]
assert sig_adam != sig_bob
@pytest.mark.asyncio
async def test_proton_activation_sets_uid_and_bearer(monkeypatch):
"""Proton activator bootstraps an anon session and injects UID + Bearer token."""
site = _FakeSite(
headers={"X-Pm-Appversion": "web-account@5.0.398.1"},
activation={"url": "https://account.proton.me/api/auth/v4/sessions"},
)
captured = {}
class FakeSession:
def __init__(self, **kwargs):
captured["session_kwargs"] = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def post(self, url, headers=None, json=None, timeout=None):
captured["url"] = url
captured["headers"] = dict(headers or {})
captured["json"] = json
captured["timeout"] = timeout
return _FakeResponse(json_data={"UID": "uid123", "AccessToken": "tok456"})
monkeypatch.setattr("maigret.activation.ClientSession", FakeSession)
await ParsingActivator.proton(site, Mock(), timeout=7)
assert captured["url"] == "https://account.proton.me/api/auth/v4/sessions"
assert captured["headers"] == {"X-Pm-Appversion": "web-account@5.0.398.1"}
assert captured["json"] == {}
assert captured["timeout"] == 7
assert site.headers["x-pm-uid"] == "uid123"
assert site.headers["Authorization"] == "Bearer tok456"
@pytest.mark.asyncio
async def test_proton_activation_strips_stale_auth_from_bootstrap(monkeypatch):
"""A prior Authorization/x-pm-uid must not be sent to the session endpoint."""
site = _FakeSite(
headers={
"X-Pm-Appversion": "web-account@5.0.398.1",
"Authorization": "Bearer stale",
"x-pm-uid": "stale-uid",
},
activation={"url": "https://account.proton.me/api/auth/v4/sessions"},
)
captured = {}
class FakeSession:
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def post(self, url, headers=None, json=None, timeout=None):
captured["headers"] = dict(headers or {})
return _FakeResponse(json_data={"UID": "u", "AccessToken": "t"})
monkeypatch.setattr("maigret.activation.ClientSession", FakeSession)
await ParsingActivator.proton(site, Mock())
assert "Authorization" not in captured["headers"]
assert "x-pm-uid" not in captured["headers"]
assert site.headers["Authorization"] == "Bearer t"
assert site.headers["x-pm-uid"] == "u"
@pytest.mark.asyncio
async def test_proton_activation_missing_token_leaves_headers_untouched(monkeypatch):
"""If Proton returns an error payload, do not inject broken auth headers."""
site = _FakeSite(
headers={"X-Pm-Appversion": "web-account@5.0.398.1"},
activation={"url": "https://account.proton.me/api/auth/v4/sessions"},
)
class FakeSession:
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def post(self, url, headers=None, json=None, timeout=None):
return _FakeResponse(json_data={"Code": 5002, "Error": "Missing header"})
monkeypatch.setattr("maigret.activation.ClientSession", FakeSession)
logger = Mock()
await ParsingActivator.proton(site, logger)
assert "Authorization" not in site.headers
assert "x-pm-uid" not in site.headers
logger.warning.assert_called_once()
@pytest.mark.asyncio
async def test_wikimapia_activation_parses_token_from_challenge():
"""The Wikimapia activator reads the ngxsession token from the challenge
body the checker already fetched and merges it into the request cookie."""
site = Mock()
site.name = "WikimapiaSearch"
site.headers = {"Cookie": "verified=1"}
challenge = (
'<html><head><meta http-equiv="refresh" content="1"></head><body>'
'<script>document.cookie="ngxsession=deadbeef0123456789";</script>'
'</body></html>'
)
await ParsingActivator.wikimapia(site, Mock(), html=challenge)
assert site.headers["Cookie"] == "verified=1; ngxsession=deadbeef0123456789"
@pytest.mark.asyncio
async def test_wikimapia_activation_replaces_stale_token():
"""A previously merged ngxsession is replaced, not duplicated, on re-activation."""
site = Mock()
site.name = "WikimapiaSearch"
site.headers = {"Cookie": "verified=1; ngxsession=0000000000000000"}
challenge = '<script>document.cookie="ngxsession=abcdef0123456789";</script>'
await ParsingActivator.wikimapia(site, Mock(), html=challenge)
assert site.headers["Cookie"] == "verified=1; ngxsession=abcdef0123456789"
@pytest.mark.asyncio
async def test_wikimapia_activation_no_token_leaves_cookie_untouched():
"""If the body carries no token, the cookie header is left as-is."""
site = Mock()
site.name = "WikimapiaSearch"
site.headers = {"Cookie": "verified=1"}
await ParsingActivator.wikimapia(site, Mock(), html="<html>no challenge here</html>")
assert site.headers["Cookie"] == "verified=1"
@pytest.fixture
def activation_db(monkeypatch, tmp_path):
"""Two sites, one that mints tokens at runtime and one that doesn't."""
db = MaigretDatabase()
db.update_site(
MaigretSite(
'Twitter',
{
'url': 'https://twitter.com/{username}',
'urlMain': 'https://twitter.com/',
'activation': {'method': 'twitter', 'marks': ['nope']},
'headers': {'x-guest-token': 'from-db', 'accept': 'text/html'},
},
)
)
db.update_site(
MaigretSite(
'Plain',
{
'url': 'https://example.com/{username}',
'urlMain': 'https://example.com/',
'headers': {'accept': 'text/html'},
},
)
)
monkeypatch.setattr(activation_cache, 'MAIGRET_HOME', str(tmp_path))
monkeypatch.setattr(
activation_cache, 'ACTIVATION_CACHE_PATH', str(tmp_path / 'activation.json')
)
return db
def test_activation_cache_persists_only_minted_headers(activation_db):
"""The database ships baseline headers; only what the run minted is cached.
Storing the full header set would pin whatever the database shipped, so a
later database update could never change those headers again.
"""
logger = Mock()
baseline = load_activation_cache(activation_db, logger)
# simulate what ParsingActivator does mid-run
activation_db.sites_dict['Twitter'].headers['x-guest-token'] = 'minted'
save_activation_cache(activation_db, baseline, logger)
written = json.loads(open(activation_cache.ACTIVATION_CACHE_PATH).read())
assert written == {'Twitter': {'x-guest-token': 'minted'}}
# the unchanged baseline header is not copied into the cache
assert 'accept' not in written['Twitter']
def test_activation_cache_round_trip_applies_token(activation_db):
logger = Mock()
baseline = load_activation_cache(activation_db, logger)
activation_db.sites_dict['Twitter'].headers['x-guest-token'] = 'minted'
save_activation_cache(activation_db, baseline, logger)
# a fresh run loads the database again, with the shipped value
activation_db.sites_dict['Twitter'].headers['x-guest-token'] = 'from-db'
load_activation_cache(activation_db, logger)
assert activation_db.sites_dict['Twitter'].headers['x-guest-token'] == 'minted'
def test_activation_cache_skips_sites_without_activation(activation_db):
logger = Mock()
baseline = load_activation_cache(activation_db, logger)
activation_db.sites_dict['Plain'].headers['accept'] = 'application/json'
save_activation_cache(activation_db, baseline, logger)
assert not os.path.exists(activation_cache.ACTIVATION_CACHE_PATH)
def test_activation_cache_ignores_corrupt_file(activation_db):
logger = Mock()
with open(activation_cache.ACTIVATION_CACHE_PATH, 'w') as f:
f.write('{ this is not json')
baseline = load_activation_cache(activation_db, logger)
assert baseline['Twitter']['x-guest-token'] == 'from-db'
assert activation_db.sites_dict['Twitter'].headers['x-guest-token'] == 'from-db'
assert logger.debug.called
def test_activation_cache_is_not_world_readable(activation_db):
"""The cache holds session credentials, so the umask must not decide."""
logger = Mock()
baseline = load_activation_cache(activation_db, logger)
activation_db.sites_dict['Twitter'].headers['x-guest-token'] = 'minted'
save_activation_cache(activation_db, baseline, logger)
mode = os.stat(activation_cache.ACTIVATION_CACHE_PATH).st_mode & 0o777
assert mode == 0o600, f"expected 0600, got {mode:o}"
def test_activation_cache_does_not_leak_into_shared_headers(monkeypatch, tmp_path):
"""MaigretSite.headers defaults to a mutable class attribute.
A site with an activation block but no headers of its own shares that
object with ~3000 other sites, so an in-place update would attach the
cached token to every one of them.
"""
logger = Mock()
db = MaigretDatabase()
db.update_site(
MaigretSite(
'NoHeaders',
{
'url': 'https://a.example/{username}',
'urlMain': 'https://a.example/',
'activation': {'method': 'twitter', 'marks': ['nope']},
},
)
)
db.update_site(
MaigretSite(
'Bystander',
{'url': 'https://b.example/{username}', 'urlMain': 'https://b.example/'},
)
)
cache_file = tmp_path / 'activation.json'
cache_file.write_text(json.dumps({'NoHeaders': {'x-guest-token': 'secret'}}))
monkeypatch.setattr(activation_cache, 'MAIGRET_HOME', str(tmp_path))
monkeypatch.setattr(activation_cache, 'ACTIVATION_CACHE_PATH', str(cache_file))
load_activation_cache(db, logger)
assert db.sites_dict['NoHeaders'].headers['x-guest-token'] == 'secret'
assert 'x-guest-token' not in db.sites_dict['Bystander'].headers
assert 'x-guest-token' not in MaigretSite.headers