From 046446156c8c7b3a58b44b53c0c71b7feb392888 Mon Sep 17 00:00:00 2001 From: jp170na Date: Mon, 2 Nov 2026 03:53:05 +0100 Subject: [PATCH] =?UTF-8?q?Oprava=20OAuth=20formul=C3=A1ra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + README.md | 10 +++++ packages/claude/oauth_provider.py | 11 ++++- scripts/check_claude_oauth.py | 73 +++++++++++++++++++++++++------ test/test_claude_packages.py | 5 ++- 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 61caef1..0ce295e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ runtime_assets/* __pycache__/ *.py[cod] *.log +*.swp +*.swo .pytest_cache/ .coverage htmlcov/ diff --git a/README.md b/README.md index f454597..f332235 100644 --- a/README.md +++ b/README.md @@ -1174,6 +1174,16 @@ jednorazové kódy, expiráciu, rotáciu, opakovanie a odvolanie tokenov. prihlásenie, PKCE, odmietnutie opakovania kódu, MCP, spoločnú verziu indexov, obnovu aj odvolanie tokenov. Kódy neukladá do reportu ani nevypisuje; testovací grant po skončení odvolá. Finálny redirect na účet Claude nenasleduje a nevolá LLM. +Voliteľné `--browser firefox` alebo `--browser chromium` odošle prihlasovací formulár +skutočným prehliadačom s čerstvým profilom (vyžaduje Playwright a jeho browser). +Tento režim nekonštruuje hlavičku Origin ručne a zachytí aj rozdiel medzi HTTP +klientom a HTML formulárom. Prehliadač má sieť povolenú iba na MCP server; test zachytí pokus o návrat +na Claude bez odoslania požiadavky do účtu. +Prihlasovacia stránka používa `Referrer-Policy: same-origin`: zachová pôvod vlastného +formulára a neposiela referrer pri návrate na Claude. Ostatné odpovede používajú +`no-referrer`; kontrola pôvodu aj CSRF zostávajú zapnuté. CSP povoľuje formuláru +iba vlastný server a presne registrovanú callback adresu Claude, aby prehliadač +neblokoval legitímne presmerovanie po prihlásení. Adaptér Anthropic je testovaný s lokálne nasimulovanými odpoveďami; bez API kľúča sa nevykonáva platené volanie Anthropic. Verejné HTTPS a samotný účet Claude sa overia až pri skutočnom pripojení. diff --git a/packages/claude/oauth_provider.py b/packages/claude/oauth_provider.py index 7b44413..e024de3 100644 --- a/packages/claude/oauth_provider.py +++ b/packages/claude/oauth_provider.py @@ -343,7 +343,16 @@ class OAuthBoundary: defaults = {b'cache-control':b'no-store', b'pragma':b'no-cache', b'referrer-policy':b'no-referrer', b'x-content-type-options':b'nosniff', b'x-frame-options':b'DENY'} if path == '/oauth/login': - defaults[b'content-security-policy'] = b"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" + # no-referrer makes native form POSTs send Origin: null (Fetch). + # Keep the real origin for this same-origin form while suppressing + # the referrer on the redirect to Claude. CSRF checks stay strict. + defaults[b'referrer-policy'] = b'same-origin' + # Chromium applies form-action to POST redirects too. Permit only + # the same fixed callbacks accepted during OAuth registration. + callbacks = ' '.join(sorted(CALLBACKS)) + defaults[b'content-security-policy'] = ( + "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' " + + callbacks + "; frame-ancestors 'none'; base-uri 'none'").encode() message = {**message,'headers':existing+[(k,v) for k,v in defaults.items() if k not in names]} await send(message) return await self.app(scope, receive, secured) diff --git a/scripts/check_claude_oauth.py b/scripts/check_claude_oauth.py index 025646d..011289d 100644 --- a/scripts/check_claude_oauth.py +++ b/scripts/check_claude_oauth.py @@ -7,6 +7,7 @@ never follows the final redirect to the Claude account. Test grants are revoked. from __future__ import annotations import asyncio +import argparse import base64 import hashlib import os @@ -30,7 +31,46 @@ def require(condition, message): raise RuntimeError(message) -async def check(): +async def browser_login(login_url, origin, password, engine): + """Use a fresh browser profile and an actual form POST, with no forged Origin.""" + from playwright.async_api import async_playwright + async with async_playwright() as playwright: + # Only the project's server bypasses the deliberately unreachable proxy. + # The browser can attempt Claude's redirect (testing CSP) but cannot send it. + browser = await getattr(playwright, engine).launch(headless=True, proxy={ + 'server':'http://127.0.0.1:9', 'bypass':urlsplit(origin).hostname}) + try: + context = await browser.new_context() + seen = {} + callback_ready = asyncio.Event() + + def callback_request(request): + if request.url.startswith(CALLBACK+'?'): + seen['url'] = request.url + seen['referrer'] = request.headers.get('referer') + callback_ready.set() + + page = await context.new_page() + page.on('request', callback_request) + await page.goto(login_url) + await page.locator('input[name=password]').fill(password) + is_form = lambda request: request.method=='POST' and request.url==origin+'/oauth/login' + async with page.expect_request(is_form) as request_info, page.expect_response( + lambda response: response.request.method=='POST' and response.url==origin+'/oauth/login') as response_info: + await page.locator('button[value=allow]').click(no_wait_after=True) + request, response = await request_info.value, await response_info.value + actual_origin = await request.header_value('origin') + print('Browser form: Origin='+str(actual_origin)+', HTTP '+str(response.status),flush=True) + require(actual_origin==origin, 'Browser form sent an unexpected Origin') + require(response.status==303, 'Browser login form failed') + await asyncio.wait_for(callback_ready.wait(), timeout=10) + require(seen.get('url') and seen.get('referrer') is None, 'Callback or referrer protection failed') + return seen['url'] + finally: + await browser.close() + + +async def check(browser=None): values = dotenv_values(ROOT / '.env') resource = os.getenv('ZP_MCP_PUBLIC_URL') or values.get('ZP_MCP_PUBLIC_URL', '') password = os.getenv('ZP_MCP_LOGIN_PASSWORD') or values.get('ZP_MCP_LOGIN_PASSWORD', '') @@ -72,17 +112,21 @@ async def check(): require(response.status_code==302, 'Authorization request failed') login = urlsplit(response.headers['location']) require(login.scheme+'://'+login.netloc==origin and login.path=='/oauth/login', 'Unexpected login destination') - response = await http.get(response.headers['location']) - require(response.status_code==200 and 'HttpOnly' in response.headers.get('set-cookie','') - and 'Secure' in response.headers.get('set-cookie',''), 'Secure login page failed') - csrf = re.search(r'name="csrf" value="([^"]+)"', response.text) - require(csrf is not None, 'Missing login CSRF field') - form = {'request':parse_qs(login.query)['request'][0],'csrf':csrf[1],'action':'allow'} - rejected = await http.post(origin+'/oauth/login',data={**form,'password':'invalid-test-code'},headers={'Origin':origin}) - require(rejected.status_code==401, 'Invalid owner code was not rejected') - response = await http.post(origin+'/oauth/login',data={**form,'password':password},headers={'Origin':origin}) - require(response.status_code==303, 'Owner login and consent failed') - callback = urlsplit(response.headers['location']) + if browser: + callback_url = await browser_login(response.headers['location'], origin, password, browser) + else: + response = await http.get(response.headers['location']) + require(response.status_code==200 and 'HttpOnly' in response.headers.get('set-cookie','') + and 'Secure' in response.headers.get('set-cookie',''), 'Secure login page failed') + csrf = re.search(r'name="csrf" value="([^"]+)"', response.text) + require(csrf is not None, 'Missing login CSRF field') + form = {'request':parse_qs(login.query)['request'][0],'csrf':csrf[1],'action':'allow'} + rejected = await http.post(origin+'/oauth/login',data={**form,'password':'invalid-test-code'},headers={'Origin':origin}) + require(rejected.status_code==401, 'Invalid owner code was not rejected') + response = await http.post(origin+'/oauth/login',data={**form,'password':password},headers={'Origin':origin}) + require(response.status_code==303, 'Owner login and consent failed') + callback_url = response.headers['location'] + callback = urlsplit(callback_url) require(callback.scheme+'://'+callback.netloc+callback.path==CALLBACK, 'Unexpected callback destination') query = parse_qs(callback.query) require(query.get('state')==[state] and query.get('code'), 'OAuth state or code failed') @@ -133,8 +177,11 @@ async def check(): if __name__=='__main__': + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--browser', choices=['firefox','chromium'], help='Submit the login form in an isolated Playwright browser') + args=parser.parse_args() try: - asyncio.run(check()) + asyncio.run(check(args.browser)) except Exception as error: # Never include HTTP bodies, credentials, token responses or callback URLs. print('OAuth verification failed: '+(str(error) if type(error) is RuntimeError else type(error).__name__),file=sys.stderr) diff --git a/test/test_claude_packages.py b/test/test_claude_packages.py index b93454f..0071e01 100644 --- a/test/test_claude_packages.py +++ b/test/test_claude_packages.py @@ -445,10 +445,13 @@ def test_oauth_rejects_bad_login_csrf_and_redirect(oauth_app): bad=await http.post('/register',json={'redirect_uris':['https://evil.test/steal'],'token_endpoint_auth_method':'none'}) assert bad.status_code==400 info,form,page,verifier=await oauth_start(http) - assert page.headers['referrer-policy']=='no-referrer' + assert page.headers['referrer-policy']=='same-origin' assert "frame-ancestors 'none'" in page.headers['content-security-policy'] + assert "form-action 'self' https://claude.ai/api/mcp/auth_callback;" in page.headers['content-security-policy'] assert (await http.post('/oauth/login',data={**form,'csrf':'wrong','password':'p'*64})).status_code==403 assert (await http.post('/oauth/login',data={**form,'password':'p'*64},headers={'Origin':'https://evil.test'})).status_code==403 + # Fix the browser's page policy, never allow opaque/cross-site origins. + assert (await http.post('/oauth/login',data={**form,'password':'p'*64},headers={'Origin':'null'})).status_code==403 wrong=await http.post('/oauth/login',data={**form,'password':'wrong'}) assert wrong.status_code==401 and 'Nesprávny' in wrong.text for _ in range(4):