Oprava OAuth formulára
This commit is contained in:
parent
7368871efd
commit
046446156c
2
.gitignore
vendored
2
.gitignore
vendored
@ -14,6 +14,8 @@ runtime_assets/*
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
*.swp
|
||||
*.swo
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
10
README.md
10
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í.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user