Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## v26.06.13 (2026-06-05)

### Security

- **Session fixation fixed.** Authentication now rotates the session id, so an
attacker who fixed a victim's pre-auth `PYFLY_SESSION` id cannot ride the
authenticated session. New `HttpSession.rotate_id()` (preserves data, records
`previous_id`); `SessionFilter` migrates the store entry and re-issues the
cookie under the new id; the OAuth2 login flow calls it on successful login.
- **Session cookie `Secure` auto-set over HTTPS.** `SessionFilter` now marks the
cookie `Secure` when the request arrives over HTTPS (honoring
`X-Forwarded-Proto`) even if not explicitly configured — hardening production
without breaking plain-HTTP local development.
- **Redis session deserialization hardened.** `RedisSessionStore` rehydrated
*any* tagged type via `importlib` + `obj(**payload)` — an arbitrary-object
instantiation gadget if the store were ever attacker-writable. Rehydration is
now restricted to an allowlist (`SecurityContext` pre-registered); other tagged
values return a plain dict. Apps opt custom dataclasses in via
`allow_session_type()`.

### Added

- **`tests/session/` suite (16 tests)** — the session subsystem was previously
untested. Covers `HttpSession` (incl. rotation), `InMemorySessionStore`
(incl. TTL expiry), `SessionFilter` (new/existing/invalidate/rotation/secure
auto-detect), and `RedisSessionStore` (SecurityContext round-trip + the
allowlist gadget guard).

Completes the session-hardening follow-up deferred from v26.06.12.

---

## v26.06.12 (2026-06-05)

### Security
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.12-brightgreen" alt="Version: 26.06.12"></a>
<a href="#"><img src="https://img.shields.io/badge/version-26.06.13-brightgreen" alt="Version: 26.06.13"></a>
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
version = "26.6.12"
version = "26.6.13"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/pyfly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""

__version__ = "26.06.12"
__version__ = "26.06.13"
3 changes: 3 additions & 0 deletions src/pyfly/security/oauth2/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ async def _handle_callback(self, request: Request) -> Response:
status_code=401,
)

# Rotate the session id on successful authentication to prevent session
# fixation — the pre-auth id (which an attacker may have fixed) is dropped.
session.rotate_id()
session.set_attribute(_SECURITY_CONTEXT_KEY, security_context)

logger.info("OAuth2 login successful for user: %s (via %s)", security_context.user_id, registration_id)
Expand Down
28 changes: 26 additions & 2 deletions src/pyfly/session/adapters/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@
_KEY_PREFIX = "pyfly:session:"
_TYPE_KEY = "__pyfly_type__"

# Tagged dataclass types allowed to be reconstructed from session JSON on read.
# Restricting this prevents an arbitrary-object instantiation gadget if the
# session store is ever attacker-writable. Framework types are pre-registered;
# applications opt their own session-stored dataclasses in via allow_session_type.
_ALLOWED_TYPE_TAGS: set[str] = {"pyfly.security.context:SecurityContext"}


def allow_session_type(cls: type) -> None:
"""Allow *cls* (a dataclass) to be rehydrated from the Redis session store.

Only allowlisted tagged types are reconstructed on read; any other tagged
value is returned as a plain dict. Call this once at startup for a custom
dataclass an application stores in the session.
"""
_ALLOWED_TYPE_TAGS.add(f"{cls.__module__}:{cls.__qualname__}")


def _json_default(obj: Any) -> Any:
"""Encode dataclass session attributes (e.g. SecurityContext) with a type tag.
Expand All @@ -41,12 +57,20 @@ def _json_default(obj: Any) -> Any:


def _json_object_hook(d: dict[str, Any]) -> Any:
"""Rehydrate a tagged dataclass dict back into its original type on read."""
"""Rehydrate an allowlisted tagged dataclass dict into its original type.

A tag that is not on the allowlist is NOT imported or instantiated — the
plain dict is returned instead — to avoid an arbitrary-object instantiation
gadget from session data.
"""
tag = d.get(_TYPE_KEY)
if not tag:
return d
module_name, _, qualname = tag.partition(":")
payload = {k: v for k, v in d.items() if k != _TYPE_KEY}
if tag not in _ALLOWED_TYPE_TAGS:
_logger.warning("Refusing to rehydrate non-allowlisted session type %r", tag)
return payload
module_name, _, qualname = tag.partition(":")
try:
obj: Any = importlib.import_module(module_name)
for part in qualname.split("."):
Expand Down
23 changes: 22 additions & 1 deletion src/pyfly/session/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ async def do_filter(self, request: Any, call_next: CallNext) -> Any:
key=self._cookie_name,
value=session.id,
httponly=True,
secure=self._secure,
secure=self._secure or self._is_secure_request(request),
samesite="lax",
max_age=self._ttl,
)
Expand All @@ -93,7 +93,28 @@ async def _load_or_create_session(self, request: Any) -> HttpSession:

async def _persist_session(self, session: HttpSession) -> None:
"""Save or delete the session in the store based on its state."""
# If the id was rotated (e.g. on login), drop the pre-rotation entry so a
# fixed/stale id can no longer resolve to this session (anti-fixation).
if session.previous_id is not None and session.previous_id != session.id:
await self._store.delete(session.previous_id)

if session.invalidated:
await self._store.delete(session.id)
elif session.modified:
await self._store.save(session.id, session.get_data(), self._ttl)

@staticmethod
def _is_secure_request(request: Any) -> bool:
"""Whether the request arrived over HTTPS (honoring ``X-Forwarded-Proto``).

Sets the cookie ``Secure`` attribute automatically in production (HTTPS)
without breaking plain-HTTP local development.
"""
headers = getattr(request, "headers", None)
forwarded = ""
if headers is not None and hasattr(headers, "get"):
forwarded = headers.get("x-forwarded-proto", "")
if forwarded:
return str(forwarded).split(",")[0].strip().lower() == "https"
url = getattr(request, "url", None)
return url is not None and getattr(url, "scheme", "") == "https"
21 changes: 21 additions & 0 deletions src/pyfly/session/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from __future__ import annotations

import time
import uuid
from typing import Any


Expand All @@ -39,6 +40,7 @@ def __init__(
self._is_new = is_new
self._invalidated = False
self._modified = is_new
self._previous_id: str | None = None

now = time.time()
if "_created_at" not in self._data:
Expand All @@ -49,6 +51,11 @@ def __init__(
def id(self) -> str:
return self._id

@property
def previous_id(self) -> str | None:
"""The id this session was rotated away from (set by :meth:`rotate_id`)."""
return self._previous_id

@property
def is_new(self) -> bool:
return self._is_new
Expand Down Expand Up @@ -88,6 +95,20 @@ def get_attribute_names(self) -> list[str]:
"""Return all attribute names, excluding internal metadata keys."""
return [k for k in self._data if not k.startswith("_")]

def rotate_id(self) -> None:
"""Assign a fresh session id, preserving all data.

Call on authentication / privilege elevation to prevent session-fixation
attacks: an attacker who fixed the victim's pre-auth session id cannot
ride the authenticated session. The store entry and cookie are migrated
to the new id when the session is persisted by the ``SessionFilter``.
"""
if self._invalidated:
return
self._previous_id = self._id
self._id = uuid.uuid4().hex
self._modified = True

def invalidate(self) -> None:
"""Mark the session for deletion."""
self._invalidated = True
Expand Down
7 changes: 7 additions & 0 deletions tests/session/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copyright 2026 Firefly Software Foundation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
Loading
Loading