Compare commits

...

5 Commits

Author SHA1 Message Date
8c216c7e56 Fix search form validation 2026-01-15 15:17:20 +01:00
d1e741ebfd Remove the need of inline scripting 2026-01-15 12:42:52 +01:00
650a93676e Implement CSP via Django 6.0 2026-01-15 10:36:07 +01:00
265aed56fe Further hardening 2026-01-15 10:06:52 +01:00
167a0593de Cookies hardening 2026-01-15 10:02:57 +01:00
13 changed files with 117 additions and 30 deletions

View File

@@ -35,7 +35,8 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"fields": ( "fields": (
"show_version", "show_version",
"use_cdn", "use_cdn",
"extra_head", "extra_html",
"extra_js",
"rest_api", "rest_api",
"version", "version",
), ),

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0.1 on 2026-01-15 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("portal", "0021_siteconfiguration_featured_items_ordering_and_more"),
]
operations = [
migrations.RenameField(
model_name="siteconfiguration",
old_name="extra_head",
new_name="extra_html",
),
migrations.AlterField(
model_name="siteconfiguration",
name="extra_html",
field=models.TextField(
blank=True,
help_text="Extra HTML to be dinamically loaded into the site.",
),
),
migrations.AddField(
model_name="siteconfiguration",
name="extra_js",
field=models.TextField(
blank=True,
help_text="Extra JS to be dinamically loaded into the site."
),
),
]

View File

@@ -39,7 +39,14 @@ class SiteConfiguration(SingletonModel):
disclaimer = tinymce.HTMLField(blank=True) disclaimer = tinymce.HTMLField(blank=True)
show_version = models.BooleanField(default=True) show_version = models.BooleanField(default=True)
use_cdn = models.BooleanField(default=True) use_cdn = models.BooleanField(default=True)
extra_head = models.TextField(blank=True) extra_html = models.TextField(
blank=True,
help_text="Extra HTML to be dinamically loaded into the site.",
)
extra_js = models.TextField(
blank=True,
help_text="Extra JS to be dinamically loaded into the site.",
)
class Meta: class Meta:
verbose_name = "Site Configuration" verbose_name = "Site Configuration"

View File

