8000 [pull] master from sissbruecker:master by pull[bot] · Pull Request #120 · bryanwills/linkding · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8000
90 changes: 90 additions & 0 deletions bookmarks/middlewares.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import logging
from urllib.parse import urlsplit

from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware
from django.core.exceptions import MiddlewareNotUsed
from django.http import HttpResponse
from django.utils.cache import patch_vary_headers

from bookmarks.models import GlobalSettings, UserProfile

logger = logging.getLogger(__name__)


class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
Expand Down Expand Up @@ -39,3 +47,85 @@ def __call__(self, request):
response = self.get_response(request)

return response


class CorsMiddleware:
"""
Adds CORS headers to API responses for origins configured through
LD_CORS_ALLOWED_ORIGINS, and answers preflight requests before they reach
the API views, which would otherwise reject them as unauthenticated.
Credentials are never allowed, so cross-origin requests have to
authenticate with an API token.

Removes itself from the middleware chain if no valid origins are configured.
"""

def __init__(self, get_response):
self.get_response = get_response
self.api_path = "/" + settings.LD_CONTEXT_PATH + "api/"
self.allowed_origins = set()
for origin in settings.LD_CORS_ALLOWED_ORIGINS.split(","):
origin = origin.strip()
if not origin:
continue
parsed = self.parse_origin(origin)
if parsed:
self.allowed_origins.add(parsed)
else:
logger.warning(
"Ignoring invalid origin in LD_CORS_ALLOWED_ORIGINS: '%s'. "
"Expected format: https://host[:port]",
origin,
)

if not self.allowed_origins:
raise MiddlewareNotUsed()

def __call__(self, request):
if not request.path.startswith(self.api_path):
return self.get_response(request)

is_preflight = (
request.method == "OPTIONS"
and "Access-Control-Request-Method" in request.headers
)
if is_preflight:
response = HttpResponse(headers={"Content-Length": "0"})
else:
response = self.get_response(request)

patch_vary_headers(response, ("Origin",))

origin = request.headers.get("Origin")
if not origin or self.parse_origin(origin) not in self.allowed_origins:
return response

response["Access-Control-Allow-Origin"] = origin
if is_preflight:
response["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, PATCH, DELETE, OPTIONS"
)
response["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
response["Access-Control-Max-Age"] = "86400"
return response

@staticmethod
def parse_origin(origin):
"""
Returns a (scheme, netloc) tuple for a valid origin, or None if the
value is not a valid origin.
"""
try:
url = urlsplit(origin)
except ValueError:
return None
if (
not url.scheme
or not url.netloc
or url.path not in ("", "/")
or url.query
or url.fragment
or url.username is not None
):
return None
return (url.scheme, url.netloc)
4 changes: 4 additions & 0 deletions bookmarks/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
]

MIDDLEWARE = [
"bookmarks.middlewares.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
Expand Down Expand Up @@ -247,6 +248,9 @@
if trusted_origins:
CSRF_TRUSTED_ORIGINS = trusted_origins.split(",")

# CORS allowed origins for the API, comma-separated
LD_CORS_ALLOWED_ORIGINS = os.getenv("LD_CORS_ALLOWED_ORIGINS", "")

# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases

Expand Down
201 changes: 201 additions & 0 deletions bookmarks/tests/test_cors_middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
from django.conf import settings
from django.core.exceptions import MiddlewareNotUsed
from django.test import override_settings
from django.urls import reverse

from bookmarks.middlewares import CorsMiddleware
from bookmarks.tests.helpers import BookmarkFactoryMixin, LinkdingApiTestCase

ORIGIN = "https://frontend.example.com"
OTHER_ORIGIN = "https://other.example.com"
INVALID_ORIGINS = [
"frontend.example.com",
"null",
"https://frontend.example.com/path",
"https://frontend.example.com?query=1",
"https://frontend.example.com#fragment",
"https://user:password@frontend.example.com",
"https://user@frontend.example.com",
"*",
]


def cors_origins(*origins):
return override_settings(LD_CORS_ALLOWED_ORIGINS=",".join(origins))


class CorsMiddlewareTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self):
self.api_url = reverse("linkding:bookmark-list")

