Skip to content

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

  1. On the first request, the middleware auto-generates a CSRF token and stores it in request.session['_csrf_token'].
  2. On state-changing requests, it checks for the token in:
  3. X-CSRF-Token header (for AJAX/fetch requests)
  4. _csrf_token form field (for HTML forms)
  5. Comparison uses constant-time HMAC (hmac.compare_digest) to prevent timing attacks.
  6. Mismatch returns 403 Forbidden.
  7. 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/json are 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 |safe to 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).

CORS_ORIGINS=http://localhost:3000,https://app.example.com

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:

TRUSTED_PROXIES=127.0.0.1,10.0.0.1

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_fields work exactly as before.
  • Merged with exclude: protected_fields and the exclude= parameter are combined.
  • Explicit opt-in: include_protected=True is 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:

  1. Rename malware.exemalware.jpg
  2. Set Content-Type: image/jpeg in the upload form
  3. 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.

# .env
JWT_SECRET=your-independent-jwt-secret-here-minimum-32-chars

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

python3 -c "import secrets; print(secrets.token_urlsafe(32))"

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_KEY is set to a strong random value in production
  • [ ] JWT_SECRET is independent from SECRET_KEY and at least 32 characters
  • [ ] DEBUG=false in production (disables debug error pages)
  • [ ] CORS_ORIGINS only 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_types is restrictive (don't allow *)
  • [ ] Rate limiting is applied to authentication and expensive endpoints
  • [ ] TRUSTED_PROXIES is configured if running behind a reverse proxy