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.
Files changed (34) hide show
  1. {httpdis-0.6.30/httpdis.egg-info → httpdis-0.6.32}/PKG-INFO +45 -8
  2. {httpdis-0.6.30 → httpdis-0.6.32}/README.md +44 -7
  3. httpdis-0.6.32/RELEASE +1 -0
  4. httpdis-0.6.32/VERSION +1 -0
  5. httpdis-0.6.32/httpdis/auth_browser.py +153 -0
  6. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/httpdis.py +19 -9
  7. {httpdis-0.6.30 → httpdis-0.6.32/httpdis.egg-info}/PKG-INFO +45 -8
  8. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis.egg-info/SOURCES.txt +2 -0
  9. {httpdis-0.6.30 → httpdis-0.6.32}/setup.yml +2 -2
  10. httpdis-0.6.32/tests/test_auth_browser.py +111 -0
  11. {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_context.py +48 -0
  12. httpdis-0.6.30/RELEASE +0 -1
  13. httpdis-0.6.30/VERSION +0 -1
  14. {httpdis-0.6.30 → httpdis-0.6.32}/LICENSE +0 -0
  15. {httpdis-0.6.30 → httpdis-0.6.32}/MANIFEST.in +0 -0
  16. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/__init__.py +0 -0
  17. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/auth_backend.py +0 -0
  18. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/auth_sqlite.py +0 -0
  19. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/authentication.py +0 -0
  20. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/config.py +0 -0
  21. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/ext/__init__.py +0 -0
  22. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis/ext/httpdis_json.py +0 -0
  23. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis.egg-info/dependency_links.txt +0 -0
  24. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis.egg-info/requires.txt +0 -0
  25. {httpdis-0.6.30 → httpdis-0.6.32}/httpdis.egg-info/top_level.txt +0 -0
  26. {httpdis-0.6.30 → httpdis-0.6.32}/pyproject.toml +0 -0
  27. {httpdis-0.6.30 → httpdis-0.6.32}/requirements.txt +0 -0
  28. {httpdis-0.6.30 → httpdis-0.6.32}/setup.cfg +0 -0
  29. {httpdis-0.6.30 → httpdis-0.6.32}/setup.py +0 -0
  30. {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_auth_backend.py +0 -0
  31. {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_auth_sqlite.py +0 -0
  32. {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_authentication_provider.py +0 -0
  33. {httpdis-0.6.30 → httpdis-0.6.32}/tests/test_body_limits.py +0 -0
  34. {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.30
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 still trigger transport cleanup.
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
- **Browser integration is still pending:** this backend does not expose login/logout
466
- routes, set cookies, configure HTTPS/CORS, enforce request Origin or provide TOTP.
467
- The caller must mark every cookie-authenticated mutation for CSRF validation; do
468
- not expose a cookie provider without the complete browser transport protections.
469
- Only the token-only provider is supplied in this step. Session/login APIs are
470
- neutral services ready for the subsequent transport integration.
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 still trigger transport cleanup.
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
- **Browser integration is still pending:** this backend does not expose login/logout
419
- routes, set cookies, configure HTTPS/CORS, enforce request Origin or provide TOTP.
420
- The caller must mark every cookie-authenticated mutation for CSRF validation; do
421
- not expose a cookie provider without the complete browser transport protections.
422
- Only the token-only provider is supplied in this step. Session/login APIs are
423
- neutral services ready for the subsequent transport integration.
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, cmd in iteritems(self.commands):
1311
- if cmd.at_stop:
1317
+ for name, cleanup in cleanups:
1318
+ try:
1312
1319
  LOG.info("at_stop: %r", name)
1313
- cmd.at_stop()
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
- if server:
1316
- try:
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.30
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 still trigger transport cleanup.
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
- **Browser integration is still pending:** this backend does not expose login/logout
466
- routes, set cookies, configure HTTPS/CORS, enforce request Origin or provide TOTP.
467
- The caller must mark every cookie-authenticated mutation for CSRF validation; do
468
- not expose a cookie provider without the complete browser transport protections.
469
- Only the token-only provider is supplied in this step. Session/login APIs are
470
- neutral services ready for the subsequent transport integration.
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.30'
8
- version: '0.6.30'
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