def assertNoCorsHeaders(self, response):
self.assertNotIn("Access-Control-Allow-Origin", response)
self.assertNotIn("Access-Control-Allow-Methods", response)
self.assertNotIn("Access-Control-Allow-Headers", response)
self.assertNotIn("Access-Control-Max-Age", response)

def preflight(self, url, origin=ORIGIN):
return self.client.options(
url,
headers={"Origin": origin, "Access-Control-Request-Method": "POST"},
)

def assertMiddlewareNotUsed(self):
with self.assertRaises(MiddlewareNotUsed):
CorsMiddleware(lambda request: None)

def test_disabled_by_default(self):
self.assertEqual(settings.LD_CORS_ALLOWED_ORIGINS, "")
self.assertMiddlewareNotUsed()

# Preflight is not answered by the middleware, but reaches the API view
response = self.preflight(self.api_url)
self.assertEqual(response.status_code, 401)
self.assertNoCorsHeaders(response)

@cors_origins("", " ")
def test_disabled_for_empty_origins(self):
self.assertMiddlewareNotUsed()

@cors_origins(*INVALID_ORIGINS)
def test_disabled_for_invalid_origins(self):
self.assertMiddlewareNotUsed()

@cors_origins(ORIGIN, OTHER_ORIGIN)
def test_allowed_origins(self):
self.authenticate()

for origin in [ORIGIN, OTHER_ORIGIN]:
response = self.client.get(self.api_url, headers={"Origin": origin})

self.assertEqual(response.status_code, 200)
self.assertEqual(response["Access-Control-Allow-Origin"], origin)
self.assertIn("Origin", response["Vary"])
self.assertNotIn("Access-Control-Allow-Methods", response)
self.assertNotIn("Access-Control-Allow-Credentials", response)

@cors_origins(ORIGIN)
def test_rejected_origins(self):
self.authenticate()

for origin in [
OTHER_ORIGIN,
"http://frontend.example.com",
"https://frontend.example.com:8443",
"https://frontend.example.com/path",
"null",
]:
response = self.client.get(self.api_url, headers={"Origin": origin})

self.assertEqual(response.status_code, 200)
self.assertNoCorsHeaders(response)
self.assertIn("Origin", response["Vary"])

response = self.client.get(self.api_url)

self.assertEqual(response.status_code, 200)
self.assertNoCorsHeaders(response)

@cors_origins(*INVALID_ORIGINS, ORIGIN)
def test_invalid_origins_are_ignored(self):
self.authenticate()

with self.assertLogs("bookmarks.middlewares", level="WARNING") as logs:
response = self.client.get(self.api_url, headers={"Origin": ORIGIN})

self.assertEqual(response["Access-Control-Allow-Origin"], ORIGIN)

messages = [record.getMessage() for record in logs.records]
self.assertEqual(len(messages), len(INVALID_ORIGINS))
for origin, message in zip(INVALID_ORIGINS, messages, strict=True):
self.assertIn("Ignoring invalid origin in LD_CORS_ALLOWED_ORIGINS", message)
self.assertIn(f"'{origin}'", message)

for origin in INVALID_ORIGINS + [OTHER_ORIGIN]:
response = self.client.get(self.api_url, headers={"Origin": origin})
self.assertNoCorsHeaders(response)

@cors_origins(" " + ORIGIN + " ", "", " ")
def test_trims_whitespace_and_ignores_empty_entries(self):
self.authenticate()

with self.assertNoLogs("bookmarks.middlewares", level="WARNING"):
response = self.client.get(self.api_url, headers={"Origin": ORIGIN})

self.assertEqual(response["Access-Control-Allow-Origin"], ORIGIN)

@cors_origins(ORIGIN + "/")
def test_allows_trailing_slash_in_configured_origin(self):
self.authenticate()

response = self.client.get(self.api_url, headers={"Origin": ORIGIN})

