Metadata-Version: 2.4
Name: django-fetch-metadata
Version: 0.1.0
Summary: Resource isolation policy for Django using Fetch Metadata request headers.
Project-URL: Homepage, https://github.com/dmptrluke/django-fetch-metadata
Author-email: Luke Rogers <luke@dmptr.com>
License: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Provides-Extra: toolbar
Requires-Dist: django-debug-toolbar>=4.0; extra == 'toolbar'
Description-Content-Type: text/markdown

# django-fetch-metadata

Resource isolation policy for Django using
[Fetch Metadata](https://web.dev/articles/fetch-metadata) request headers.

Browsers send `Sec-Fetch-Site` and `Sec-Fetch-Mode` headers on every request,
indicating where the request came from and how it was initiated. This middleware
uses those headers to block cross-site attacks while allowing legitimate
same-origin requests and direct navigations.

This is a defense-in-depth layer that works alongside Django's CSRF middleware,
not a replacement for it. Non-browser clients that don't send Fetch Metadata
headers (curl, API consumers, webhooks) pass through by default.

## Installation

```bash
pip install django-fetch-metadata
```

Add the middleware to your `MIDDLEWARE` setting, before `CsrfViewMiddleware`:

```python
MIDDLEWARE = [
    'django.middleware.common.CommonMiddleware',
    'fetch_metadata.middleware.FetchMetadataMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    # ...
]
```

For most projects, this will be all you need. The DEFAULT preset allows same-origin
requests, direct navigations (bookmarks, URL bar), and cross-site link clicks.
Everything else is blocked.

To enable system checks, add `'fetch_metadata'` to `INSTALLED_APPS`.

## Presets

Three named presets cover common configurations:

| Preset | Allowed Sites | Navigations | Fail Open | Use Case |
|--------|--------------|-------------|-----------|----------|
| **DEFAULT** | `same-origin`, `none` | Yes | Yes | Standard web app |
| **API** | `same-origin` | No | Yes | API endpoints |
| **STRICT** | `same-origin` | No | No | Admin panels, internal tools |

```python
FETCH_METADATA_PRESET = 'API'
```

By default, `DEFAULT` is used. Any setting you specify explicitly overrides the
preset value.

See [Presets](docs/presets.md) for detailed scenarios.

## Configuration

All settings are optional. The `DEFAULT` preset works without any configuration.

| Setting | Default | Description |
|---------|---------|-------------|
| `FETCH_METADATA_PRESET` | `'DEFAULT'` | Named preset: `DEFAULT`, `API`, or `STRICT` |
| `FETCH_METADATA_ALLOWED_SITES` | preset | List of allowed `Sec-Fetch-Site` values |
| `FETCH_METADATA_ALLOW_NAVIGATIONS` | preset | Allow cross-site `navigate` + GET/HEAD |
| `FETCH_METADATA_FAIL_OPEN` | preset | Pass requests with no `Sec-Fetch-Site` header |
| `FETCH_METADATA_REPORT_ONLY` | `False` | Log violations without blocking |
| `FETCH_METADATA_EXEMPT_PATHS` | `[]` | Path prefixes to skip (e.g. `['/.well-known/']`) |
| `FETCH_METADATA_FAILURE_VIEW` | `None` | Dotted path to a custom 403 view |

See [Configuration](docs/configuration.md) for details.

## Per-View Decorators

Exempt a view from all checks:

```python
from fetch_metadata.decorators import fetch_metadata_exempt

@fetch_metadata_exempt
class WebhookView(View):
    ...
```

Override the policy for a specific view:

```python
from fetch_metadata.decorators import fetch_metadata_policy

@fetch_metadata_policy(allowed_sites=['same-origin', 'same-site', 'none'])
class SubdomainAPIView(View):
    ...
```

Both decorators work on function-based views too:

```python
@fetch_metadata_exempt
def webhook_receiver(request):
    ...
```

## Test Utilities

`FetchMetadataTestMixin` provides assertion helpers for testing views against
the policy:

```python
from django.test import TestCase
from fetch_metadata.test import FetchMetadataTestMixin

class MyViewTests(FetchMetadataTestMixin, TestCase):
    def test_cross_site_blocked(self):
        self.assert_blocks('/api/data/')

    def test_same_origin_allowed(self):
        self.assert_allows('/api/data/')
```

`assert_blocks` sends a cross-site POST by default. `assert_allows` sends a
same-origin POST. Both accept `method`, `site`, and `mode` keyword arguments.

## Debug Toolbar Panel

Install with the `toolbar` extra:

```bash
pip install django-fetch-metadata[toolbar]
```

Add the panel to your toolbar config:

```python
DEBUG_TOOLBAR_PANELS = [
    # ... default panels ...
    'fetch_metadata.contrib.toolbar.FetchMetadataPanel',
]
```

The panel shows all four `Sec-Fetch-*` header values, the active policy, and
the allow/block decision for each request.

## How It Works

The middleware runs on every request via Django's `process_view` hook:

1. **OPTIONS** requests always pass (CORS preflight carries no credentials)
2. Exempt views and paths skip all checks
3. The active policy is resolved (per-view decorator, or global preset + overrides)
4. Missing `Sec-Fetch-Site` header: pass if `FAIL_OPEN`, block if not
5. Header value in `allowed_sites`: pass
6. Cross-site navigation via GET/HEAD with `ALLOW_NAVIGATIONS`: pass
7. Everything else: log at WARNING and block (or pass in report-only mode)

All requests are checked, including GET. A cross-site `fetch()` GET is blocked.
A cross-site link click (`Sec-Fetch-Mode: navigate` + GET) is allowed when
`ALLOW_NAVIGATIONS` is enabled.

Cross-site form POSTs (`Sec-Fetch-Mode: navigate` + POST) are blocked even with
`ALLOW_NAVIGATIONS` enabled. The navigation exemption only applies to safe
methods.

## Common Patterns

**Subdomain setup** (allow requests from other subdomains):

```python
FETCH_METADATA_ALLOWED_SITES = ['same-origin', 'same-site', 'none']
```

**Webhook endpoint exemption:**

```python
FETCH_METADATA_EXEMPT_PATHS = ['/webhooks/']
```

**Report-only rollout** (log violations without blocking, then review logs):

```python
FETCH_METADATA_REPORT_ONLY = True
```

Violations are logged to the `fetch_metadata` logger at WARNING level.

## Further Reading

- [Configuration](docs/configuration.md) - all settings, path exemptions, custom failure views
- [Presets](docs/presets.md) - preset details with request flow traces
- [W3C Fetch Metadata spec](https://www.w3.org/TR/fetch-metadata/) - the underlying browser mechanism
- [web.dev: Protect your resources](https://web.dev/articles/fetch-metadata) - Google's implementation guide
- [Django ticket #31823](https://code.djangoproject.com/ticket/31823) - discussion of adding this to Django core

## License

MIT. See [LICENSE](LICENSE).