@@ -3,4 +3,4 @@
* Copyright 2011-2023 The Bootstrap Authors * Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License. * Licensed under the Creative Commons Attribution 3.0 Unported License.
*/ */
(()=>{"use strict";const e=()=>localStorage.getItem("theme"),t=()=>{const t=e();return t||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")},a=e=>{"auto"===e&&window.matchMedia("(prefers-color-scheme: dark)").matches?document.documentElement.setAttribute("data-bs-theme","dark"):document.documentElement.setAttribute("data-bs-theme",e)};a(t());const r=(e,t=!1)=>{const a=document.querySelector("#bd-theme");if(!a)return;const r=document.querySelector(".theme-icon-active i"),o=document.querySelector(`[data-bs-theme-value="${e}"]`),s=o.querySelector(".theme-icon i").getAttribute("class");document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-pressed","false")}),o.classList.add("active"),o.setAttribute("aria-pressed","true"),r.setAttribute("class",s),t&&a.focus()};window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const r=e();"light"!==r&&"dark"!==r&&a(t())}),window.addEventListener("DOMContentLoaded",()=>{r(t()),document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-bs-theme-value");(e=>{localStorage.setItem("theme",e)})(t),a(t),r(t,!0)})})})})(),document.addEventListener("DOMContentLoaded",function(){const e=document.getElementById("tabSelector"),t=window.location.hash.substring(1);if(t){const a=`#nav-${t}`,r=document.querySelector(`[data-bs-target="${a}"]`);r&&(bootstrap.Tab.getOrCreateInstance(r).show(),e.value=a)}document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(e=>{e.addEventListener("shown.bs.tab",e=>{const t=e.target.getAttribute("data-bs-target").replace("nav-","");history.replaceState(null,null,t)})}),e&&(e.addEventListener("change",function(){const e=this.value,t=document.querySelector(`[data-bs-target="${e}"]`);if(t){bootstrap.Tab.getOrCreateInstance(t).show()}}),document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t=>{t.addEventListener("shown.bs.tab",t=>{const a=t.target.getAttribute("data-bs-target");e.value=a})}))}); (()=>{"use strict";const e=()=>localStorage.getItem("theme"),t=()=>{const t=e();return t||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")},a=e=>{"auto"===e&&window.matchMedia("(prefers-color-scheme: dark)").matches?document.documentElement.setAttribute("data-bs-theme","dark"):document.documentElement.setAttribute("data-bs-theme",e)};a(t());const r=(e,t=!1)=>{const a=document.querySelector("#bd-theme");if(!a)return;const r=document.querySelector(".theme-icon-active i"),o=document.querySelector(`[data-bs-theme-value="${e}"]`),s=o.querySelector(".theme-icon i").getAttribute("class");document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-pressed","false")}),o.classList.add("active"),o.setAttribute("aria-pressed","true"),r.setAttribute("class",s),t&&a.focus()};window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const r=e();"light"!==r&&"dark"!==r&&a(t())}),window.addEventListener("DOMContentLoaded",()=>{r(t()),document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-bs-theme-value");(e=>{localStorage.setItem("theme",e)})(t),a(t),r(t,!0)})})})})(),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.getElementById("tabSelector"),t=window.location.hash.substring(1);if(t){const a=`#nav-${t}`,r=document.querySelector(`[data-bs-target="${a}"]`);r&&(bootstrap.Tab.getOrCreateInstance(r).show(),e.value=a)}document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(e=>{e.addEventListener("shown.bs.tab",e=>{const t=e.target.getAttribute("data-bs-target").replace("nav-","");history.replaceState(null,null,t)})}),e&&(e.addEventListener("change",function(){const e=this.value,t=document.querySelector(`[data-bs-target="${e}"]`);if(t){bootstrap.Tab.getOrCreateInstance(t).show()}}),document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t=>{t.addEventListener("shown.bs.tab",t=>{const a=t.target.getAttribute("data-bs-target");e.value=a})}))}),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.querySelectorAll(".needs-validation");Array.from(e).forEach(e=>{e.addEventListener("submit",t=>{e.checkValidity()||(t.preventDefault(),t.stopPropagation()),e.classList.add("was-validated")},!1)})});

View File

@@ -1,5 +1,7 @@
// use Bootstrap 5's Tab component to manage tab navigation and synchronize with URL hash // use Bootstrap 5's Tab component to manage tab navigation and synchronize with URL hash
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
'use strict';
const selectElement = document.getElementById('tabSelector'); const selectElement = document.getElementById('tabSelector');
// code to handle tab selection and URL hash synchronization // code to handle tab selection and URL hash synchronization
const hash = window.location.hash.substring(1) // remove the '#' prefix const hash = window.location.hash.substring(1) // remove the '#' prefix
@@ -8,7 +10,7 @@ document.addEventListener("DOMContentLoaded", function () {
const trigger = document.querySelector(`[data-bs-target="${target}"]`); const trigger = document.querySelector(`[data-bs-target="${target}"]`);
if (trigger) { if (trigger) {
bootstrap.Tab.getOrCreateInstance(trigger).show(); bootstrap.Tab.getOrCreateInstance(trigger).show();
selectElement.value = target // keep the dropdown in sync selectElement.value = target; // keep the dropdown in sync
} }
} }
@@ -35,7 +37,7 @@ document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => { document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => {
btn.addEventListener('shown.bs.tab', event => { btn.addEventListener('shown.bs.tab', event => {
const target = event.target.getAttribute('data-bs-target'); const target = event.target.getAttribute('data-bs-target');
selectElement.value = target selectElement.value = target;
}); });
}); });
}); });

View File

@@ -0,0 +1,15 @@
document.addEventListener("DOMContentLoaded", function () {
'use strict'
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
});

View File

@@ -10,21 +10,3 @@
<button class="btn btn-outline-primary" type="submit">Search</button> <button class="btn btn-outline-primary" type="submit">Search</button>
</div> </div>
</form> </form>
<script>
(function () {
'use strict'
// Fetch all the forms we want to apply custom Bootstrap validation styles to
var forms = document.querySelectorAll('.needs-validation')
// Loop over them and prevent submission
Array.prototype.slice.call(forms)
.forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!form.checkValidity()) {
form.classList.add('was-validated')
event.preventDefault()
event.stopPropagation()
}
}, false)
})
})()
</script>

View File

@@ -25,7 +25,8 @@
<link href="{% static "css/main.min.css" %}?v={{ site_conf.version }}" rel="stylesheet"> <link href="{% static "css/main.min.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<script src="{% static "js/main.min.js" %}?v={{ site_conf.version }}"></script> <script src="{% static "js/main.min.js" %}?v={{ site_conf.version }}"></script>
{% block extra_head %} {% block extra_head %}
{{ site_conf.extra_head | safe }} {% if site_conf.extra_html %}{{ site_conf.extra_html | safe }}{% endif %}
{% if site_conf.extra_js %}<script src="{% url 'extra_js' %}"></script>{% endif %}
{% endblock %} {% endblock %}
</head> </head>
<body> <body>

View File

