Security¶
Nori provides multiple layers of security — from HTTP headers and CSRF protection to data-level safeguards in the ORM and file upload pipeline. All security features are enabled by default and require no configuration to activate.
Security Headers¶
SecurityHeadersMiddleware injects the following headers on every HTTP response:
| Header | Value | Protects Against |
|---|---|---|
X-Content-Type-Options |
nosniff |
MIME-sniffing attacks |
X-Frame-Options |
DENY |
Clickjacking |
X-XSS-Protection |
1; mode=block |
Reflected XSS (legacy browsers) |
Referrer-Policy |
strict-origin-when-cross-origin |
Information leakage via referrer |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
Unauthorized device access |
Strict-Transport-Security |
max-age=31536000 (1 year) |
Downgrade attacks (HSTS) |
Optional Content-Security-Policy (CSP) can be configured if needed.
CSRF Protection¶
CsrfMiddleware validates CSRF tokens on all state-changing HTTP methods (POST, PUT, DELETE, PATCH).
How it works¶
- On the first request, the middleware auto-generates a CSRF token and stores it in
request.session['_csrf_token']. - On state-changing requests, it checks for the token in:
X-CSRF-Tokenheader (for AJAX/fetch requests)_csrf_tokenform field (for HTML forms)- Comparison uses constant-time HMAC (
hmac.compare_digest) to prevent timing attacks. - Mismatch returns 403 Forbidden.
- Oversized body (> 10 MB) returns 413 Request Entity Too Large (DoS protection).
Exempt from CSRF¶
- Safe methods: GET, HEAD, OPTIONS, TRACE
- JSON APIs: Requests with
Content-Type: application/jsonare exempt (browsers enforce CORS for cross-origin JSON requests) - Custom paths: Configurable exempt paths
Usage in Templates¶
csrf_field and csrf_token are registered as Jinja2 globals — you can call them directly in any template without passing them from the controller:
<form method="POST" action="/articles">
{{ csrf_field(request.session)|safe }}
<input type="text" name="title" />
<button type="submit">Create</button>
</form>
csrf_field(request.session)returns a full<input type="hidden" ...>tag — use|safeto render the HTML.csrf_token(request.session)returns the raw token string (useful for AJAX headers).
For AJAX requests:
fetch('/articles', {
method: 'POST',
headers: {
'X-CSRF-Token': '{{ csrf_token(request.session) }}',
'Content-Type': 'application/json',
},
body: JSON.stringify({ title: 'Hello' }),
});
Cross-Origin Resource Sharing (CORS)¶
Only activated if CORS_ORIGINS is set in .env. If omitted or empty, all cross-origin requests are denied (same-site policy).
Configuration: - Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS - Headers: Content-Type, Authorization, X-CSRF-Token - Credentials: Enabled (cookies/sessions work cross-origin)
Rate Limiting (@throttle)¶
Protects against brute-force, scraping, and DoS attacks. Limits are applied per endpoint + per IP address — blocking one endpoint doesn't affect others.
from core.http.throttle import throttle
class AuthController:
@throttle('5/minute') # 5 attempts per minute
async def login(self, request):
...
@throttle('100/hour') # API consumption limit
async def get_report(self, request):
...
Returns 429 Too Many Requests when the limit is exceeded (JSON or HTML based on Accept header).
Trusted Proxies (IP Spoofing Protection)¶
Rate limiting uses the client IP address as part of the key. When running behind a reverse proxy (Nginx, Cloudflare, ALB), the real client IP is in the X-Forwarded-For header. However, this header is only trusted from known proxies to prevent IP spoofing.
Configure trusted proxies in .env:
If TRUSTED_PROXIES is empty (default), X-Forwarded-For is ignored and the direct connection IP is used. This also affects the IP address recorded by the audit logger.
Backends¶
| Backend | Config | Best for |
|---|---|---|
memory (default) |
THROTTLE_BACKEND=memory |
Single-process, development |
redis |
THROTTLE_BACKEND=redis + REDIS_URL=redis://localhost:6379 |
Multi-process, production clusters |
The Redis backend shares counters across Gunicorn workers and Docker replicas.
ORM: protected_fields¶
Models can define a protected_fields class attribute to prevent sensitive data from leaking through to_dict().
The Problem¶
Without protected_fields, a developer who forgets exclude= will accidentally expose sensitive data:
user = await User.get(id=1)
return JSONResponse(user.to_dict()) # ⚠ Includes password_hash, tokens, etc.
The Solution¶
from tortoise import fields, Model
from core.mixins.model import NoriModelMixin
class User(NoriModelMixin, Model):
protected_fields = ['password_hash', 'remember_token', 'two_factor_secret']
id = fields.IntField(primary_key=True)
username = fields.CharField(max_length=100)
email = fields.CharField(max_length=255)
password_hash = fields.CharField(max_length=255)
remember_token = fields.CharField(max_length=255, default='')
two_factor_secret = fields.CharField(max_length=255, default='')
Now to_dict() automatically excludes protected fields:
user.to_dict()
# → {'id': 1, 'username': 'alice', 'email': 'alice@example.com'}
# password_hash, remember_token, two_factor_secret are excluded
user.to_dict(exclude=['email'])
# → {'id': 1, 'username': 'alice'}
# Both protected_fields AND explicit exclude are merged
user.to_dict(include_protected=True)
# → {'id': 1, 'username': 'alice', 'email': '...', 'password_hash': '...', ...}
# Force-include for internal/admin operations
Key Behaviors¶
- Backwards compatible: Models without
protected_fieldswork exactly as before. - Merged with
exclude:protected_fieldsand theexclude=parameter are combined. - Explicit opt-in:
include_protected=Trueis the only way to get protected fields in the output.
Upload Security: Magic Byte Verification¶
File uploads are validated through three layers (see Services for full upload docs):
Layer 1: Extension Check¶
Only extensions in allowed_types are accepted. A .exe file is rejected before any further processing.
Layer 2: MIME Type Check¶
The client-declared Content-Type header must match the expected MIME for the extension. The base MIME type is extracted before comparison (e.g. image/jpeg; charset=utf-8 is treated as image/jpeg), so charset parameters don't cause false rejections. Empty files (0 bytes) are also rejected at this stage.
Layer 3: Magic Byte Verification¶
The actual file content is inspected for known file signatures:
| Extension | Magic Bytes | Description |
|---|---|---|
jpg/jpeg |
\xff\xd8\xff |
JPEG Start of Image |
png |
\x89PNG\r\n\x1a\n |
PNG signature |
gif |
GIF87a or GIF89a |
GIF versions |
pdf |
%PDF |
PDF header |
webp |
RIFF + WEBP at offset 8 |
WebP container (full RIFF structure validated) |
Why Magic Bytes Matter¶
An attacker can trivially bypass extension and MIME checks:
- Rename
malware.exe→malware.jpg - Set
Content-Type: image/jpegin the upload form - Without magic byte verification, the file passes all checks
With magic byte verification, the actual file content is inspected. A PE executable starts with MZ, not \xff\xd8\xff — the upload is rejected with UploadError: File content does not match expected format for '.jpg' (magic byte verification failed).
Design Decision¶
Magic byte verification is implemented in pure Python (~15 lines) without external dependencies. This is intentional — python-magic requires libmagic (a C library, ~10 MB), which violates Nori's "Keep it Native" philosophy. The pure Python approach covers the most common file types (~90% of real-world uploads). Extensions without known signatures (SVG, CSV, etc.) skip this check gracefully.
JWT Security¶
Nori implements JWT with HMAC-SHA256 in core.auth.jwt. Five safeguards protect token integrity:
1. Algorithm Validation¶
verify_token() explicitly decodes the JWT header and rejects any token where alg is not HS256. This defends against algorithm confusion attacks (e.g. alg: none).
2. Clock Skew Tolerance¶
Token expiration includes a 10-second leeway to account for clock differences in distributed systems. A token expired 5 seconds ago will still be accepted; one expired 15 seconds ago will not.
3. Independent Secret¶
JWT_SECRET must be set separately from SECRET_KEY in production. If JWT_SECRET falls back to SECRET_KEY, a warning is logged and validate_settings() reports an error.
4. Minimum Length Enforcement¶
validate_settings() enforces a minimum of 32 characters for JWT_SECRET in production. Shorter secrets are rejected at startup:
Settings validation failed:
- JWT_SECRET is too short (minimum 32 characters).
Use: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
5. Constant-Time Comparison¶
Token signatures are verified using hmac.compare_digest(), which prevents timing attacks that could otherwise be used to forge valid signatures byte by byte.
Generate a Secure Secret¶
This produces a 43-character URL-safe string with 256 bits of entropy.
Password Hashing¶
core.auth.security.Security provides PBKDF2-HMAC-SHA256 with 100,000 iterations:
from core.auth.security import Security
hashed = Security.hash_password('my_password')
# → 'pbkdf2_sha256$100000$random_salt$derived_hash'
Security.verify_password('my_password', hashed) # → True
Security.verify_password('wrong', hashed) # → False (constant-time)
- Salt: Random per password (stored in the hash string).
- Comparison: Constant-time via
hmac.compare_digest. - Format:
algorithm$iterations$salt$hash— self-describing, no external state needed.
Security Checklist¶
When building with Nori, ensure:
- [ ]
SECRET_KEYis set to a strong random value in production - [ ]
JWT_SECRETis independent fromSECRET_KEYand at least 32 characters - [ ]
DEBUG=falsein production (disables debug error pages) - [ ]
CORS_ORIGINSonly lists trusted domains (or is empty for same-site) - [ ] State-changing actions use POST/PUT/DELETE, never GET
- [ ] All forms include
{{ csrf_field(request.session) }} - [ ] Models with sensitive fields define
protected_fields - [ ] File upload
allowed_typesis restrictive (don't allow*) - [ ] Rate limiting is applied to authentication and expensive endpoints
- [ ]
TRUSTED_PROXIESis configured if running behind a reverse proxy