diff --git a/frappe/app.py b/frappe/app.py index 6a07c9c223..7f4e4bcfac 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -126,6 +126,12 @@ def application(request: Request): elif request.path.startswith("/private/files/"): response = frappe.utils.response.download_private_file(request.path) + elif request.path == "/.well-known/security.txt" and request.method == "GET": + if request.scheme != "https": + raise NotFound + security_settings = frappe.get_doc("Security Settings") + response = Response(security_settings.security_txt, content_type="text/plain") + elif request.path.startswith("/.well-known/") and request.method == "GET": response = handle_wellknown(request.path) diff --git a/frappe/core/doctype/security_settings/__init__.py b/frappe/core/doctype/security_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/security_settings/security_settings.js b/frappe/core/doctype/security_settings/security_settings.js new file mode 100644 index 0000000000..05cbf3c9bd --- /dev/null +++ b/frappe/core/doctype/security_settings/security_settings.js @@ -0,0 +1,20 @@ +// Copyright (c) 2026, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Security Settings", { + refresh(frm) { + const wrapper = frm.fields_dict.securitytxt_section.wrapper; + if ($(wrapper).find(".security-txt-banner").length) return; + + $(wrapper) + .find(".section-body") + .prepend( + `
+ ${__("Security.txt will be served only under HTTPS.")} + ${__( + "Learn more" + )} +
` + ); + }, +}); diff --git a/frappe/core/doctype/security_settings/security_settings.json b/frappe/core/doctype/security_settings/security_settings.json new file mode 100644 index 0000000000..bbaf9b7c16 --- /dev/null +++ b/frappe/core/doctype/security_settings/security_settings.json @@ -0,0 +1,81 @@ +{ + "actions": [], + "creation": "2026-04-10 16:14:40.343135", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "securitytxt_section", + "public_expires", + "public_contacts", + "public_languages", + "public_policy", + "security_txt" + ], + "fields": [ + { + "fieldname": "securitytxt_section", + "fieldtype": "Section Break", + "label": "Security.txt" + }, + { + "description": "Date after which this security.txt should be considered stale.", + "fieldname": "public_expires", + "fieldtype": "Datetime", + "label": "Expires" + }, + { + "description": "Website, email or phone where vulnerabilities can be reported. Defaults to `https://security.frappe.io`", + "fieldname": "public_contacts", + "fieldtype": "Table", + "label": "Contact", + "options": "Security Settings Contact" + }, + { + "description": "Defaults to `en`", + "fieldname": "public_languages", + "fieldtype": "Table MultiSelect", + "label": "Preferred Language", + "options": "Security Settings Language" + }, + { + "description": "Guidelines and policies on vulnerability reporting. Defaults to `https://frappe.io/security`", + "fieldname": "public_policy", + "fieldtype": "Data", + "label": "Policy", + "options": "URL" + }, + { + "fieldname": "security_txt", + "fieldtype": "Small Text", + "is_virtual": 1, + "label": "Preview", + "read_only": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-04-14 12:50:57.138749", + "modified_by": "Administrator", + "module": "Core", + "name": "Security Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/core/doctype/security_settings/security_settings.py b/frappe/core/doctype/security_settings/security_settings.py new file mode 100644 index 0000000000..be52808e01 --- /dev/null +++ b/frappe/core/doctype/security_settings/security_settings.py @@ -0,0 +1,111 @@ +# Copyright (c) 2026, Frappe Technologies and contributors +# For license information, please see license.txt + +from datetime import datetime + +import frappe +import frappe.utils +from frappe import _ +from frappe.model.document import Document +from frappe.utils import validate_email_address, validate_phone_number, validate_url + + +class SecuritySettings(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.core.doctype.security_settings_contact.security_settings_contact import ( + SecuritySettingsContact, + ) + from frappe.core.doctype.security_settings_language.security_settings_language import ( + SecuritySettingsLanguage, + ) + from frappe.types import DF + + public_contacts: DF.Table[SecuritySettingsContact] + public_expires: DF.Datetime | None + public_languages: DF.TableMultiSelect[SecuritySettingsLanguage] + public_policy: DF.Data | None + # end: auto-generated types + + @property + def security_txt(self): + return "\n\n".join( + [ + self.public_policy_section, + self.public_contacts_section, + self.public_languages_section, + self.public_expires_section, + ] + ) + + @property + def public_policy_section(self): + value = self.public_policy or "https://frappe.io/security" + return f"# Read our security policy before reporting an issue\nPolicy: {value}" + + @property + def public_contacts_section(self): + contacts = [self.with_protocol(c.contact, c.type) for c in self.public_contacts] or [ + "https://security.frappe.io" + ] + value = "\n".join(f"Contact: {c}" for c in contacts) + return f"# Our security address\n{value}" + + @property + def public_languages_section(self): + langs = [l.language for l in self.public_languages] or ["en"] + value = ", ".join(langs) + return f"# We prefer talking in\nPreferred-Languages: {value}" + + @property + def public_expires_section(self): + expires = self.public_expires or frappe.utils.add_years(frappe.utils.now_datetime(), 1) + expires = (isinstance(expires, str) and datetime.fromisoformat(expires)) or expires + expires = expires.replace(microsecond=0) + value = expires.isoformat() + return f"Expires: {value}" + + def with_protocol(self, url: str, type_: str) -> str: + """Prefix the URL with the appropriate protocol based on the contact type.""" + match type_: + case "Email": + if not url.startswith("mailto:"): + return f"mailto:{url}" + case "Phone": + if not url.startswith("tel:"): + return f"tel:{url}" + return url + + def validate(self): + self.validate_public_policy() + self.validate_public_contacts() + self.validate_expires() + + def validate_public_policy(self): + if self.public_policy: + if not self.public_policy.startswith("https://"): + frappe.throw(_("Public Policy URL must start with https://")) + + def validate_public_contacts(self): + for contact in self.public_contacts: + match contact.type: + case "Email": + validate_email_address(contact.contact, throw=True) + case "Phone": + validate_phone_number(contact.contact, throw=True) + case "Website": + validate_url(contact.contact, throw=True) + if not contact.contact.startswith("https://"): + frappe.throw(_("URL contact must start with https://")) + + def validate_expires(self): + if self.public_expires: + expires = self.public_expires + if isinstance(expires, str): + expires = datetime.fromisoformat(expires) + if expires <= datetime.now(): + frappe.throw(_("Expiration date must be in the future")) diff --git a/frappe/core/doctype/security_settings/security_settings_alert.py b/frappe/core/doctype/security_settings/security_settings_alert.py new file mode 100644 index 0000000000..bdf899e721 --- /dev/null +++ b/frappe/core/doctype/security_settings/security_settings_alert.py @@ -0,0 +1,43 @@ +# Copyright (c) 2026, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.utils import get_datetime, now_datetime +from frappe.utils.user import get_users_with_role + + +def check_security_txt_expiry(): + security_settings = frappe.get_doc("Security Settings") + if not security_settings.public_expires: + return + expires = security_settings.public_expires + if isinstance(expires, str): + expires = get_datetime(expires) + now = now_datetime() + days_until_expiry = (expires - now).days + alert_days = [30, 15, 7, 1] + if days_until_expiry in alert_days: + send_expiry_alert(frappe.local.site, expires, days_until_expiry) + + +def send_expiry_alert(site: str, expires, days_until_expiry: int): + recipients = get_users_with_role("System Manager") + if not recipients: + return + subject = get_email_subject(site, days_until_expiry) + frappe.sendmail( + recipients=recipients, + subject=subject, + template="security_txt_expiry_alert", + args={ + "site": site, + "expires": expires, + "days_remaining": days_until_expiry, + }, + ) + + +def get_email_subject(site: str, days_until_expiry: int) -> str: + if days_until_expiry == 1: + return f"[URGENT] Security.txt expires in 1 day - {site}" + return f"Security.txt expires in {days_until_expiry} days - {site}" diff --git a/frappe/core/doctype/security_settings/test_security_settings.py b/frappe/core/doctype/security_settings/test_security_settings.py new file mode 100644 index 0000000000..a398bb8b10 --- /dev/null +++ b/frappe/core/doctype/security_settings/test_security_settings.py @@ -0,0 +1,268 @@ +# Copyright (c) 2026, Frappe Technologies and Contributors +# License: MIT. See LICENSE + +from datetime import datetime, timedelta + +import frappe +from frappe.tests import UnitTestCase + + +class TestSecuritySettings(UnitTestCase): + def test_public_policy_section_default(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_policy": None, + } + ) + section = doc.public_policy_section + self.assertIn("Policy: https://frappe.io/security", section) + + def test_public_policy_section_custom(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_policy": "https://example.com/security-policy", + } + ) + section = doc.public_policy_section + self.assertIn("Policy: https://example.com/security-policy", section) + + def test_public_languages_section_default(self): + doc = frappe.get_doc({"doctype": "Security Settings"}) + section = doc.public_languages_section + self.assertIn("Preferred-Languages: en", section) + + def test_public_languages_section_custom(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_languages": [ + {"language": "en"}, + {"language": "fr"}, + ], + } + ) + section = doc.public_languages_section + self.assertIn("Preferred-Languages: en, fr", section) + + def test_public_contacts_section_default(self): + doc = frappe.get_doc({"doctype": "Security Settings"}) + section = doc.public_contacts_section + self.assertIn("https://security.frappe.io", section) + + def test_public_contacts_section_email(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Email", "contact": "security@example.com"}, + ], + } + ) + section = doc.public_contacts_section + self.assertIn("mailto:security@example.com", section) + + def test_public_contacts_section_phone(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Phone", "contact": "+1234567890"}, + ], + } + ) + section = doc.public_contacts_section + self.assertIn("tel:+1234567890", section) + + def test_public_contacts_section_website(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Website", "contact": "https://security.example.com"}, + ], + } + ) + section = doc.public_contacts_section + self.assertIn("https://security.example.com", section) + + def test_with_protocol_email_without_protocol(self): + doc = frappe.get_doc({"doctype": "Security Settings"}) + result = doc.with_protocol("security@example.com", "Email") + self.assertEqual(result, "mailto:security@example.com") + + def test_with_protocol_email_with_protocol(self): + doc = frappe.get_doc({"doctype": "Security Settings"}) + result = doc.with_protocol("mailto:security@example.com", "Email") + self.assertEqual(result, "mailto:security@example.com") + + def test_with_protocol_phone_without_protocol(self): + doc = frappe.get_doc({"doctype": "Security Settings"}) + result = doc.with_protocol("+1234567890", "Phone") + self.assertEqual(result, "tel:+1234567890") + + def test_with_protocol_phone_with_protocol(self): + doc = frappe.get_doc({"doctype": "Security Settings"}) + result = doc.with_protocol("tel:+1234567890", "Phone") + self.assertEqual(result, "tel:+1234567890") + + def test_with_protocol_website(self): + doc = frappe.get_doc({"doctype": "Security Settings"}) + result = doc.with_protocol("https://example.com", "Website") + self.assertEqual(result, "https://example.com") + + def test_security_txt_full(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_policy": "https://example.com/policy", + "public_contacts": [ + {"type": "Email", "contact": "security@example.com"}, + ], + "public_languages": [ + {"language": "en"}, + ], + "public_expires": datetime.now() + timedelta(days=365), + } + ) + security_txt = doc.security_txt + self.assertIn("Policy: https://example.com/policy", security_txt) + self.assertIn("mailto:security@example.com", security_txt) + self.assertIn("Preferred-Languages: en", security_txt) + self.assertIn("Expires:", security_txt) + + def test_validate_public_policy_with_http(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_policy": "http://example.com", + } + ) + self.assertRaises(frappe.ValidationError, doc.validate_public_policy) + + def test_validate_public_policy_with_https(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_policy": "https://example.com", + } + ) + # Should not raise + doc.validate_public_policy() + + def test_validate_public_contacts_invalid_email(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Email", "contact": "invalid-email"}, + ], + } + ) + self.assertRaises(frappe.ValidationError, doc.validate_public_contacts) + + def test_validate_public_contacts_valid_email(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Email", "contact": "security@example.com"}, + ], + } + ) + # Should not raise + doc.validate_public_contacts() + + def test_validate_public_contacts_invalid_phone(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Phone", "contact": "not-a-phone"}, + ], + } + ) + self.assertRaises(frappe.ValidationError, doc.validate_public_contacts) + + def test_validate_public_contacts_valid_phone(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Phone", "contact": "+1234567890"}, + ], + } + ) + # Should not raise + doc.validate_public_contacts() + + def test_validate_public_contacts_website_without_https(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Website", "contact": "http://example.com"}, + ], + } + ) + self.assertRaises(frappe.ValidationError, doc.validate_public_contacts) + + def test_validate_public_contacts_valid_website(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_contacts": [ + {"type": "Website", "contact": "https://example.com"}, + ], + } + ) + # Should not raise + doc.validate_public_contacts() + + def test_validate_expires_past(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_expires": datetime.now() - timedelta(days=1), + } + ) + self.assertRaises(frappe.ValidationError, doc.validate_expires) + + def test_validate_expires_future(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_expires": datetime.now() + timedelta(days=365), + } + ) + # Should not raise + doc.validate_expires() + + def test_public_expires_section_future_date(self): + future_date = datetime(2027, 12, 31, 23, 59, 59) + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_expires": future_date, + } + ) + section = doc.public_expires_section + self.assertIn("2027-12-31T23:59:59", section) + + def test_public_expires_section_string(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_expires": "2027-12-31T23:59:59", + } + ) + section = doc.public_expires_section + self.assertIn("2027-12-31T23:59:59", section) + + def test_public_expires_section_default(self): + doc = frappe.get_doc({"doctype": "Security Settings"}) + section = doc.public_expires_section + # Default is 1 year from now + self.assertIn("Expires:", section) + self.assertIn("T", section) # ISO format diff --git a/frappe/core/doctype/security_settings_contact/__init__.py b/frappe/core/doctype/security_settings_contact/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/security_settings_contact/security_settings_contact.json b/frappe/core/doctype/security_settings_contact/security_settings_contact.json new file mode 100644 index 0000000000..d1ac7d46d0 --- /dev/null +++ b/frappe/core/doctype/security_settings_contact/security_settings_contact.json @@ -0,0 +1,43 @@ +{ + "actions": [], + "creation": "2026-04-11 13:06:29.308243", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "type", + "contact" + ], + "fields": [ + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Website\nEmail\nPhone", + "reqd": 1 + }, + { + "fieldname": "contact", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Contact", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-14 12:50:25.814560", + "modified_by": "Administrator", + "module": "Core", + "name": "Security Settings Contact", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/core/doctype/security_settings_contact/security_settings_contact.py b/frappe/core/doctype/security_settings_contact/security_settings_contact.py new file mode 100644 index 0000000000..2bca260e81 --- /dev/null +++ b/frappe/core/doctype/security_settings_contact/security_settings_contact.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SecuritySettingsContact(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + contact: DF.Data + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + type: DF.Literal["Website", "Email", "Phone"] + # end: auto-generated types + + pass diff --git a/frappe/core/doctype/security_settings_language/__init__.py b/frappe/core/doctype/security_settings_language/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/security_settings_language/security_settings_language.json b/frappe/core/doctype/security_settings_language/security_settings_language.json new file mode 100644 index 0000000000..803c8e82de --- /dev/null +++ b/frappe/core/doctype/security_settings_language/security_settings_language.json @@ -0,0 +1,35 @@ +{ + "actions": [], + "creation": "2026-04-11 12:53:09.006649", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "language" + ], + "fields": [ + { + "fieldname": "language", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Language", + "options": "Language", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-14 12:50:44.554462", + "modified_by": "Administrator", + "module": "Core", + "name": "Security Settings Language", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/core/doctype/security_settings_language/security_settings_language.py b/frappe/core/doctype/security_settings_language/security_settings_language.py new file mode 100644 index 0000000000..72648ae494 --- /dev/null +++ b/frappe/core/doctype/security_settings_language/security_settings_language.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SecuritySettingsLanguage(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + language: DF.Link + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + # end: auto-generated types + + pass diff --git a/frappe/hooks.py b/frappe/hooks.py index 6ff518f357..e1c46826f6 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -232,6 +232,10 @@ scheduler_events = { "0 */3 * * *": [ "frappe.search.sqlite_search.build_index_if_not_exists", ], + # Daily at 6:00 AM. + "0 6 * * *": [ + "frappe.core.doctype.security_settings.security_settings_alert.check_security_txt_expiry", + ], }, "all": [ "frappe.email.queue.flush", diff --git a/frappe/templates/emails/security_txt_expiry_alert.html b/frappe/templates/emails/security_txt_expiry_alert.html new file mode 100644 index 0000000000..94175e4238 --- /dev/null +++ b/frappe/templates/emails/security_txt_expiry_alert.html @@ -0,0 +1,20 @@ +

{{_("Security.txt will expire soon!")}}

+ + + + + + + + + + + + + + +
{{_("Site")}}{{ site }}
{{_("Expires")}}{{ expires.strftime('%B %-d, %Y %-I:%M %p') }}
{{_("Days Remaining")}}{{ days_remaining }}
+ +

+ {{_("Please update your security settings from desk.")}} +