mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-22 01:52:17 -05:00
Levels, principals, password hashing, cookies and login throttling. Pure library code -- nothing imports it into a request path yet. levels.py An ordered read < edit < manage scale. Ranks live in a separate mapping rather than as comparison operators on the enum: StrEnum inherits str's operators, so defining __lt__/__ge__ would shadow string ordering with a second alphabetical meaning that happens to agree today and would stop agreeing the moment a level is renamed. Callers use covers(). principal.py A frozen dataclass describing who is asking and what they may do. The absence of a credential is None rather than a Principal, so "nobody" can never be mistaken for a permission holder. Carries the tenant_id seam from the design doc: always None, present so auth functions already thread a tenant and adding tenancy later is a filter inside them rather than a change to forty routers. UNRESTRICTED is what gates return when auth is off, before any database access. hashing.py scrypt at n=2**14, r=8, p=1 -- argon2-cffi and bcrypt ship compiled wheels that are painful on the armv7 image, and scrypt is memory-hard and in the standard library. OWASP's headline n=2**17 would be multi-second on the ARM boards Spoolman runs on; their memory-constrained variant raises p, which costs the same time without the memory hardness. Hashes embed their own parameters, so the cost can be raised later without a migration: verification re-derives with the stored parameters and needs_rehash() reports staleness for opportunistic upgrade at login. Deriving blocks for ~50 ms here and several times that on a Pi. Spoolman is a single process, so a synchronous call in a handler would stall every other request. Measured 4 hashes serially at 171 ms against 63 ms across four threads, confirming scrypt releases the GIL and the _async wrappers are a real offload. Handlers must use those. Session tokens are hashed with plain unsalted SHA-256, deliberately: they are 256 bits of cryptographic randomness, so there is no dictionary to attack and no work factor worth paying. The digest exists so a database leak does not hand over usable cookies. cookies.py Session cookie is HttpOnly, so CSRF is handled by double submit against a second script-readable cookie. Secure is only ever concluded from positive evidence -- real TLS, or a proxy listed in SPOOLMAN_TRUSTED_PROXIES saying so -- because setting it on a plain-HTTP LAN deployment makes the browser discard the cookie and presents as a login that succeeds and does nothing. ratelimit.py In-process sliding windows keyed by username and by client address. Sound because the deployment contract is one process, the same assumption the websocket manager and scheduler already make; counters reset on restart, which is why lockout is also persisted on auth_user. X-Forwarded-For and X-Forwarded-Proto are honoured only from a configured trusted proxy, otherwise any client could spoof them to evade the limiter. Also adds AuthenticationRequiredError and PermissionDeniedError to exceptions.py, matching the existing bare-Exception style. Verified: level ordering and rejection of unknown names; principal frozen and anonymous capped at read; hash round-trip, distinct salts, corrupt hashes never validating, needs_rehash on stale parameters, and dummy_verify costing 41 ms against a real verify's 42 ms so unknown usernames are timing-indistinguishable; window limit/expiry/reset; untrusted X-Forwarded-For ignored and untrusted proxies unable to force Secure; and all nine CSRF cases including a lowercase-method bypass attempt.
26 lines
463 B
Python
26 lines
463 B
Python
"""Various exceptions used."""
|
|
|
|
|
|
class ItemNotFoundError(Exception):
|
|
pass
|
|
|
|
|
|
class ItemDeleteError(Exception):
|
|
pass
|
|
|
|
|
|
class ItemCreateError(Exception):
|
|
pass
|
|
|
|
|
|
class SpoolMeasureError(Exception):
|
|
pass
|
|
|
|
|
|
class AuthenticationRequiredError(Exception):
|
|
"""No usable credential was presented. Mapped to HTTP 401."""
|
|
|
|
|
|
class PermissionDeniedError(Exception):
|
|
"""A credential was presented but does not permit the operation. Mapped to HTTP 403."""
|