Skip to content
Merged

Dev #53

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
11 changes: 7 additions & 4 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,20 @@ jobs:
python -m pip install --upgrade pip
pip install -U flake8 setuptools
pip install -U openapi-core uwsgi simplejson WSocket PyJWT pyaes
pip install -U pytest pytest-doctestplus pytest-pylint pytest-mypy requests websocket-client
pip install -U types-simplejson types-requests types-PyYAML
pip install -U pytest pytest-doctestplus pytest-mypy requests websocket-client
pip install -U pylint types-simplejson types-requests types-PyYAML
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Lint with pylit
- name: Lint with pylint
run: |
pytest -v poorwsgi --pylint --mypy --doctest-plus --doctest-rst
pylint poorwsgi
- name: Lint with mypy and doctest
run: |
pytest -v poorwsgi --mypy --doctest-plus --doctest-rst
- name: Tests
run: |
pytest -v tests --mypy
Expand Down
18 changes: 4 additions & 14 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

name: Upload Python Package

on:
Expand All @@ -14,16 +6,17 @@ on:

permissions:
contents: read
id-token: write

jobs:
deploy:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
Expand All @@ -34,7 +27,4 @@ jobs:
run: |
python -m build
- name: Publish package
uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
uses: pypa/gh-action-pypi-publish@release/v1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ tags
__pycache__/
*.pyc
*.profile
.coverage
2 changes: 0 additions & 2 deletions .isort.cfg

This file was deleted.

22 changes: 22 additions & 0 deletions doc/ChangeLog
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
==== 2.8.1 ====
* Session, PoorSession, AESSession: validate same_site argument
- Accepted values are 'Strict', 'Lax', 'None', or False
- ValueError is raised for any other value
- ValueError is raised when same_site='None' and secure=False
(browsers reject SameSite=None without the Secure flag)
- Type annotation corrected from Union[str, bool] to
Union[str, Literal[False]]
* Updated docstrings and documentation to document valid same_site
values, the Secure requirement for 'None', and raised exceptions
* Fix Session.destroy() with max_age: Max-Age=-1 was overwritten by
the subsequent write() call; _destroyed flag now prevents that
* Unit tests for session.py and aes_session.py: 100% coverage
- get_token, check_token, NoCompress
- Session.destroy() with max_age and secure
- Session.header() with a headers argument
- PoorSession and AESSession: str key, empty cookie, short
signature/payload, non-dict data
* Consolidate tool configuration into pyproject.toml
- ruff.toml and .isort.cfg removed
- pytest testpaths and coverage source configured