self.assertEqual(response["Access-Control-Allow-Origin"], ORIGIN)

@cors_origins(ORIGIN)
def test_preflight_does_not_require_authentication(self):
response = self.preflight(self.api_url)

self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"")
self.assertEqual(response["Access-Control-Allow-Origin"], ORIGIN)
self.assertEqual(
response["Access-Control-Allow-Methods"],
"GET, POST, PUT, PATCH, DELETE, OPTIONS",
)
self.assertEqual(
response["Access-Control-Allow-Headers"], "Authorization, Content-Type"
)
self.assertEqual(response["Access-Control-Max-Age"], "86400")
self.assertNotIn("Access-Control-Allow-Credentials", response)

@cors_origins(ORIGIN)
def test_preflight_from_unknown_origin(self):
response = self.preflight(self.api_url, origin=OTHER_ORIGIN)

self.assertEqual(response.status_code, 200)
self.assertNoCorsHeaders(response)

@cors_origins(ORIGIN)
def test_plain_options_request_is_passed_to_view(self):
response = self.client.options(self.api_url, headers={"Origin": ORIGIN})

# Without Access-Control-Request-Method this is not a preflight, so the
# API view handles it and rejects the unauthenticated request
self.assertEqual(response.status_code, 401)
self.assertEqual(response["Access-Control-Allow-Origin"], ORIGIN)

@cors_origins(ORIGIN)
def test_error_responses_include_cors_headers(self):
response = self.client.get(self.api_url, headers={"Origin": ORIGIN})

self.assertEqual(response.status_code, 401)
self.assertEqual(response["Access-Control-Allow-Origin"], ORIGIN)

self.authenticate()
url = reverse("linkding:bookmark-detail", args=[9999])
response = self.client.get(url, headers={"Origin": ORIGIN})

self.assertEqual(response.status_code, 404)
self.assertEqual(response["Access-Control-Allow-Origin"], ORIGIN)

@cors_origins(ORIGIN)
def test_non_api_paths_are_not_affected(self):
response = self.client.get(reverse("login"), headers={"Origin": ORIGIN})

self.assertNoCorsHeaders(response)
self.assertNotIn("Origin", response.get("Vary", ""))

response = self.preflight(reverse("login"))

self.assertNoCorsHeaders(response)

@override_settings(LD_CORS_ALLOWED_ORIGINS=ORIGIN, LD_CONTEXT_PATH="linkding/")
def test_respects_context_path(self):
response = self.preflight("/linkding/api/bookmarks/")

self.assertEqual(response["Access-Control-Allow-Origin"], ORIGIN)

response = self.preflight("/api/bookmarks/")

self.assertNoCorsHeaders(response)
15 changes: 15 additions & 0 deletions docs/src/content/docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,21 @@ Values: `Integer` as seconds | Default = `1209600`

Set the lifetime of the session cookie, in seconds. This value determines how long a browser will stay logged in to the web interface. The default value is 2 weeks.

### `LD_CORS_ALLOWED_ORIGINS`

Values: `String` | Default = None

Comma-separated list of origins that are allowed to call the REST API from a browser, for example when running an alternative web frontend on a different domain.
Setting this option enables CORS headers for the API endpoints (`/api/*`) only. No other part of the application is affected.

For example, to allow a frontend hosted at https://frontend.mydomain.com, configure the setting to `https://frontend.mydomain.com`.
Note that origins **must** include the correct protocol (`https` or `http`), and **must not** include a path, query string, fragment or user credentials.
Origins are compared exactly as sent by the browser, so use lowercase and omit default ports (`:80` and `:443`).
Multiple origins can be specified by separating them with a comma (`,`). Wildcards (`*`) are not supported, each origin must be listed explicitly.

Cross-origin requests must authenticate using an API token in the `Authorization` header.
If you need more control over CORS headers, configure them in your reverse proxy instead.

### `LD_CSRF_TRUSTED_ORIGINS`

Values: `String` | Default = None
Expand Down
Loading
0