Oprava OAuth formulára

This commit is contained in:
Ján Pták 2026-11-02 03:53:05 +01:00
parent 7368871efd
commit 046446156c
5 changed files with 86 additions and 15 deletions

2
.gitignore vendored
View File

@ -14,6 +14,8 @@ runtime_assets/*
__pycache__/
*.py[cod]
*.log
*.swp
*.swo
.pytest_cache/
.coverage
htmlcov/

View File

@ -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í.

View File

@ -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)

View File

@ -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)

View File

@ -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):