==== 2.8.0 ====
* Fix I/O operation on closed file/buffer error in Response classes (#21)
* Validate route filter definitions to reject spaces with clear error
Expand Down
46 changes: 42 additions & 4 deletions doc/documentation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1075,10 +1075,34 @@ No encryption is applied — the value is stored as-is in the cookie.
session.load(req.cookies)
server_data = server_store[session.data]

The ``Session`` class accepts the following keyword arguments:
``sid``, ``expires``, ``max_age``, ``domain``, ``path``, ``secure``,
``same_site``. It exposes ``load()``, ``write()``, ``destroy()``, and
``header()`` methods.
The ``Session`` constructor accepts the following keyword arguments:

``sid``
Cookie name (default ``'SESSID'``).
``expires``
``Expires`` time in seconds from now. ``0`` (default) means no expiration.
``max_age``
``Max-Age`` in seconds. Takes precedence over ``expires`` when both are set.
``domain``
``Domain`` attribute — restricts which hosts receive the cookie.
``path``
``Path`` attribute (default ``'/'``).
``secure``
Set the ``Secure`` flag. Required when ``same_site='None'``.
``same_site``
``SameSite`` attribute. Accepted values:

* ``'Strict'`` — cookie is sent only in same-site requests.
* ``'Lax'`` — cookie is sent in same-site requests and top-level
navigations (browsers default to this when the attribute is absent).
* ``'None'`` — cookie is sent in all contexts including cross-site
requests. **Requires** ``secure=True``.
* ``False`` (default) — the attribute is omitted entirely.

Passing any other value raises ``ValueError``. Passing ``'None'``
without ``secure=True`` also raises ``ValueError``.

It exposes ``load()``, ``write()``, ``destroy()``, and ``header()`` methods.

.. note::

Expand Down Expand Up @@ -1117,6 +1141,13 @@ variable or the ``Application.secret_key`` property.

* **Cookie format**: ``base64(ciphertext).base64(hmac-sha256)``

``PoorSession`` accepts the same cookie keyword arguments as ``Session``
(``sid``, ``expires``, ``max_age``, ``domain``, ``path``, ``secure``,
``same_site``) plus ``compress`` and ``secret_key`` (positional).
The constructor raises ``SessionError`` if ``secret_key`` is empty, and
``ValueError`` for an invalid ``same_site`` value or the ``'None'`` /
``secure=False`` combination (see `Session`_ for details).

The ``KEYSTREAM_SIZE`` constant in ``poorwsgi.session`` controls the keystream
length (default ``1024``). Increasing it makes known-plaintext attacks harder
at the cost of slightly larger memory usage per session instance. Changing it
Expand Down Expand Up @@ -1206,6 +1237,13 @@ A 16-byte random nonce is generated on every ``write()`` call to prevent
CTR nonce reuse. A missing or tampered signature causes ``SessionError``
to be raised in ``load()``.

``AESSession`` accepts the same cookie keyword arguments as ``Session``
(``sid``, ``expires``, ``max_age``, ``domain``, ``path``, ``secure``,
``same_site``) plus ``secret_key`` (positional).
The constructor raises ``SessionError`` if ``secret_key`` is empty, and
``ValueError`` for an invalid ``same_site`` value or the ``'None'`` /
``secure=False`` combination (see `Session`_ for details).

JSON Web Tokens
```````````````

Expand Down
20 changes: 16 additions & 4 deletions poorwsgi/aes_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from json import dumps, loads
from logging import getLogger
from os import urandom
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Literal, Optional, Union

from pyaes import ( # type: ignore[import-untyped]
AESModeOfOperationCTR, Counter)
Expand Down Expand Up @@ -54,7 +54,7 @@ def __init__( # pylint: disable=too-many-positional-arguments
domain: str = '',
path: str = '/',
secure: bool = False,
same_site: Union[str, bool] = False,
same_site: Union[str, Literal[False]] = False,
sid: str = 'SESSID'):
"""Constructor.

Expand All @@ -72,10 +72,22 @@ def __init__( # pylint: disable=too-many-positional-arguments
secure
If ``True``, set the ``Secure`` cookie attribute.
same_site
The ``SameSite`` attribute value (``'Strict'``, ``'Lax'``,
``'None'``) or ``False`` to omit it.
The ``SameSite`` cookie attribute. Accepted values are
``'Strict'``, ``'Lax'``, ``'None'``, or ``False``.
``False`` (the default) omits the attribute entirely,
which browsers treat as ``'Lax'``. ``'None'`` permits
the cookie in cross-site requests but requires
``secure=True``.
sid
Cookie name.

Raises:
SessionError
If *secret_key* is empty.
ValueError
If *same_site* is not one of ``'Strict'``, ``'Lax'``,
``'None'``, or ``False``; or if *same_site* is ``'None'``
and *secure* is ``False``.
"""
if not secret_key:
raise SessionError("Empty secret_key")
Expand Down
12 changes: 8 additions & 4 deletions poorwsgi/digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
# pylint: disable=consider-using-f-string

from argparse import ArgumentParser
from hashlib import md5, sha256
from hashlib import md5, sha256 # nosec B324 - required by RFC 7616
from hmac import compare_digest
from traceback import print_exc
from getpass import getpass
from os.path import exists
Expand Down Expand Up @@ -68,7 +69,7 @@ def check_response(req, password):
response = req.app.auth_hash(
'{hash1}:{nonce}:{hash2}'
''.format(**kwargs).encode()).hexdigest()
return response == kwargs['response']
return compare_digest(response, kwargs['response'])


def check_credentials(req, realm, username=None):
Expand Down Expand Up @@ -182,6 +183,9 @@ def hexdigest(username, realm, password, algorithm=md5):
"""Returns the digest hash value for a user's password.

Returns algorithm(username:realm:password).hexdigest().

The default algorithm is MD5 for compatibility with the htdigest file
Comment thread
ondratu marked this conversation as resolved.
Dismissed
format (RFC 2617). Use algorithm=sha256 for stronger hashing.
"""
return algorithm(
('%s:%s:%s' % (username, realm, password)).encode()
Expand Down Expand Up @@ -217,7 +221,7 @@ def find(self, realm, username):
def verify(self, realm, username, digest):
"""Checks the digest in the password map."""
digest_ = self.find(realm, username)
return bool(digest_) and digest_ == digest
return bool(digest_) and compare_digest(digest_, digest)

def load(self):
"""Loads the map from a file."""
Expand Down Expand Up @@ -302,7 +306,7 @@ def main(): # noqa: C901
password = get_re_type()
if password is None:
return 1
digest = hexdigest(args.username, args.realm, password)
digest = hexdigest(args.username, args.realm, password, algorithm)
print('%s:%s:%s' % (args.username, args.realm, digest))
return 0

Expand Down
60 changes: 46 additions & 14 deletions poorwsgi/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from logging import getLogger
from random import Random
from time import time
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Literal, Optional, Union

from http.cookies import SimpleCookie

Expand Down Expand Up @@ -62,7 +62,7 @@ def hidden(text: Union[str, bytes], secret_hash: bytes) -> bytes:
for i, val in enumerate(text):
retval.append(val ^ secret_hash[i % secret_len])

return retval
return bytes(retval)


def encrypt(data: bytes, table: bytearray) -> bytes:
Expand Down Expand Up @@ -175,7 +175,8 @@ class Session:

def __init__(self, expires: int = 0, max_age: Optional[int] = None,
domain: str = '', path: str = '/', secure: bool = False,
same_site: Union[str, bool] = False, sid: str = 'SESSID'):
same_site: Union[str, Literal[False]] = False,
sid: str = 'SESSID'):
"""Constructor.

Arguments:
Expand All @@ -192,12 +193,27 @@ def __init__(self, expires: int = 0, max_age: Optional[int] = None,
secure
If the ``Secure`` cookie attribute will be sent.
same_site
The ``SameSite`` attribute. When set, it can be one of
``Strict|Lax|None``. By default, the attribute is not
set, which browsers default to ``Lax``.
The ``SameSite`` cookie attribute. Accepted values are
``'Strict'``, ``'Lax'``, ``'None'``, or ``False``.
``False`` (the default) omits the attribute entirely,
which browsers treat as ``'Lax'``. ``'None'`` permits
the cookie in cross-site requests but requires
``secure=True``.
sid
The cookie key name.

Raises:
ValueError
If *same_site* is not one of ``'Strict'``, ``'Lax'``,
``'None'``, or ``False``; or if *same_site* is ``'None'``
and *secure* is ``False``.
"""
if same_site not in ("Strict", "Lax", "None", False):
msg = (f"same_site={same_site!r} is not valid; "
"use 'Strict', 'Lax', 'None', or False")
raise ValueError(msg)
if same_site == "None" and not secure:
raise ValueError("same_site='None' requires secure=True")
self._sid = sid
self.__expires = expires
self.__max_age = max_age
Expand All @@ -209,13 +225,16 @@ def __init__(self, expires: int = 0, max_age: Optional[int] = None,
self.data: Any = ""
self.cookie: SimpleCookie = SimpleCookie()
self.cookie[sid] = ''
self._destroyed: bool = False

def _apply_cookie_attrs(self):
"""Apply security and configuration attributes to the session cookie.

Called by ``write`` and subclass overrides of ``write``.
Sets ``HttpOnly``, ``Domain``, ``Path``, ``Secure``, ``SameSite``,
``Expires``, and ``Max-Age`` as configured.
Skips ``Expires`` and ``Max-Age`` when ``destroy()`` was already
called, so those fields are not overwritten with the original values.
"""
self.cookie[self._sid]['HttpOnly'] = True
if self.__domain:
Expand All @@ -226,10 +245,11 @@ def _apply_cookie_attrs(self):
self.cookie[self._sid]['Secure'] = True
if self.__same_site:
self.cookie[self._sid]['SameSite'] = self.__same_site
if self.__expires:
self.cookie[self._sid]['expires'] = self.__expires
if self.__max_age is not None:
self.cookie[self._sid]['Max-Age'] = self.__max_age
if not self._destroyed:
if self.__expires:
self.cookie[self._sid]['expires'] = self.__expires
if self.__max_age is not None:
self.cookie[self._sid]['Max-Age'] = self.__max_age

def load(self, cookies: Optional[SimpleCookie]):
"""Load the session value from the request's cookies.
Expand Down Expand Up @@ -259,6 +279,7 @@ def destroy(self):
Ensures that data cannot be changed:
https://stackoverflow.com/a/5285982/8379994
"""
self._destroyed = True
self.cookie[self._sid]['expires'] = -1
if self.__max_age is not None:
self.cookie[self._sid]['Max-Age'] = -1
Expand Down Expand Up @@ -332,7 +353,7 @@ def to_dict(self):
def __init__(self, secret_key: Union[str, bytes],
expires: int = 0, max_age: Optional[int] = None,
domain: str = '', path: str = '/', secure: bool = False,
same_site: Union[str, bool] = False, compress=bz2,
same_site: Union[str, Literal[False]] = False, compress=bz2,
sid: str = 'SESSID'):
"""Constructor.

Expand All @@ -352,9 +373,12 @@ def __init__(self, secret_key: Union[str, bytes],
secure
If the ``Secure`` cookie attribute will be sent.
same_site
The ``SameSite`` attribute. When set, it can be one of
``Strict|Lax|None``. By default, the attribute is not
set, which browsers default to ``Lax``.
The ``SameSite`` cookie attribute. Accepted values are
``'Strict'``, ``'Lax'``, ``'None'``, or ``False``.
``False`` (the default) omits the attribute entirely,
which browsers treat as ``'Lax'``. ``'None'`` permits
the cookie in cross-site requests but requires
``secure=True``.
compress
Can be ``bz2``, ``gzip.zlib``, or any other, which has
standard compress and decompress methods. Or it can be
Expand Down Expand Up @@ -383,6 +407,14 @@ def __init__(self, secret_key: Union[str, bytes],

*Changed in version 2.4.x*: Use app.secret_key in the
constructor, and then call the load method.

Raises:
ValueError
If *same_site* is not one of ``'Strict'``, ``'Lax'``,
``'None'``, or ``False``; or if *same_site* is ``'None'``
and *secure* is ``False``.
SessionError
If *secret_key* is empty.
"""
super().__init__(expires=expires, max_age=max_age, domain=domain,
path=path, secure=secure, same_site=same_site,
Expand Down
Loading
Loading