Skip to content
Open
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
30 changes: 30 additions & 0 deletions specifyweb/backend/context/migrations/0001_login_notice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
('specify', '0040_components'),
]

operations = [
migrations.CreateModel(
name='LoginNotice',
fields=[
('sp_global_messages_id', models.AutoField(db_column='SpGlobalMessagesID', primary_key=True, serialize=False)),
('scope', models.TextField(default='login')),
('content', models.TextField(blank=True, default='')),
('is_enabled', models.BooleanField(default=False)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'spglobalmessages',
},
),
migrations.AddConstraint(
model_name='loginnotice',
constraint=models.UniqueConstraint(fields=('scope',), name='spglobalmessages_scope_unique'),
),
]
1 change: 1 addition & 0 deletions specifyweb/backend/context/migrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

26 changes: 25 additions & 1 deletion specifyweb/backend/context/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
from django.db import models

# Create your models here.

class LoginNotice(models.Model):
"""
Stores the optional institution-wide notice displayed on the login screen.
"""

sp_global_messages_id = models.AutoField(
primary_key=True,
db_column='SpGlobalMessagesID',
)
scope = models.TextField(default='login')
content = models.TextField(blank=True, default='')
is_enabled = models.BooleanField(default=False)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
db_table = 'spglobalmessages'
constraints = [
models.UniqueConstraint(fields=['scope'], name='spglobalmessages_scope_unique')
]

def __str__(self) -> str: # pragma: no cover - helpful in admin/debug
state = 'enabled' if self.is_enabled else 'disabled'
preview = (self.content or '').strip().replace('\n', ' ')[:40]
return f'Login notice ({state}): {preview}'
118 changes: 118 additions & 0 deletions specifyweb/backend/context/sanitizers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from __future__ import annotations

from html import escape
from html.parser import HTMLParser
from typing import Iterable, List, Sequence, Tuple
from urllib.parse import urlsplit

# Allow a conservative subset of HTML tags for the login notice.
_ALLOWED_TAGS = {
'a',
'br',
'em',
'i',
'li',
'ol',
'p',
'strong',
'u',
'ul',
}

_SELF_CLOSING_TAGS = {'br'}

_ALLOWED_ATTRS = {
'a': {'href', 'title'},
}

_ALLOWED_SCHEMES = {'http', 'https', 'mailto'}


def _is_safe_url(value: str | None) -> bool:
if value is None:
return False
stripped = value.strip()
if not stripped:
return False
parsed = urlsplit(stripped)
if parsed.scheme == '':
# Treat relative URLs as safe.
return not stripped.lower().startswith('javascript:')
return parsed.scheme.lower() in _ALLOWED_SCHEMES


class _LoginNoticeSanitizer(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self._parts: List[str] = []

def handle_starttag(self, tag: str, attrs: Sequence[Tuple[str, str | None]]) -> None:
if tag not in _ALLOWED_TAGS:
return
attributes = self._sanitize_attrs(tag, attrs)
self._parts.append(self._build_start_tag(tag, attributes))

def handle_startendtag(self, tag: str, attrs: Sequence[Tuple[str, str | None]]) -> None:
if tag not in _ALLOWED_TAGS:
return
attributes = self._sanitize_attrs(tag, attrs)
self._parts.append(self._build_start_tag(tag, attributes, self_closing=True))

def handle_endtag(self, tag: str) -> None:
if tag not in _ALLOWED_TAGS or tag in _SELF_CLOSING_TAGS:
return
self._parts.append(f'</{tag}>')

def handle_data(self, data: str) -> None:
self._parts.append(escape(data))

def handle_entityref(self, name: str) -> None: # pragma: no cover - defensive
self._parts.append(f'&{name};')

def handle_charref(self, name: str) -> None: # pragma: no cover - defensive
self._parts.append(f'&#{name};')

def handle_comment(self, data: str) -> None:
# Strip HTML comments entirely.
return

def get_html(self) -> str:
return ''.join(self._parts)

def _sanitize_attrs(
self,
tag: str,
attrs: Sequence[Tuple[str, str | None]],
) -> Iterable[Tuple[str, str]]:
allowed = _ALLOWED_ATTRS.get(tag, set())
for name, value in attrs:
if name not in allowed:
continue
if tag == 'a' and name == 'href' and not _is_safe_url(value):
continue
if value is None:
continue
yield name, escape(value, quote=True)

def _build_start_tag(
self,
tag: str,
attrs: Iterable[Tuple[str, str]],
self_closing: bool = False,
) -> str:
rendered_attrs = ' '.join(f'{name}="{value}"' for name, value in attrs)
suffix = ' /' if self_closing and tag not in _SELF_CLOSING_TAGS else ''
if rendered_attrs:
return f'<{tag} {rendered_attrs}{suffix}>'
return f'<{tag}{suffix}>'


def sanitize_login_notice_html(raw_html: str) -> str:
"""
Sanitize the provided HTML string for safe display on the login screen.
"""

parser = _LoginNoticeSanitizer()
parser.feed(raw_html or '')
parser.close()
return parser.get_html()
77 changes: 77 additions & 0 deletions specifyweb/backend/context/tests/test_login_notice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import json

from django.test import Client

from specifyweb.backend.context.models import LoginNotice
from specifyweb.backend.context.sanitizers import sanitize_login_notice_html
from specifyweb.specify.tests.test_api import ApiTests


class LoginNoticeTests(ApiTests):
def setUp(self) -> None:
super().setUp()
self.client = Client()

def test_public_endpoint_returns_204_when_disabled(self) -> None:
response = self.client.get('/context/login_notice/')
self.assertEqual(response.status_code, 204)

LoginNotice.objects.create(content='', is_enabled=False)
response = self.client.get('/context/login_notice/')
self.assertEqual(response.status_code, 204)

def test_public_endpoint_returns_sanitized_content(self) -> None:
LoginNotice.objects.create(
content='<p>Hello</p><script>alert(1)</script>',
is_enabled=True,
)

response = self.client.get('/context/login_notice/')
self.assertEqual(response.status_code, 200)
payload = json.loads(response.content)
self.assertEqual(payload['message'], '<p>Hello</p>alert(1)')

def test_manage_requires_administrator(self) -> None:
non_admin = self.specifyuser.__class__.objects.create(
isloggedin=False,
isloggedinreport=False,
name='readonly',
password='205C0D906445E1C71CA77C6D714109EB6D582B03A5493E4C',
)
client = Client()
client.force_login(non_admin)

response = client.get('/context/login_notice/manage/')
self.assertEqual(response.status_code, 403)

def test_manage_update_sanitizes_and_persists(self) -> None:
self.client.force_login(self.specifyuser)
payload = {
'enabled': True,
'content': '<p>Welcome</p><img src=x onerror="alert(1)">',
}

response = self.client.put(
'/context/login_notice/manage/',
data=json.dumps(payload),
content_type='application/json',
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['enabled'])
self.assertEqual(
data['content'],
sanitize_login_notice_html(payload['content']),
)

notice = LoginNotice.objects.get(scope='login')
self.assertTrue(notice.is_enabled)
self.assertEqual(
notice.content,
sanitize_login_notice_html(payload['content']),
)

public_response = self.client.get('/context/login_notice/')
self.assertEqual(public_response.status_code, 200)
public_payload = json.loads(public_response.content)
self.assertEqual(public_payload['message'], notice.content)
2 changes: 2 additions & 0 deletions specifyweb/backend/context/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
re_path(r'^api_endpoints.json$', views.api_endpoints),
re_path(r'^api_endpoints_all.json$', views.api_endpoints_all),
re_path(r'^user.json$', views.user),
path('login_notice/manage/', views.manage_login_notice),
path('login_notice/', views.login_notice),
re_path(r'^system_info.json$', views.system_info),
re_path(r'^server_time.json$', views.get_server_time),
re_path(r'^domain.json$', views.domain),
Expand Down
79 changes: 79 additions & 0 deletions specifyweb/backend/context/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@
from specifyweb.specify.api.serializers import uri_for_model
from specifyweb.specify.utils.specify_jar import specify_jar
from specifyweb.specify.views import login_maybe_required, openapi
from .models import LoginNotice
from .app_resource import get_app_resource, FORM_RESOURCE_EXCLUDED_LST
from .remote_prefs import get_remote_prefs
from .schema_localization import get_schema_languages, get_schema_localization
from .viewsets import get_views
from .sanitizers import sanitize_login_notice_html


def set_collection_cookie(response, collection_id): # pragma: no cover
Expand Down Expand Up @@ -350,6 +352,83 @@ def domain(request):

return HttpResponse(json.dumps(domain), content_type='application/json')


def _get_login_notice() -> LoginNotice:
notice, _created = LoginNotice.objects.get_or_create(
scope='login',
defaults={'content': '', 'is_enabled': False},
)
return notice


@require_http_methods(['GET'])
@cache_control(max_age=60, public=True)
def login_notice(request):
"""
Public endpoint that returns the sanitized login notice HTML.
"""

notice = LoginNotice.objects.filter(scope='login', is_enabled=True).first()
if notice is None:
return HttpResponse(status=204)

html = notice.content.strip()
if not html:
return HttpResponse(status=204)

return JsonResponse({'message': html})


@login_maybe_required
@require_http_methods(['GET', 'PUT'])
@never_cache
def manage_login_notice(request):
"""
Allow institution administrators to view and update the login notice.
"""

if not request.specify_user.is_admin():
return HttpResponseForbidden()

notice = _get_login_notice()

if request.method == 'GET':
return JsonResponse(
{
'enabled': notice.is_enabled and notice.content.strip() != '',
'content': notice.content,
'updated_at': notice.updated_at.isoformat(),
}
)

try:
payload = json.loads(request.body or '{}')
except json.JSONDecodeError:
return HttpResponseBadRequest('Invalid JSON payload.')

content = payload.get('content', '')
enabled = payload.get('enabled', False)

if not isinstance(content, str):
return HttpResponseBadRequest('"content" must be a string.')
if not isinstance(enabled, bool):
return HttpResponseBadRequest('"enabled" must be a boolean.')

sanitized = sanitize_login_notice_html(content)
is_enabled = enabled and sanitized.strip() != ''

notice.content = sanitized
notice.is_enabled = is_enabled
notice.save(update_fields=['content', 'is_enabled', 'updated_at'])

return JsonResponse(
{
'enabled': notice.is_enabled,
'content': notice.content,
'updated_at': notice.updated_at.isoformat(),
}
)

@openapi(schema={
"parameters": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import { DataObjectFormatter } from '../Formatters';
import { formattersSpec } from '../Formatters/spec';
import { FormEditor } from '../FormEditor';
import { viewSetsSpec } from '../FormEditor/spec';
import { UserPreferencesEditor, CollectionPreferencesEditor, GlobalPreferencesEditor } from '../Preferences/Editor';
import {
UserPreferencesEditor,
CollectionPreferencesEditor,
GlobalPreferencesEditor,
} from '../Preferences/Editor';
import { useDarkMode } from '../Preferences/Hooks';
import type { BaseSpec } from '../Syncer';
import type { SimpleXmlNode } from '../Syncer/xmlToJson';
Expand Down Expand Up @@ -189,4 +193,4 @@ export const visualAppResourceEditors = f.store<
otherJsonResource: undefined,
otherPropertiesResource: undefined,
otherAppResources: undefined,
}));
}));
Loading