httpdis 0.6.30__tar.gz → 0.6.32__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {httpdis-0.6.30/httpdis.egg-info → httpdis-0.6.32}/PKG-INFO +45 -8
- {httpdis-0.6.30 → httpdis-0.6.32}/README.md +44 -7
- httpdis-0.6.32/RELEASE +1 -0
- httpdis-0.6.32/VERSION +1 -0
- httpdis-0.6.32/httpdis/auth_browser.py +153 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/httpdis.py +19 -9
- {httpdis-0.6.30 → httpdis-0.6.32/httpdis.egg-info}/PKG-INFO +45 -8
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis.egg-info/SOURCES.txt +2 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/setup.yml +2 -2
- httpdis-0.6.32/tests/test_auth_browser.py +111 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_context.py +48 -0
- httpdis-0.6.30/RELEASE +0 -1
- httpdis-0.6.30/VERSION +0 -1
- {httpdis-0.6.30 → httpdis-0.6.32}/LICENSE +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/MANIFEST.in +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/__init__.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/auth_backend.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/auth_sqlite.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/authentication.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/config.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/ext/__init__.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/ext/httpdis_json.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis.egg-info/dependency_links.txt +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis.egg-info/requires.txt +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/httpdis.egg-info/top_level.txt +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/pyproject.toml +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/requirements.txt +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/setup.cfg +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/setup.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_auth_backend.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_auth_sqlite.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_authentication_provider.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_body_limits.py +0 -0
- {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_httpdis.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpdis
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.32
|
|
4
4
|
Summary: httpdis
|
|
5
5
|
Home-page: https://github.com/decryptus/httpdis
|
|
6
6
|
Author: Adrien Delle Cave
|
|
@@ -237,7 +237,10 @@ Routes, authentication, response formats, route/global body-limit fallback and
|
|
|
237
237
|
lifecycle callback arguments remain compatible. Handler classes are now bound
|
|
238
238
|
through a subclass rather than modified in place. Repeated `stop()` calls no
|
|
239
239
|
longer repeat shutdown hooks. Startup/serving failures close the created socket,
|
|
240
|
-
and shutdown-hook failures
|
|
240
|
+
and shutdown-hook failures do not skip subsequent hooks or transport cleanup.
|
|
241
|
+
Shutdown attempts every registered cleanup and re-raises the first failure with
|
|
242
|
+
its traceback, even if later hooks or transport cleanup also fail. Reentrant
|
|
243
|
+
and repeated `stop()` calls do not run the hooks again.
|
|
241
244
|
|
|
242
245
|
The default schema is defined once in `httpdis.config`. Both historical
|
|
243
246
|
`DEFAULT_OPTIONS` import paths remain independent dictionaries with the same
|
|
@@ -462,9 +465,43 @@ is accepted; incompatible databases are refused, never silently reset. JSON
|
|
|
462
465
|
records are bounded to 64 KiB and support the existing four namespaces. Redis
|
|
463
466
|
and automatic schema migrations are not supplied by this adapter.
|
|
464
467
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
468
|
+
### Browser session transport
|
|
469
|
+
|
|
470
|
+
`httpdis.auth_browser.BrowserAuthProvider(service, origin, cookie_prefix='httpdis')`
|
|
471
|
+
adds an opt-in cookie adapter over the same `LocalAuthService`. The daemon supplies
|
|
472
|
+
the service, backend, public origin and lifecycle; HTTPdis installs no routes.
|
|
473
|
+
`BearerAuthProvider` remains token-only and legacy Basic behavior is unchanged.
|
|
474
|
+
|
|
475
|
+
The provider's `login(AuthenticationRequest, principal, password)` returns the
|
|
476
|
+
existing redacted session grant. Send `provider.cookie(grant.secret, max_age)` as
|
|
477
|
+
`Set-Cookie`, and return only the CSRF value and identity to browser JavaScript.
|
|
478
|
+
Do not return the session secret in JSON or store it in browser JavaScript storage.
|
|
479
|
+
Use `provider.logout(request)` before sending `provider.expired_cookie()`.
|
|
480
|
+
Login rotates any previous cookie session, and logout requires the current CSRF.
|
|
481
|
+
The service already enforces password hashing, login limits, idle/absolute expiry,
|
|
482
|
+
account revision, disable and persistent revocation.
|
|
483
|
+
|
|
484
|
+
HTTPS cookies use `__Host-<prefix>-session`, `Secure`, `HttpOnly`, `SameSite=Strict`,
|
|
485
|
+
`Path=/`, a bounded `Max-Age`, and no Domain attribute. HTTP is accepted only for
|
|
486
|
+
literal `127.0.0.1` / `::1` development origins, with an unprefixed non-Secure cookie.
|
|
487
|
+
The configured origin must have no path; standard ports are canonicalized.
|
|
488
|
+
Use a dedicated origin: unrelated applications on the same origin share browser
|
|
489
|
+
security boundaries. Behind TLS termination, configure the external HTTPS origin,
|
|
490
|
+
preserve Host, and keep direct backend access private. Forwarded headers are never
|
|
491
|
+
trusted to choose the origin or weaken cookie policy.
|
|
492
|
+
|
|
493
|
+
Cookie reads require the configured Host and reject foreign Origin/fetch metadata.
|
|
494
|
+
Every cookie mutation requires exact Origin, JSON Content-Type and X-CSRF-Token.
|
|
495
|
+
Login requires the same Origin/JSON checks even before a session exists. Duplicate
|
|
496
|
+
security headers/cookies and mixed Bearer/session credentials are rejected. Explicit
|
|
497
|
+
Bearer clients without browser metadata continue to work without cookies or Origin.
|
|
498
|
+
|
|
499
|
+
Embedding requirements: serve only fixed public login/assets routes; protect all
|
|
500
|
+
business routes; bound login request bodies; prohibit CORS/preflight in browser
|
|
501
|
+
mode; apply no-store, CSP, frame denial and nosniff headers to errors as well as
|
|
502
|
+
successes; never log credentials, payloads or cookies. `require_browser` is exposed
|
|
503
|
+
for checking public routes before parsing their body. HTTPdis's historical generic
|
|
504
|
+
OPTIONS behavior is unchanged: override it in the embedding browser handler.
|
|
505
|
+
Application scopes, ownership and action confirmation remain application policy.
|
|
506
|
+
This adapter supplies no HTML UI, proxy configuration, TOTP or SSO.
|
|
507
|
+
|
|
@@ -190,7 +190,10 @@ Routes, authentication, response formats, route/global body-limit fallback and
|
|
|
190
190
|
lifecycle callback arguments remain compatible. Handler classes are now bound
|
|
191
191
|
through a subclass rather than modified in place. Repeated `stop()` calls no
|
|
192
192
|
longer repeat shutdown hooks. Startup/serving failures close the created socket,
|
|
193
|
-
and shutdown-hook failures
|
|
193
|
+
and shutdown-hook failures do not skip subsequent hooks or transport cleanup.
|
|
194
|
+
Shutdown attempts every registered cleanup and re-raises the first failure with
|
|
195
|
+
its traceback, even if later hooks or transport cleanup also fail. Reentrant
|
|
196
|
+
and repeated `stop()` calls do not run the hooks again.
|
|
194
197
|
|
|
195
198
|
The default schema is defined once in `httpdis.config`. Both historical
|
|
196
199
|
`DEFAULT_OPTIONS` import paths remain independent dictionaries with the same
|
|
@@ -415,9 +418,43 @@ is accepted; incompatible databases are refused, never silently reset. JSON
|
|
|
415
418
|
records are bounded to 64 KiB and support the existing four namespaces. Redis
|
|
416
419
|
and automatic schema migrations are not supplied by this adapter.
|
|
417
420
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
421
|
+
### Browser session transport
|
|
422
|
+
|
|
423
|
+
`httpdis.auth_browser.BrowserAuthProvider(service, origin, cookie_prefix='httpdis')`
|
|
424
|
+
adds an opt-in cookie adapter over the same `LocalAuthService`. The daemon supplies
|
|
425
|
+
the service, backend, public origin and lifecycle; HTTPdis installs no routes.
|
|
426
|
+
`BearerAuthProvider` remains token-only and legacy Basic behavior is unchanged.
|
|
427
|
+
|
|
428
|
+
The provider's `login(AuthenticationRequest, principal, password)` returns the
|
|
429
|
+
existing redacted session grant. Send `provider.cookie(grant.secret, max_age)` as
|
|
430
|
+
`Set-Cookie`, and return only the CSRF value and identity to browser JavaScript.
|
|
431
|
+
Do not return the session secret in JSON or store it in browser JavaScript storage.
|
|
432
|
+
Use `provider.logout(request)` before sending `provider.expired_cookie()`.
|
|
433
|
+
Login rotates any previous cookie session, and logout requires the current CSRF.
|
|
434
|
+
The service already enforces password hashing, login limits, idle/absolute expiry,
|
|
435
|
+
account revision, disable and persistent revocation.
|
|
436
|
+
|
|
437
|
+
HTTPS cookies use `__Host-<prefix>-session`, `Secure`, `HttpOnly`, `SameSite=Strict`,
|
|
438
|
+
`Path=/`, a bounded `Max-Age`, and no Domain attribute. HTTP is accepted only for
|
|
439
|
+
literal `127.0.0.1` / `::1` development origins, with an unprefixed non-Secure cookie.
|
|
440
|
+
The configured origin must have no path; standard ports are canonicalized.
|
|
441
|
+
Use a dedicated origin: unrelated applications on the same origin share browser
|
|
442
|
+
security boundaries. Behind TLS termination, configure the external HTTPS origin,
|
|
443
|
+
preserve Host, and keep direct backend access private. Forwarded headers are never
|
|
444
|
+
trusted to choose the origin or weaken cookie policy.
|
|
445
|
+
|
|
446
|
+
Cookie reads require the configured Host and reject foreign Origin/fetch metadata.
|
|
447
|
+
Every cookie mutation requires exact Origin, JSON Content-Type and X-CSRF-Token.
|
|
448
|
+
Login requires the same Origin/JSON checks even before a session exists. Duplicate
|
|
449
|
+
security headers/cookies and mixed Bearer/session credentials are rejected. Explicit
|
|
450
|
+
Bearer clients without browser metadata continue to work without cookies or Origin.
|
|
451
|
+
|
|
452
|
+
Embedding requirements: serve only fixed public login/assets routes; protect all
|
|
453
|
+
business routes; bound login request bodies; prohibit CORS/preflight in browser
|
|
454
|
+
mode; apply no-store, CSP, frame denial and nosniff headers to errors as well as
|
|
455
|
+
successes; never log credentials, payloads or cookies. `require_browser` is exposed
|
|
456
|
+
for checking public routes before parsing their body. HTTPdis's historical generic
|
|
457
|
+
OPTIONS behavior is unchanged: override it in the embedding browser handler.
|
|
458
|
+
Application scopes, ownership and action confirmation remain application policy.
|
|
459
|
+
This adapter supplies no HTML UI, proxy configuration, TOTP or SSO.
|
|
460
|
+
|
httpdis-0.6.32/RELEASE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.6.32
|
httpdis-0.6.32/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.6.32
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Opt-in browser transport policy over an application-owned LocalAuthService.
|
|
3
|
+
|
|
4
|
+
No routes, database paths, proxy trust or default backend are installed here.
|
|
5
|
+
The embedding server must expose login/logout, bound JSON bodies, disable CORS
|
|
6
|
+
and serve the configured public HTTPS origin (HTTP is local development only).
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
from six import string_types
|
|
10
|
+
from six.moves.urllib.parse import urlsplit
|
|
11
|
+
from .authentication import AuthenticationDenied, AuthenticationProvider, AUTH_HEADER_NAMES
|
|
12
|
+
from .auth_backend import BearerAuthProvider
|
|
13
|
+
|
|
14
|
+
SAFE_METHODS = frozenset(('GET', 'HEAD'))
|
|
15
|
+
MAX_COOKIE_BYTES = 8192
|
|
16
|
+
COOKIE_PREFIX_PATTERN = re.compile(r'^[a-z][a-z0-9-]{0,39}$')
|
|
17
|
+
OPAQUE_PATTERN = re.compile(r'^[A-Za-z0-9_-]{43}$')
|
|
18
|
+
ORIGIN_AUTHORITY_PATTERN = re.compile(r'^(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.\-]+)(?::([0-9]{1,5}))?$')
|
|
19
|
+
LOOPBACK_HOSTS = frozenset(('127.0.0.1', '::1'))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def browser_origin(value):
|
|
23
|
+
"""Validate and canonicalize an explicit public origin; never trust Forwarded."""
|
|
24
|
+
try:
|
|
25
|
+
if (not isinstance(value, string_types) or not value or len(value) > 2048
|
|
26
|
+
or any(ord(c) <= 32 or ord(c) >= 127 for c in value)):
|
|
27
|
+
raise ValueError()
|
|
28
|
+
parsed = urlsplit(value)
|
|
29
|
+
if (parsed.scheme not in ('https', 'http') or not parsed.hostname
|
|
30
|
+
or parsed.username is not None or parsed.password is not None
|
|
31
|
+
or parsed.path or parsed.query or parsed.fragment or '?' in value or '#' in value
|
|
32
|
+
or not ORIGIN_AUTHORITY_PATTERN.match(parsed.netloc)
|
|
33
|
+
or parsed.netloc.endswith(':')):
|
|
34
|
+
raise ValueError()
|
|
35
|
+
# Python 2.7/3.5 can turn an out-of-range .port into None. Validate the
|
|
36
|
+
# literal authority ourselves so supported interpreters agree.
|
|
37
|
+
authority_match = ORIGIN_AUTHORITY_PATTERN.match(parsed.netloc)
|
|
38
|
+
port = int(authority_match.group(2)) if authority_match.group(2) is not None else None
|
|
39
|
+
if port is not None and not 1 <= port <= 65535:
|
|
40
|
+
raise ValueError()
|
|
41
|
+
if parsed.scheme == 'http' and parsed.hostname not in LOOPBACK_HOSTS:
|
|
42
|
+
raise ValueError()
|
|
43
|
+
host = parsed.hostname.lower()
|
|
44
|
+
authority = '[%s]' % host if ':' in host else host
|
|
45
|
+
if port is not None and port != (443 if parsed.scheme == 'https' else 80):
|
|
46
|
+
authority += ':%s' % port
|
|
47
|
+
return parsed.scheme + '://' + authority
|
|
48
|
+
except (ValueError, TypeError):
|
|
49
|
+
raise ValueError('browser origin must be HTTPS, or HTTP on 127.0.0.1/::1, without a path')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BrowserAuthProvider(AuthenticationProvider):
|
|
53
|
+
"""Cookies for browsers, Bearer for explicit API callers, never fallback.
|
|
54
|
+
|
|
55
|
+
Session secrets stay in HttpOnly cookies. The login grant's separate CSRF
|
|
56
|
+
value must be sent in X-CSRF-Token for every mutation, including logout.
|
|
57
|
+
Cookie and Bearer credentials cannot be combined in one request.
|
|
58
|
+
"""
|
|
59
|
+
challenge = 'Bearer'
|
|
60
|
+
|
|
61
|
+
def __init__(self, service, origin, cookie_prefix='httpdis'):
|
|
62
|
+
if (not isinstance(cookie_prefix, string_types)
|
|
63
|
+
or not COOKIE_PREFIX_PATTERN.match(cookie_prefix)
|
|
64
|
+
or COOKIE_PREFIX_PATTERN.match(cookie_prefix).end() != len(cookie_prefix)):
|
|
65
|
+
raise ValueError('invalid cookie prefix')
|
|
66
|
+
self.service = service
|
|
67
|
+
self.origin = browser_origin(origin)
|
|
68
|
+
self.authority = urlsplit(self.origin).netloc
|
|
69
|
+
self.secure = self.origin.startswith('https://')
|
|
70
|
+
self.cookie_name = ('__Host-' if self.secure else '') + cookie_prefix + '-session'
|
|
71
|
+
self.bearer = BearerAuthProvider(service)
|
|
72
|
+
|
|
73
|
+
def require_browser(self, request, mutation=False):
|
|
74
|
+
for name in AUTH_HEADER_NAMES:
|
|
75
|
+
request.header(name) # Reject duplicate security headers on public login too.
|
|
76
|
+
if request.header('host', '').lower() != self.authority:
|
|
77
|
+
raise AuthenticationDenied()
|
|
78
|
+
origin = request.header('origin')
|
|
79
|
+
if origin is not None and origin != self.origin:
|
|
80
|
+
raise AuthenticationDenied()
|
|
81
|
+
if request.header('sec-fetch-site') not in (None, 'same-origin', 'none'):
|
|
82
|
+
raise AuthenticationDenied()
|
|
83
|
+
if mutation:
|
|
84
|
+
ctype = request.header('content-type', '').split(';', 1)[0].strip().lower()
|
|
85
|
+
if origin != self.origin or ctype != 'application/json':
|
|
86
|
+
raise AuthenticationDenied()
|
|
87
|
+
|
|
88
|
+
def session_secret(self, request):
|
|
89
|
+
cookie = request.header('cookie', '')
|
|
90
|
+
if not isinstance(cookie, string_types) or len(cookie) > MAX_COOKIE_BYTES:
|
|
91
|
+
raise AuthenticationDenied()
|
|
92
|
+
values = []
|
|
93
|
+
for part in cookie.split(';'):
|
|
94
|
+
name, sep, value = part.strip().partition('=')
|
|
95
|
+
if name == self.cookie_name:
|
|
96
|
+
if not sep:
|
|
97
|
+
raise AuthenticationDenied()
|
|
98
|
+
values.append(value)
|
|
99
|
+
if not values:
|
|
100
|
+
return None
|
|
101
|
+
if len(values) != 1 or not self._opaque(values[0]):
|
|
102
|
+
raise AuthenticationDenied()
|
|
103
|
+
return values[0]
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _opaque(value):
|
|
107
|
+
return (isinstance(value, string_types) and len(value) == 43
|
|
108
|
+
and OPAQUE_PATTERN.match(value) is not None)
|
|
109
|
+
|
|
110
|
+
def authenticate(self, request):
|
|
111
|
+
secret = self.session_secret(request)
|
|
112
|
+
if request.header('authorization') is not None:
|
|
113
|
+
if secret is not None:
|
|
114
|
+
raise AuthenticationDenied()
|
|
115
|
+
if request.header('origin') is not None or request.header('sec-fetch-site') is not None:
|
|
116
|
+
self.require_browser(request, request.method not in SAFE_METHODS)
|
|
117
|
+
return self.bearer.authenticate(request)
|
|
118
|
+
mutation = request.method not in SAFE_METHODS
|
|
119
|
+
self.require_browser(request, mutation)
|
|
120
|
+
if secret is None:
|
|
121
|
+
raise AuthenticationDenied()
|
|
122
|
+
return self.service.authenticate_session(secret, request.header('x-csrf-token'), mutation)
|
|
123
|
+
|
|
124
|
+
def login(self, request, principal, password):
|
|
125
|
+
self.require_browser(request, mutation=True)
|
|
126
|
+
if request.method != 'POST' or request.header('authorization') is not None:
|
|
127
|
+
raise AuthenticationDenied()
|
|
128
|
+
previous = self.session_secret(request)
|
|
129
|
+
grant = self.service.login(principal, password, request.peer)
|
|
130
|
+
# Never adopt a supplied session identifier. Reauthentication rotates it.
|
|
131
|
+
if previous is not None:
|
|
132
|
+
try:
|
|
133
|
+
self.service.logout(previous)
|
|
134
|
+
except Exception:
|
|
135
|
+
self.service.logout(grant.secret)
|
|
136
|
+
raise
|
|
137
|
+
return grant
|
|
138
|
+
|
|
139
|
+
def logout(self, request):
|
|
140
|
+
if request.method != 'POST' or request.header('authorization') is not None:
|
|
141
|
+
raise AuthenticationDenied()
|
|
142
|
+
self.authenticate(request)
|
|
143
|
+
self.service.logout(self.session_secret(request))
|
|
144
|
+
|
|
145
|
+
def cookie(self, secret, max_age):
|
|
146
|
+
if not self._opaque(secret) or type(max_age) is not int or max_age < 0:
|
|
147
|
+
raise ValueError('invalid session cookie')
|
|
148
|
+
return '%s=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Strict%s' % (
|
|
149
|
+
self.cookie_name, secret, max_age, '; Secure' if self.secure else '')
|
|
150
|
+
|
|
151
|
+
def expired_cookie(self):
|
|
152
|
+
return '%s=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict%s' % (
|
|
153
|
+
self.cookie_name, '; Secure' if self.secure else '')
|
|
@@ -56,7 +56,8 @@ from six import (BytesIO,
|
|
|
56
56
|
ensure_binary,
|
|
57
57
|
ensure_text,
|
|
58
58
|
integer_types,
|
|
59
|
-
iteritems
|
|
59
|
+
iteritems,
|
|
60
|
+
reraise)
|
|
60
61
|
from six.moves import http_cookies
|
|
61
62
|
from six.moves.urllib import parse as urlparse, request as urlrequest
|
|
62
63
|
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler
|
|
@@ -1306,17 +1307,26 @@ class HttpServerContext(object):
|
|
|
1306
1307
|
server = self.server
|
|
1307
1308
|
# Application callbacks may wait for workers. Do not hold the lifecycle
|
|
1308
1309
|
# lock while they run; the serving thread must be able to finish.
|
|
1310
|
+
cleanups = [(name, cmd.at_stop) for name, cmd in iteritems(self.commands)
|
|
1311
|
+
if cmd.at_stop]
|
|
1312
|
+
if server:
|
|
1313
|
+
cleanups.extend((('server.kill', server.kill),
|
|
1314
|
+
('server.server_close', server.server_close)))
|
|
1315
|
+
first_error = None
|
|
1309
1316
|
try:
|
|
1310
|
-
for name,
|
|
1311
|
-
|
|
1317
|
+
for name, cleanup in cleanups:
|
|
1318
|
+
try:
|
|
1312
1319
|
LOG.info("at_stop: %r", name)
|
|
1313
|
-
|
|
1320
|
+
cleanup()
|
|
1321
|
+
except BaseException:
|
|
1322
|
+
if first_error is None:
|
|
1323
|
+
first_error = sys.exc_info()
|
|
1324
|
+
LOG.error('HTTP shutdown cleanup failed: %r', name)
|
|
1325
|
+
if first_error is not None:
|
|
1326
|
+
reraise(*first_error)
|
|
1314
1327
|
finally:
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
server.kill()
|
|
1318
|
-
finally:
|
|
1319
|
-
server.server_close()
|
|
1328
|
+
# Do not retain this frame through the saved exception traceback.
|
|
1329
|
+
first_error = None
|
|
1320
1330
|
|
|
1321
1331
|
def run(self, options=None, http_req_handler=HttpReqHandler,
|
|
1322
1332
|
http_server_class=KillableThreadingHTTPServer):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpdis
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.32
|
|
4
4
|
Summary: httpdis
|
|
5
5
|
Home-page: https://github.com/decryptus/httpdis
|
|
6
6
|
Author: Adrien Delle Cave
|
|
@@ -237,7 +237,10 @@ Routes, authentication, response formats, route/global body-limit fallback and
|
|
|
237
237
|
lifecycle callback arguments remain compatible. Handler classes are now bound
|
|
238
238
|
through a subclass rather than modified in place. Repeated `stop()` calls no
|
|
239
239
|
longer repeat shutdown hooks. Startup/serving failures close the created socket,
|
|
240
|
-
and shutdown-hook failures
|
|
240
|
+
and shutdown-hook failures do not skip subsequent hooks or transport cleanup.
|
|
241
|
+
Shutdown attempts every registered cleanup and re-raises the first failure with
|
|
242
|
+
its traceback, even if later hooks or transport cleanup also fail. Reentrant
|
|
243
|
+
and repeated `stop()` calls do not run the hooks again.
|
|
241
244
|
|
|
242
245
|
The default schema is defined once in `httpdis.config`. Both historical
|
|
243
246
|
`DEFAULT_OPTIONS` import paths remain independent dictionaries with the same
|
|
@@ -462,9 +465,43 @@ is accepted; incompatible databases are refused, never silently reset. JSON
|
|
|
462
465
|
records are bounded to 64 KiB and support the existing four namespaces. Redis
|
|
463
466
|
and automatic schema migrations are not supplied by this adapter.
|
|
464
467
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
468
|
+
### Browser session transport
|
|
469
|
+
|
|
470
|
+
`httpdis.auth_browser.BrowserAuthProvider(service, origin, cookie_prefix='httpdis')`
|
|
471
|
+
adds an opt-in cookie adapter over the same `LocalAuthService`. The daemon supplies
|
|
472
|
+
the service, backend, public origin and lifecycle; HTTPdis installs no routes.
|
|
473
|
+
`BearerAuthProvider` remains token-only and legacy Basic behavior is unchanged.
|
|
474
|
+
|
|
475
|
+
The provider's `login(AuthenticationRequest, principal, password)` returns the
|
|
476
|
+
existing redacted session grant. Send `provider.cookie(grant.secret, max_age)` as
|
|
477
|
+
`Set-Cookie`, and return only the CSRF value and identity to browser JavaScript.
|
|
478
|
+
Do not return the session secret in JSON or store it in browser JavaScript storage.
|
|
479
|
+
Use `provider.logout(request)` before sending `provider.expired_cookie()`.
|
|
480
|
+
Login rotates any previous cookie session, and logout requires the current CSRF.
|
|
481
|
+
The service already enforces password hashing, login limits, idle/absolute expiry,
|
|
482
|
+
account revision, disable and persistent revocation.
|
|
483
|
+
|
|
484
|
+
HTTPS cookies use `__Host-<prefix>-session`, `Secure`, `HttpOnly`, `SameSite=Strict`,
|
|
485
|
+
`Path=/`, a bounded `Max-Age`, and no Domain attribute. HTTP is accepted only for
|
|
486
|
+
literal `127.0.0.1` / `::1` development origins, with an unprefixed non-Secure cookie.
|
|
487
|
+
The configured origin must have no path; standard ports are canonicalized.
|
|
488
|
+
Use a dedicated origin: unrelated applications on the same origin share browser
|
|
489
|
+
security boundaries. Behind TLS termination, configure the external HTTPS origin,
|
|
490
|
+
preserve Host, and keep direct backend access private. Forwarded headers are never
|
|
491
|
+
trusted to choose the origin or weaken cookie policy.
|
|
492
|
+
|
|
493
|
+
Cookie reads require the configured Host and reject foreign Origin/fetch metadata.
|
|
494
|
+
Every cookie mutation requires exact Origin, JSON Content-Type and X-CSRF-Token.
|
|
495
|
+
Login requires the same Origin/JSON checks even before a session exists. Duplicate
|
|
496
|
+
security headers/cookies and mixed Bearer/session credentials are rejected. Explicit
|
|
497
|
+
Bearer clients without browser metadata continue to work without cookies or Origin.
|
|
498
|
+
|
|
499
|
+
Embedding requirements: serve only fixed public login/assets routes; protect all
|
|
500
|
+
business routes; bound login request bodies; prohibit CORS/preflight in browser
|
|
501
|
+
mode; apply no-store, CSP, frame denial and nosniff headers to errors as well as
|
|
502
|
+
successes; never log credentials, payloads or cookies. `require_browser` is exposed
|
|
503
|
+
for checking public routes before parsing their body. HTTPdis's historical generic
|
|
504
|
+
OPTIONS behavior is unchanged: override it in the embedding browser handler.
|
|
505
|
+
Application scopes, ownership and action confirmation remain application policy.
|
|
506
|
+
This adapter supplies no HTML UI, proxy configuration, TOTP or SSO.
|
|
507
|
+
|
|
@@ -10,6 +10,7 @@ setup.py
|
|
|
10
10
|
setup.yml
|
|
11
11
|
httpdis/__init__.py
|
|
12
12
|
httpdis/auth_backend.py
|
|
13
|
+
httpdis/auth_browser.py
|
|
13
14
|
httpdis/auth_sqlite.py
|
|
14
15
|
httpdis/authentication.py
|
|
15
16
|
httpdis/config.py
|
|
@@ -22,6 +23,7 @@ httpdis.egg-info/top_level.txt
|
|
|
22
23
|
httpdis/ext/__init__.py
|
|
23
24
|
httpdis/ext/httpdis_json.py
|
|
24
25
|
tests/test_auth_backend.py
|
|
26
|
+
tests/test_auth_browser.py
|
|
25
27
|
tests/test_auth_sqlite.py
|
|
26
28
|
tests/test_authentication_provider.py
|
|
27
29
|
tests/test_body_limits.py
|
|
@@ -4,8 +4,8 @@ description: httpdis
|
|
|
4
4
|
author: Adrien Delle Cave
|
|
5
5
|
author_email: pypi@doowan.net
|
|
6
6
|
copyright: '2022 Adrien Delle Cave'
|
|
7
|
-
release: '0.6.
|
|
8
|
-
version: '0.6.
|
|
7
|
+
release: '0.6.32'
|
|
8
|
+
version: '0.6.32'
|
|
9
9
|
license: License GPL-3
|
|
10
10
|
url: https://github.com/decryptus/httpdis
|
|
11
11
|
python_requires:
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Browser policy against real session services; no framework request dependency."""
|
|
2
|
+
import unittest
|
|
3
|
+
from httpdis.auth_backend import LocalAuthService, MemoryAuthStore
|
|
4
|
+
from httpdis.auth_browser import BrowserAuthProvider, browser_origin
|
|
5
|
+
from httpdis.authentication import AuthenticationDenied, AuthenticationRequest
|
|
6
|
+
from test_auth_backend import FixturePasswords
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BrowserTests(unittest.TestCase):
|
|
10
|
+
def setUp(self):
|
|
11
|
+
self.now = 100
|
|
12
|
+
self.service = LocalAuthService(MemoryAuthStore(), FixturePasswords(), clock=lambda: self.now,
|
|
13
|
+
session_ttl=100, idle_ttl=20)
|
|
14
|
+
self.service.provision('alice', 'correct-password', ['read', 'run'])
|
|
15
|
+
self.provider = BrowserAuthProvider(self.service, 'https://auton.example', 'autond')
|
|
16
|
+
|
|
17
|
+
def request(self, method='GET', cookie=None, csrf=None, **headers):
|
|
18
|
+
values = {'host': 'auton.example', 'origin': 'https://auton.example',
|
|
19
|
+
'content-type': 'application/json', 'sec-fetch-site': 'same-origin'}
|
|
20
|
+
if cookie is not None:
|
|
21
|
+
values['cookie'] = cookie
|
|
22
|
+
if csrf is not None:
|
|
23
|
+
values['x-csrf-token'] = csrf
|
|
24
|
+
values.update(headers)
|
|
25
|
+
return AuthenticationRequest(method, '/route', tuple((k, v) for k, v in values.items() if v is not None), '127.0.0.1')
|
|
26
|
+
|
|
27
|
+
def login(self):
|
|
28
|
+
return self.provider.login(self.request('POST'), 'alice', 'correct-password')
|
|
29
|
+
|
|
30
|
+
def cookie(self, grant):
|
|
31
|
+
return self.provider.cookie_name + '=' + grant.secret
|
|
32
|
+
|
|
33
|
+
def test_login_cookie_csrf_logout_and_rotation(self):
|
|
34
|
+
first = self.login()
|
|
35
|
+
cookie = self.cookie(first)
|
|
36
|
+
header = self.provider.cookie(first.secret, 100)
|
|
37
|
+
self.assertTrue(header.startswith('__Host-autond-session='))
|
|
38
|
+
for flag in ('HttpOnly', 'SameSite=Strict', 'Secure', 'Path=/', 'Max-Age=100'):
|
|
39
|
+
self.assertIn(flag, header)
|
|
40
|
+
self.assertNotIn('Domain', header)
|
|
41
|
+
self.assertEqual(self.provider.authenticate(self.request(cookie=cookie)).principal, 'alice')
|
|
42
|
+
for csrf in (None, 'wrong'):
|
|
43
|
+
with self.assertRaises(AuthenticationDenied):
|
|
44
|
+
self.provider.authenticate(self.request('POST', cookie, csrf))
|
|
45
|
+
self.assertEqual(self.provider.authenticate(self.request('POST', cookie, first.csrf)).method, 'session')
|
|
46
|
+
second = self.provider.login(self.request('POST', cookie), 'alice', 'correct-password')
|
|
47
|
+
self.assertNotEqual(second.secret, first.secret)
|
|
48
|
+
with self.assertRaises(AuthenticationDenied):
|
|
49
|
+
self.provider.authenticate(self.request(cookie=cookie))
|
|
50
|
+
self.provider.logout(self.request('POST', self.cookie(second), second.csrf))
|
|
51
|
+
with self.assertRaises(AuthenticationDenied):
|
|
52
|
+
self.provider.authenticate(self.request(cookie=self.cookie(second)))
|
|
53
|
+
self.assertIn('Max-Age=0', self.provider.expired_cookie())
|
|
54
|
+
|
|
55
|
+
def test_origin_host_fetch_metadata_and_json_before_login(self):
|
|
56
|
+
for headers in ({'origin': None}, {'origin': 'null'}, {'origin': 'https://evil.example'},
|
|
57
|
+
{'host': 'evil.example'}, {'sec-fetch-site': 'same-site'},
|
|
58
|
+
{'sec-fetch-site': 'cross-site'}, {'content-type': 'text/plain'},
|
|
59
|
+
{'content-type': 'application/x-www-form-urlencoded'}, {'authorization': 'Basic bad'}):
|
|
60
|
+
with self.assertRaises(AuthenticationDenied):
|
|
61
|
+
self.provider.login(self.request('POST', **headers), 'alice', 'correct-password')
|
|
62
|
+
with self.service.store.transaction() as tx:
|
|
63
|
+
self.assertEqual(tx.items('sessions'), [])
|
|
64
|
+
self.assertEqual(tx.items('attempts'), [])
|
|
65
|
+
grant = self.login()
|
|
66
|
+
for headers in ({'origin': 'https://evil.example'}, {'host': 'evil.example'},
|
|
67
|
+
{'sec-fetch-site': 'same-site'}):
|
|
68
|
+
with self.assertRaises(AuthenticationDenied):
|
|
69
|
+
self.provider.authenticate(self.request(cookie=self.cookie(grant), **headers))
|
|
70
|
+
self.assertEqual(self.provider.authenticate(self.request(cookie=self.cookie(grant), origin=None)).principal, 'alice')
|
|
71
|
+
|
|
72
|
+
def test_no_ambiguous_cookie_or_credential_fallback(self):
|
|
73
|
+
grant = self.login()
|
|
74
|
+
token = self.service.issue_token('alice', ['read'], 20)
|
|
75
|
+
authorization = 'Bearer ' + token.secret
|
|
76
|
+
request = self.request(authorization=authorization, origin=None, **{'sec-fetch-site': None})
|
|
77
|
+
self.assertEqual(self.provider.authenticate(request).method, 'token')
|
|
78
|
+
for cookie in (self.cookie(grant) + '; ' + self.cookie(grant),
|
|
79
|
+
self.provider.cookie_name + '=bad', 'x=' + 'a' * 9000):
|
|
80
|
+
with self.assertRaises(AuthenticationDenied):
|
|
81
|
+
self.provider.authenticate(self.request(cookie=cookie))
|
|
82
|
+
with self.assertRaises(AuthenticationDenied):
|
|
83
|
+
self.provider.authenticate(self.request(cookie=self.cookie(grant), authorization=authorization))
|
|
84
|
+
with self.assertRaises(AuthenticationDenied):
|
|
85
|
+
self.provider.authenticate(self.request(authorization='Basic ignored'))
|
|
86
|
+
request = self.request('POST')
|
|
87
|
+
duplicate = request._replace(headers=request.headers + (('origin', 'https://auton.example'),))
|
|
88
|
+
with self.assertRaises(AuthenticationDenied):
|
|
89
|
+
self.provider.login(duplicate, 'alice', 'correct-password')
|
|
90
|
+
|
|
91
|
+
def test_session_expiry_revocation_and_principal_scopes_are_shared(self):
|
|
92
|
+
grant = self.login()
|
|
93
|
+
self.now += 21
|
|
94
|
+
with self.assertRaises(AuthenticationDenied):
|
|
95
|
+
self.provider.authenticate(self.request(cookie=self.cookie(grant)))
|
|
96
|
+
grant = self.login()
|
|
97
|
+
self.service.disable('alice')
|
|
98
|
+
with self.assertRaises(AuthenticationDenied):
|
|
99
|
+
self.provider.authenticate(self.request(cookie=self.cookie(grant)))
|
|
100
|
+
|
|
101
|
+
def test_origin_configuration_and_loopback_development(self):
|
|
102
|
+
self.assertEqual(browser_origin('https://Auton.Example:443'), 'https://auton.example')
|
|
103
|
+
for origin in ('http://remote.example', 'http://localhost', 'https://x/', 'https://x?',
|
|
104
|
+
'https://x#', 'https://user:pass@x', 'https://x:0', 'https://x:70000',
|
|
105
|
+
'https://x:\n', 'https://x\\evil', '', None):
|
|
106
|
+
with self.assertRaises(ValueError):
|
|
107
|
+
browser_origin(origin)
|
|
108
|
+
local = BrowserAuthProvider(self.service, 'http://127.0.0.1:8666', 'autond')
|
|
109
|
+
self.assertEqual(local.cookie_name, 'autond-session')
|
|
110
|
+
self.assertNotIn('Secure', local.cookie('a' * 43, 10))
|
|
111
|
+
self.assertEqual(browser_origin('http://[::1]:8666'), 'http://[::1]:8666')
|
|
@@ -246,6 +246,54 @@ class ContextTests(unittest.TestCase):
|
|
|
246
246
|
self.assertEqual(server.kill.call_count, 1)
|
|
247
247
|
self.assertEqual(server.server_close.call_count, 1)
|
|
248
248
|
|
|
249
|
+
def test_stop_runs_all_cleanup_and_preserves_first_failure(self):
|
|
250
|
+
# Include BaseException: an extension's SystemExit must not bypass cleanup.
|
|
251
|
+
for first_error in (ValueError('first'), SystemExit('first')):
|
|
252
|
+
context = h.HttpServerContext()
|
|
253
|
+
calls = []
|
|
254
|
+
def first():
|
|
255
|
+
calls.append('first')
|
|
256
|
+
context.stop() # Reentrant stop must not repeat callbacks.
|
|
257
|
+
raise first_error
|
|
258
|
+
def second():
|
|
259
|
+
calls.append('second')
|
|
260
|
+
raise RuntimeError('second')
|
|
261
|
+
def last():
|
|
262
|
+
calls.append('last')
|
|
263
|
+
context.register(lambda request: None, 'GET', name='first', at_stop=first)
|
|
264
|
+
context.register(lambda request: None, 'GET', name='second', at_stop=second)
|
|
265
|
+
context.register(lambda request: None, 'GET', name='last', at_stop=last)
|
|
266
|
+
# Dictionary order is not guaranteed on supported legacy interpreters.
|
|
267
|
+
from collections import OrderedDict
|
|
268
|
+
context.commands = OrderedDict((name, context.commands['GET /' + name])
|
|
269
|
+
for name in ('first', 'second', 'last'))
|
|
270
|
+
server = mock.Mock()
|
|
271
|
+
server.kill.side_effect = RuntimeError('kill')
|
|
272
|
+
server.server_close.side_effect = RuntimeError('close')
|
|
273
|
+
context.server = server
|
|
274
|
+
with self.assertRaises(type(first_error)) as caught:
|
|
275
|
+
context.stop()
|
|
276
|
+
self.assertIs(caught.exception, first_error)
|
|
277
|
+
self.assertEqual(calls, ['first', 'second', 'last'])
|
|
278
|
+
context.stop()
|
|
279
|
+
self.assertEqual(calls, ['first', 'second', 'last'])
|
|
280
|
+
self.assertEqual(server.kill.call_count, 1)
|
|
281
|
+
self.assertEqual(server.server_close.call_count, 1)
|
|
282
|
+
|
|
283
|
+
def test_transport_failure_still_attempts_close_and_is_propagated(self):
|
|
284
|
+
for failing_method in ('kill', 'server_close'):
|
|
285
|
+
context = h.HttpServerContext()
|
|
286
|
+
server = mock.Mock()
|
|
287
|
+
first_error = RuntimeError(failing_method)
|
|
288
|
+
getattr(server, failing_method).side_effect = first_error
|
|
289
|
+
context.server = server
|
|
290
|
+
with self.assertRaises(RuntimeError) as caught:
|
|
291
|
+
context.stop()
|
|
292
|
+
self.assertIs(caught.exception, first_error)
|
|
293
|
+
context.stop()
|
|
294
|
+
self.assertEqual(server.kill.call_count, 1)
|
|
295
|
+
self.assertEqual(server.server_close.call_count, 1)
|
|
296
|
+
|
|
249
297
|
def test_custom_stdlib_handler_class_can_be_bound(self):
|
|
250
298
|
from six.moves.BaseHTTPServer import BaseHTTPRequestHandler
|
|
251
299
|
bound = h.HttpServerContext().bind_handler(BaseHTTPRequestHandler)
|
httpdis-0.6.30/RELEASE
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.6.30
|
httpdis-0.6.30/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.6.30
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|