@@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from portal.views import ( from portal.views import (
RenderExtraJS,
GetHome, GetHome,
GetRoster, GetRoster,
GetObjectsFiltered, GetObjectsFiltered,
@@ -24,6 +25,7 @@ from portal.views import (
urlpatterns = [ urlpatterns = [
path("", GetHome.as_view(), name="index"), path("", GetHome.as_view(), name="index"),
path("extra.js", RenderExtraJS.as_view(), name="extra_js"),
path("roster", GetRoster.as_view(), name="roster"), path("roster", GetRoster.as_view(), name="roster"),
path("roster/page/<int:page>", GetRoster.as_view(), name="roster"), path("roster/page/<int:page>", GetRoster.as_view(), name="roster"),
path( path(

View File

@@ -7,7 +7,7 @@ from urllib.parse import unquote
from django.conf import settings from django.conf import settings
from django.views import View from django.views import View
from django.urls import Resolver404 from django.urls import Resolver404
from django.http import Http404, HttpResponseBadRequest from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from django.db.models import F, Q, Count from django.db.models import F, Q, Count
from django.db.models.functions import Lower from django.db.models.functions import Lower
@@ -78,6 +78,16 @@ class Render404(View):
) )
class RenderExtraJS(View):
def get(self, request):
try:
extra_js = get_site_conf().extra_js
except (OperationalError, ProgrammingError):
extra_js = ""
return HttpResponse(extra_js, content_type="application/javascript")
class GetData(View): class GetData(View):
title = None title = None
template = "pagination.html" template = "pagination.html"
@@ -267,6 +277,9 @@ class SearchObjects(View):
return _filter, search return _filter, search
def get(self, request, search, page=1): def get(self, request, search, page=1):
if not search:
return HttpResponseBadRequest()
try: try:
encoded_search = search encoded_search = search
search = base64.b64decode(search.encode()).decode() search = base64.b64decode(search.encode()).decode()

View File

@@ -1,4 +1,13 @@
from django import VERSION as DJANGO_VERSION
from django.utils.termcolors import colorize
from ram.utils import git_suffix from ram.utils import git_suffix
if DJANGO_VERSION < (6, 0):
exit(
colorize(
"ERROR: This project requires Django 6.0 or higher.", fg="red"
)
)
__version__ = "0.19.10" __version__ = "0.19.10"
__version__ += git_suffix(__file__) __version__ += git_suffix(__file__)

View File

@@ -3,6 +3,7 @@ Django settings for ram project.
""" """
from pathlib import Path from pathlib import Path
from django.utils.csp import CSP
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -12,9 +13,7 @@ STORAGE_DIR = BASE_DIR / "storage"
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = ( SECRET_KEY = "django-ram-insecure-Chang3m3-1n-Pr0duct10n!"
"django-ram-insecure-Chang3m3-1n-Pr0duct10n!"
)
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
@@ -48,6 +47,7 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.middleware.csp.ContentSecurityPolicyMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -109,6 +109,27 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MEDIA_URL = "media/" MEDIA_URL = "media/"
MEDIA_ROOT = STORAGE_DIR / "media" MEDIA_ROOT = STORAGE_DIR / "media"
# Enforce a CSP policy:
CDN_WHITELIST_CSP = ["https://cdn.jsdelivr.net/"]
SECURE_CSP = {
"default-src": [CSP.SELF] + CDN_WHITELIST_CSP,
"img-src": ["data:", "*"],
"script-src": [
CSP.SELF,
"https://www.googletagmanager.com/",
]
+ CDN_WHITELIST_CSP,
"style-src": [CSP.SELF, CSP.UNSAFE_INLINE] + CDN_WHITELIST_CSP,
}
# Cookies hardening
SESSION_COOKIE_NAME = "__Secure-sessionid"
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_NAME = "__Secure-csrftoken"
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
# django-ram REST API settings # django-ram REST API settings
REST_ENABLED = False # Set to True to enable the REST API REST_ENABLED = False # Set to True to enable the REST API
REST_FRAMEWORK = { REST_FRAMEWORK = {
@@ -161,7 +182,7 @@ MANUFACTURER_TYPES = [
("model", "Model"), ("model", "Model"),
("real", "Real"), ("real", "Real"),
("accessory", "Accessory"), ("accessory", "Accessory"),
("other", "Other") ("other", "Other"),
] ]
ROLLING_STOCK_TYPES = [ ROLLING_STOCK_TYPES = [

View File

@@ -1,7 +1,7 @@
pytz pytz
pillow pillow
markdown markdown
Django Django>=6.0
djangorestframework djangorestframework
django-solo django-solo
django-countries django-countries