feat(mcp): durcir le consentement OAuth (remember-cookie SameSite + CSRF) (#114)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+7
-3
@@ -150,11 +150,15 @@ if _mcp_enabled:
|
|||||||
|
|
||||||
oauth_routes.init_oauth(app.server)
|
oauth_routes.init_oauth(app.server)
|
||||||
|
|
||||||
# Exempter les routes OAuth du CSRF (POST externes JSON-RPC/form sans jeton).
|
# Exempter du CSRF les endpoints OAuth machine-à-machine (POST externes sans
|
||||||
|
# cookie) et les documents de découverte (GET). /oauth/authorize N'EST PAS
|
||||||
|
# exempté : endpoint de consentement authentifié par cookie de session, donc
|
||||||
|
# protégé par CSRF via un jeton dans le formulaire de consentement.
|
||||||
if _auth_csrf is not None:
|
if _auth_csrf is not None:
|
||||||
|
_csrf_exempt_oauth = ("/oauth/token", "/oauth/register", "/oauth/revoke")
|
||||||
for _rule in app.server.url_map.iter_rules():
|
for _rule in app.server.url_map.iter_rules():
|
||||||
if _rule.rule.startswith("/oauth") or _rule.rule.startswith(
|
if _rule.rule in _csrf_exempt_oauth or _rule.rule.startswith(
|
||||||
"/.well-known/oauth"
|
"/.well-known/"
|
||||||
):
|
):
|
||||||
_vf = app.server.view_functions.get(_rule.endpoint)
|
_vf = app.server.view_functions.get(_rule.endpoint)
|
||||||
if _vf is not None:
|
if _vf is not None:
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ def init_auth(app: Flask) -> None:
|
|||||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||||
app.config["SESSION_COOKIE_SECURE"] = not DEVELOPMENT
|
app.config["SESSION_COOKIE_SECURE"] = not DEVELOPMENT
|
||||||
|
app.config["REMEMBER_COOKIE_SAMESITE"] = "Lax"
|
||||||
|
app.config["REMEMBER_COOKIE_SECURE"] = not DEVELOPMENT
|
||||||
|
app.config["REMEMBER_COOKIE_HTTPONLY"] = True
|
||||||
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 30 # 30 jours
|
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 30 # 30 jours
|
||||||
|
|
||||||
db.init_schema()
|
db.init_schema()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from urllib.parse import urlencode
|
|||||||
|
|
||||||
from flask import redirect, request
|
from flask import redirect, request
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
|
from flask_wtf.csrf import generate_csrf
|
||||||
|
|
||||||
from src.mcp.oauth import consent
|
from src.mcp.oauth import consent
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ def authorize():
|
|||||||
client.client_metadata.get("client_name", client.get_client_id()),
|
client.client_metadata.get("client_name", client.get_client_id()),
|
||||||
grant.request.redirect_uri or client.get_default_redirect_uri(),
|
grant.request.redirect_uri or client.get_default_redirect_uri(),
|
||||||
scope,
|
scope,
|
||||||
|
csrf_token=generate_csrf(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# POST
|
# POST
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ def render_subscription_required() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_consent(client_name: str, redirect_uri: str, scope: str) -> str:
|
def render_consent(
|
||||||
|
client_name: str, redirect_uri: str, scope: str, csrf_token: str = ""
|
||||||
|
) -> str:
|
||||||
host = urlparse(redirect_uri).netloc or redirect_uri
|
host = urlparse(redirect_uri).netloc or redirect_uri
|
||||||
return (
|
return (
|
||||||
"<!doctype html><html lang=fr><head><meta charset=utf-8>"
|
"<!doctype html><html lang=fr><head><meta charset=utf-8>"
|
||||||
@@ -31,6 +33,7 @@ def render_consent(client_name: str, redirect_uri: str, scope: str) -> str:
|
|||||||
"colibre en votre nom via le connecteur MCP.</p>"
|
"colibre en votre nom via le connecteur MCP.</p>"
|
||||||
f"<p>Vous serez redirigé vers <strong>{escape(host)}</strong>.</p>"
|
f"<p>Vous serez redirigé vers <strong>{escape(host)}</strong>.</p>"
|
||||||
'<form method="post">'
|
'<form method="post">'
|
||||||
|
f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
|
||||||
'<button name="confirm" value="yes" type="submit">Autoriser</button> '
|
'<button name="confirm" value="yes" type="submit">Autoriser</button> '
|
||||||
'<button name="confirm" value="no" type="submit">Refuser</button>'
|
'<button name="confirm" value="no" type="submit">Refuser</button>'
|
||||||
"</form></body></html>"
|
"</form></body></html>"
|
||||||
|
|||||||
@@ -27,3 +27,24 @@ def test_render_consent_shows_redirect_host():
|
|||||||
def test_render_subscription_required_links_abonnement():
|
def test_render_subscription_required_links_abonnement():
|
||||||
html = consent.render_subscription_required()
|
html = consent.render_subscription_required()
|
||||||
assert "/compte/abonnement" in html
|
assert "/compte/abonnement" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_consent_escapes_client_name():
|
||||||
|
html = consent.render_consent(
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"https://claude.ai/api/mcp/auth_callback",
|
||||||
|
"mcp",
|
||||||
|
)
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_consent_includes_csrf_token():
|
||||||
|
html = consent.render_consent(
|
||||||
|
"Claude",
|
||||||
|
"https://claude.ai/api/mcp/auth_callback",
|
||||||
|
"mcp",
|
||||||
|
csrf_token="tok-abc-123",
|
||||||
|
)
|
||||||
|
assert 'name="csrf_token"' in html
|
||||||
|
assert "tok-abc-123" in html
|
||||||
|
|||||||
@@ -92,6 +92,30 @@ def test_authorize_requires_subscription(flow_app, monkeypatch):
|
|||||||
assert b"Abonnement requis" in resp.data
|
assert b"Abonnement requis" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorize_post_requires_subscription(flow_app, monkeypatch):
|
||||||
|
from src.auth import db as auth_db
|
||||||
|
|
||||||
|
app, _ = flow_app
|
||||||
|
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
|
||||||
|
uid = auth_db.create_user("nosub2@ex.fr", "h")
|
||||||
|
client = app.test_client()
|
||||||
|
client.get(f"/_test_login/{uid}")
|
||||||
|
cid = _register(client)
|
||||||
|
_, challenge = _pkce()
|
||||||
|
qs = {
|
||||||
|
"client_id": cid,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
|
||||||
|
"scope": "mcp",
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
"resource": "https://colibre.fr/_mcp",
|
||||||
|
}
|
||||||
|
resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert b"Abonnement requis" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_full_flow_issues_audience_bound_token(flow_app, monkeypatch):
|
def test_full_flow_issues_audience_bound_token(flow_app, monkeypatch):
|
||||||
from src.auth import db as auth_db
|
from src.auth import db as auth_db
|
||||||
from src.mcp.oauth import store
|
from src.mcp.oauth import store
|
||||||
|
|||||||
Reference in New Issue
Block a user