|
41 | 41 | from __future__ import annotations |
42 | 42 |
|
43 | 43 | import html |
44 | | -import os |
45 | 44 | from datetime import UTC, datetime, timedelta |
46 | 45 |
|
47 | 46 | from fastapi import APIRouter, Form, Request |
|
55 | 54 | from src.domain.entities.identity_links import IdentityLinkMethod, IdentityProvider |
56 | 55 | from src.domain.repositories.identity_link_repository import IdentityLinkRepository |
57 | 56 | from src.domain.services.identity_link_service import ( |
58 | | - SESSION_COOKIE_NAME, |
59 | 57 | IdentityLinkService, |
| 58 | + session_cookie_name, |
60 | 59 | ) |
61 | 60 | from src.domain.services.link_nonce_service import LinkNonceService |
62 | 61 | from src.utils import session_jwt |
|
70 | 69 | # Used only when the session token doesn't declare its own expiry. Never "no |
71 | 70 | # expiry": storing a credential with an unbounded lifetime is how you end up |
72 | 71 | # holding one indefinitely, so an unknown expiry becomes a short known one. |
73 | | -_FALLBACK_TTL_DAYS = int(os.getenv("IDENTITY_LINK_FALLBACK_TTL_DAYS", "30")) |
74 | | - |
75 | | -# Require the Slack account's email to match the signed-in SGP account's. |
76 | | -# |
77 | | -# This is the only real defence against a *forwarded* link. The nonce stops an |
78 | | -# attacker forging someone else's Slack identity, but nothing stops them sending |
79 | | -# their OWN link to a victim: if the victim clicks it while signed in, the |
80 | | -# attacker's Slack identity binds to the victim's SGP account, and thereafter the |
81 | | -# attacker's Slack messages run as the victim, using the victim's integrations. The |
82 | | -# confirmation page names both identities, which catches a mis-click but reduces to |
83 | | -# user vigilance against a deliberate attempt. |
84 | | -# |
85 | | -# OFF by default because it needs the ``users:read.email`` Slack scope, which is not |
86 | | -# granted until the app is reinstalled. Enabling it without the scope would refuse |
87 | | -# every link (the check treats an unreadable email as a mismatch, deliberately), so |
88 | | -# the flag and the scope have to be turned on together. |
89 | | -_REQUIRE_EMAIL_MATCH = os.getenv("IDENTITY_LINK_REQUIRE_EMAIL_MATCH", "").lower() in ( |
90 | | - "1", |
91 | | - "true", |
92 | | - "yes", |
93 | | -) |
| 72 | +_FALLBACK_TTL_DAYS = 30 |
| 73 | + |
| 74 | +# Email matching has no flag: it enforces itself whenever Slack will tell us the |
| 75 | +# email, and stands down when it won't. See ``_email_mismatch``. |
94 | 76 |
|
95 | 77 |
|
96 | 78 | def _page(title: str, body: str, *, status: int = 200) -> HTMLResponse: |
@@ -155,15 +137,71 @@ def _session_credential(request: Request) -> str | None: |
155 | 137 | chokes on, silently dropping every morsel after the first bad one — which can |
156 | 138 | include the session cookie itself. The same reasoning (and the same approach) |
157 | 139 | applies in ``delegation_headers``. |
| 140 | +
|
| 141 | + The name comes from the delegation allowlist, so what we store here is by |
| 142 | + construction what the delegation layer will forward later. None when cookie |
| 143 | + delegation is disabled: there would be no way to act through the credential, so |
| 144 | + there is no point storing one. |
158 | 145 | """ |
| 146 | + wanted = session_cookie_name() |
| 147 | + if wanted is None: |
| 148 | + return None |
159 | 149 | raw = request.headers.get("cookie") or "" |
160 | 150 | for part in raw.split(";"): |
161 | 151 | name, sep, value = part.strip().partition("=") |
162 | | - if sep and name.strip() == SESSION_COOKIE_NAME: |
| 152 | + if sep and name.strip() == wanted: |
163 | 153 | return value.strip() or None |
164 | 154 | return None |
165 | 155 |
|
166 | 156 |
|
| 157 | +async def _email_mismatch(external_user_id: str, sgp_email: str | None) -> bool: |
| 158 | + """True when Slack and SGP demonstrably identify different people. |
| 159 | +
|
| 160 | + This is the only real defence against a *forwarded* link. The nonce stops an |
| 161 | + attacker forging someone else's Slack identity; it does not stop them sending |
| 162 | + their OWN link to a victim, who — clicking it while signed in — would bind the |
| 163 | + attacker's Slack identity to their SGP account, after which the attacker's Slack |
| 164 | + messages run as them with their integrations. |
| 165 | +
|
| 166 | + **Self-enabling, with no flag.** The check needs the ``users:read.email`` Slack |
| 167 | + scope, which may not be granted. Rather than gate that on configuration — where |
| 168 | + the flag and the scope must be flipped together, and flipping one alone either |
| 169 | + breaks every link or silently protects nothing — it simply enforces whenever |
| 170 | + Slack answers with an email and stands down when it won't. Granting the scope |
| 171 | + turns the protection on by itself. |
| 172 | +
|
| 173 | + So the asymmetry is deliberate: **verified different -> refuse; unverifiable -> |
| 174 | + allow and warn.** Failing closed on an unreadable email would be stronger, but |
| 175 | + with no flag to distinguish "scope missing" from "Slack had a bad minute" it |
| 176 | + would make linking fail randomly. The gap it leaves is not attacker-reachable: |
| 177 | + nobody outside our infrastructure influences whether our own Slack lookup |
| 178 | + succeeds. |
| 179 | + """ |
| 180 | + from src.domain.use_cases.slack_gateway_use_case import slack_user_profile |
| 181 | + |
| 182 | + profile = await slack_user_profile(external_user_id) |
| 183 | + slack_email = profile.get("email") |
| 184 | + if not slack_email: |
| 185 | + logger.warning( |
| 186 | + "identity link: email not verified (Slack would not tell us)", |
| 187 | + extra={ |
| 188 | + "external_user_id": external_user_id, |
| 189 | + "slack_error": profile.get("error"), |
| 190 | + "hint": "grant users:read.email to enable this check", |
| 191 | + }, |
| 192 | + ) |
| 193 | + return False |
| 194 | + if not sgp_email: |
| 195 | + # Slack gave us an email but the SGP session didn't. Nothing to compare, so |
| 196 | + # the same rule applies: can't verify, don't block. |
| 197 | + logger.warning( |
| 198 | + "identity link: email not verified (no email on the SGP principal)", |
| 199 | + extra={"external_user_id": external_user_id}, |
| 200 | + ) |
| 201 | + return False |
| 202 | + return slack_email.strip().lower() != sgp_email.strip().lower() |
| 203 | + |
| 204 | + |
167 | 205 | @router.get("/slack/link", summary="Confirm linking a Slack identity to SGP") |
168 | 206 | async def slack_link_page(request: Request, nonce: str = "") -> HTMLResponse: |
169 | 207 | """Render the confirmation screen. Does NOT consume the nonce, so a refresh or |
@@ -267,47 +305,34 @@ async def slack_link_confirm(request: Request, nonce: str = Form("")) -> HTMLRes |
267 | 305 | status=409, |
268 | 306 | ) |
269 | 307 |
|
270 | | - if _REQUIRE_EMAIL_MATCH: |
271 | | - # Local import: the gateway module owns the Slack token and HTTP calls, and |
272 | | - # importing it at module load would pull the use case into the route's import |
273 | | - # graph for a feature that is off by default. |
274 | | - from src.domain.use_cases.slack_gateway_use_case import slack_user_profile |
275 | | - |
276 | | - slack_email = (await slack_user_profile(link_request.external_user_id)).get( |
277 | | - "email" |
| 308 | + if await _email_mismatch(link_request.external_user_id, email): |
| 309 | + logger.warning( |
| 310 | + "identity link refused: Slack/SGP email mismatch", |
| 311 | + extra={ |
| 312 | + "sgp_user_id": sgp_user_id, |
| 313 | + "external_user_id": link_request.external_user_id, |
| 314 | + }, |
| 315 | + ) |
| 316 | + return _page( |
| 317 | + "Accounts don't match", |
| 318 | + "<h1>Those accounts don't match</h1>" |
| 319 | + "<p>The Slack account this link was made for and the SGP account " |
| 320 | + "you're signed in as belong to different people.</p>" |
| 321 | + "<p class=muted>If someone sent you this link, don't use it — it " |
| 322 | + "would let their Slack messages run as you. Mention the agent in " |
| 323 | + "Slack yourself to get your own link.</p>", |
| 324 | + status=403, |
278 | 325 | ) |
279 | | - # An unreadable email is treated as a mismatch, not as "skip the check". |
280 | | - # Failing open here would silently disable the only defence against a |
281 | | - # forwarded link the moment the Slack scope lapsed. |
282 | | - if not slack_email or not email or slack_email.lower() != email.lower(): |
283 | | - logger.warning( |
284 | | - "identity link refused: Slack/SGP email mismatch", |
285 | | - extra={ |
286 | | - "sgp_user_id": sgp_user_id, |
287 | | - "external_user_id": link_request.external_user_id, |
288 | | - "slack_email_known": bool(slack_email), |
289 | | - }, |
290 | | - ) |
291 | | - return _page( |
292 | | - "Accounts don't match", |
293 | | - "<h1>Those accounts don't match</h1>" |
294 | | - "<p>The Slack account this link was made for and the SGP account " |
295 | | - "you're signed in as belong to different people.</p>" |
296 | | - "<p class=muted>If someone sent you this link, don't use it — it " |
297 | | - "would let their Slack messages run as you. Mention the agent in " |
298 | | - "Slack yourself to get your own link.</p>", |
299 | | - status=403, |
300 | | - ) |
301 | 326 |
|
302 | 327 | secret = _session_credential(request) |
303 | 328 | if not secret: |
304 | 329 | # The middleware authenticated this caller somehow, but not by a session |
305 | | - # cookie — an api-key or bearer caller, or a cookie under a different name. |
306 | | - # There is nothing here we can act through later, so refuse rather than |
307 | | - # store an empty credential. |
| 330 | + # cookie — an api-key or bearer caller, a cookie under a different name, or |
| 331 | + # cookie delegation switched off entirely. There is nothing here we can act |
| 332 | + # through later, so refuse rather than store an empty credential. |
308 | 333 | logger.warning( |
309 | 334 | "identity link refused: no session cookie on an authenticated request", |
310 | | - extra={"sgp_user_id": sgp_user_id, "cookie": SESSION_COOKIE_NAME}, |
| 335 | + extra={"sgp_user_id": sgp_user_id, "cookie": session_cookie_name()}, |
311 | 336 | ) |
312 | 337 | return _page( |
313 | 338 | "Couldn't read your session", |
|
0 commit comments