Skip to content

Commit da9ad0d

Browse files
committed
fix: confirm same-session external partial resumes
1 parent e05a572 commit da9ad0d

4 files changed

Lines changed: 154 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [5.0.1](https://github.com/python-social-auth/social-core/releases/tag/5.0.1) - 2026-06-24
9+
10+
### Security
11+
12+
- Externally resumable partial request links now require confirmation even in
13+
the browser session that created the partial, preventing validation links from
14+
being consumed by a plain GET.
15+
816
## [5.0.0](https://github.com/python-social-auth/social-core/releases/tag/5.0.0) - 2026-06-23
917

1018
### Security

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ license-files = ["LICENSE"]
5959
name = "social-auth-core"
6060
readme = "README.md"
6161
requires-python = ">=3.10"
62-
version = "5.0.0"
62+
version = "5.0.1"
6363

6464
[project.optional-dependencies]
6565
all = [

social_core/tests/test_utils.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,98 @@ def test_returns_partial_when_request_token_matches_session_id(self) -> None:
260260
self.assertIsNotNone(partial)
261261
backend.strategy.partial_load.assert_called_once_with("session-token")
262262

263+
def test_same_session_external_resume_requires_confirmation(self) -> None:
264+
response = object()
265+
backend = self._backend(
266+
request_data={
267+
"partial_token": "session-token",
268+
"verification_code": "123456",
269+
},
270+
partial_data={PARTIAL_PIPELINE_ALLOW_EXTERNAL_RESUME: True},
271+
)
272+
backend.strategy.partial_pipeline_external_resume_confirmation.return_value = (
273+
response
274+
)
275+
276+
result = partial_pipeline_result(backend)
277+
278+
self.assertIsNone(result.partial)
279+
self.assertEqual(result.response, response)
280+
self.assertFalse(result.halt)
281+
backend.strategy.session_set.assert_any_call(
282+
PARTIAL_TOKEN_PENDING_SESSION_NAME, "session-token"
283+
)
284+
backend.strategy.session_set.assert_any_call(
285+
PARTIAL_TOKEN_PENDING_REQUEST_SESSION_NAME,
286+
{"partial_token": "session-token", "verification_code": "123456"},
287+
)
288+
289+
def test_same_session_external_resume_rejects_initial_confirmation(self) -> None:
290+
response = object()
291+
backend = self._backend(
292+
request_data={
293+
"partial_token": "session-token",
294+
"partial_pipeline_confirm": "1",
295+
"verification_code": "123456",
296+
},
297+
partial_data={PARTIAL_PIPELINE_ALLOW_EXTERNAL_RESUME: True},
298+
)
299+
backend.strategy.partial_pipeline_external_resume_confirmation.return_value = (
300+
response
301+
)
302+
303+
result = partial_pipeline_result(backend)
304+
305+
self.assertIsNone(result.partial)
306+
self.assertEqual(result.response, response)
307+
backend.strategy.partial_pipeline_external_resume_confirmed.assert_not_called()
308+
backend.strategy.session_set.assert_any_call(
309+
PARTIAL_TOKEN_PENDING_SESSION_NAME, "session-token"
310+
)
311+
backend.strategy.session_set.assert_any_call(
312+
PARTIAL_TOKEN_PENDING_REQUEST_SESSION_NAME,
313+
{
314+
"partial_token": "session-token",
315+
"partial_pipeline_confirm": "1",
316+
"verification_code": "123456",
317+
},
318+
)
319+
320+
def test_same_session_external_resume_without_request_data_resumes(self) -> None:
321+
backend = self._backend(
322+
partial_data={PARTIAL_PIPELINE_ALLOW_EXTERNAL_RESUME: True},
323+
)
324+
325+
result = partial_pipeline_result(backend)
326+
327+
self.assertIsNotNone(result.partial)
328+
backend.strategy.partial_pipeline_external_resume_confirmation.assert_not_called()
329+
330+
def test_same_session_external_resume_without_request_token_requires_confirmation(
331+
self,
332+
) -> None:
333+
response = object()
334+
backend = self._backend(
335+
request_data={"verification_code": "123456"},
336+
partial_data={PARTIAL_PIPELINE_ALLOW_EXTERNAL_RESUME: True},
337+
)
338+
backend.strategy.partial_pipeline_external_resume_confirmation.return_value = (
339+
response
340+
)
341+
342+
result = partial_pipeline_result(backend)
343+
344+
self.assertIsNone(result.partial)
345+
self.assertEqual(result.response, response)
346+
self.assertFalse(result.halt)
347+
backend.strategy.session_set.assert_any_call(
348+
PARTIAL_TOKEN_PENDING_SESSION_NAME, "session-token"
349+
)
350+
backend.strategy.session_set.assert_any_call(
351+
PARTIAL_TOKEN_PENDING_REQUEST_SESSION_NAME,
352+
{"verification_code": "123456"},
353+
)
354+
263355
def test_request_token_without_session_match_is_halted(self) -> None:
264356
backend = self._backend(
265357
request_data={"partial_token": "attacker-token"},
@@ -406,6 +498,33 @@ def test_confirmed_external_resume_uses_pending_request_data(self) -> None:
406498
result.partial.kwargs["request"]["partial_pipeline_confirm"], "1"
407499
)
408500

501+
def test_confirmed_same_session_resume_uses_pending_request_data(self) -> None:
502+
backend = self._backend(
503+
request_data={
504+
"partial_token": "session-token",
505+
"partial_pipeline_confirm": "1",
506+
},
507+
pending_resume={
508+
"token": "session-token",
509+
"request": {
510+
"partial_token": "session-token",
511+
"verification_code": "123456",
512+
},
513+
},
514+
partial_data={PARTIAL_PIPELINE_ALLOW_EXTERNAL_RESUME: True},
515+
)
516+
517+
result = partial_pipeline_result(backend, request=object())
518+
519+
self.assertIsNotNone(result.partial)
520+
assert result.partial is not None
521+
self.assertEqual(
522+
result.partial.kwargs["request"]["partial_token"], "session-token"
523+
)
524+
self.assertEqual(
525+
result.partial.kwargs["request"]["verification_code"], "123456"
526+
)
527+
409528
def test_clean_pipeline_when_uid_does_not_match(self) -> None:
410529
backend = self._backend({"uid": "foo@example.com"})
411530
backend.strategy.request_data.return_value = {backend.ID_KEY: "bar@example.com"}

social_core/utils.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,6 @@ def _select_partial_pipeline_token(
230230
pending_token: str | None,
231231
confirmation_requested: bool,
232232
) -> PartialPipelineSelection:
233-
if request_token and request_token == session_token:
234-
return PartialPipelineSelection(token=request_token, owns_token=True)
235-
236233
if confirmation_requested and pending_token:
237234
selected_token = request_token or pending_token
238235
pending_resume = selected_token == pending_token
@@ -242,12 +239,28 @@ def _select_partial_pipeline_token(
242239
pending_resume=pending_resume,
243240
)
244241

242+
if request_token and request_token == session_token:
243+
return PartialPipelineSelection(token=request_token, owns_token=True)
244+
245245
if request_token:
246246
return PartialPipelineSelection(token=request_token)
247247

248248
return PartialPipelineSelection(token=session_token, owns_token=bool(session_token))
249249

250250

251+
def _partial_pipeline_requires_confirmation(
252+
partial: PartialMixin,
253+
request_token: str | None,
254+
request_data: dict[str, Any],
255+
pending_resume: bool,
256+
) -> bool:
257+
return bool(
258+
not pending_resume
259+
and partial.data.get(PARTIAL_PIPELINE_ALLOW_EXTERNAL_RESUME)
260+
and (request_token or request_data)
261+
)
262+
263+
251264
def _confirmed_partial_pipeline_request_data(
252265
backend: BaseAuth,
253266
request_data: dict[str, Any],
@@ -338,7 +351,16 @@ def partial_pipeline_result(
338351
backend, partial, effective_request_data
339352
)
340353
if partial and partial_matches:
341-
if selection.owns_token:
354+
if _partial_pipeline_requires_confirmation(
355+
partial,
356+
request_token,
357+
effective_request_data,
358+
selection.pending_resume,
359+
):
360+
result = _external_partial_pipeline_result(
361+
backend, partial, selection.token, effective_request_data
362+
)
363+
elif selection.owns_token:
342364
result = PartialPipelineResult(
343365
partial=_extend_partial_pipeline(
344366
partial, effective_request_data, user, kwargs

0 commit comments

Comments
 (0)