From 65c4b3e8bc4f995d891317595bb884f7204d3ce6 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Thu, 23 Jul 2026 15:55:42 +0200 Subject: [PATCH] Add secure ICS calendar imports and subscriptions --- docs/release-testing-0.7.md | 1 + docs/roadmap.md | 5 +- docs/web.md | 17 ++ po/bongo-web/de.po | 24 +++ po/bongo-web/fr.po | 24 +++ po/bongo-web/pt_BR.po | 24 +++ src/libs/msgapi/CMakeLists.txt | 4 + src/libs/msgapi/msgcollector.c | 59 +++++- src/libs/msgapi/msghttp.c | 49 +++++ src/libs/msgapi/msghttp.h | 2 + src/libs/msgapi/tests/msghttp-address-test.c | 59 ++++++ src/www/bongo-web.py.in | 112 +++++++++-- src/www/bongo_web/calendar_import.py | 197 +++++++++++++++++++ src/www/bongo_web/calendar_store.py | 76 +++++-- src/www/bongo_web/dav.py | 26 ++- src/www/static/app.js | 107 +++++++++- src/www/static/index.html | 24 ++- src/www/tests/test_calendar_import.py | 113 +++++++++++ src/www/tests/test_calendar_store.py | 49 ++++- src/www/tests/test_dav.py | 15 ++ src/www/tests/test_web_api.py | 42 +++- 21 files changed, 978 insertions(+), 51 deletions(-) create mode 100644 src/libs/msgapi/tests/msghttp-address-test.c create mode 100644 src/www/bongo_web/calendar_import.py create mode 100644 src/www/tests/test_calendar_import.py diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index b5958cf..432f30c 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -248,6 +248,7 @@ inputs and outputs is absent from the ZIP. | WEB-06 | Identities, external senders, signatures, SMTP selection | | | | | WEB-07 | Filters/tasks/quarantine/external accounts/app passwords/2FA | | | | | WEB-08 | English, German, and every installed gettext catalogue load | | | | +| WEB-09 | ICS file import and bounded public-HTTPS holiday subscription into personal/named calendars | | | | | DAV-01 | CalDAV discovery, CRUD, sync, ETag, and iCalendar | | | | | DAV-02 | CardDAV discovery, CRUD, sync, ETag, and vCard | | | | | DAV-03 | DAV master/app-password scope and TLS/proxy policy | | | | diff --git a/docs/roadmap.md b/docs/roadmap.md index 0a08814..7453b26 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -104,8 +104,9 @@ fit Bongo; it is not a reason to rewrite working server code. extensions once their Standards Track specifications are stable; draft versions may be exercised behind experimental capabilities but are not part of the supported 0.9 interoperability contract; -- scheduled external CalDAV calendar subscriptions and provider calendar sync, - reusing the external-account identity and credential model; +- authenticated external CalDAV/provider calendar sync, building on the + HTTPS iCalendar URL subscriptions already available in 0.7 and reusing the + external-account identity and credential model; - stable migration and compatibility APIs needed by the Web redesign and future clients. diff --git a/docs/web.md b/docs/web.md index d678f19..9ca1122 100644 --- a/docs/web.md +++ b/docs/web.md @@ -46,6 +46,23 @@ then dismissed, retried, ignored, or completed where the task type permits. English, German, and every installed gettext catalogue use the same API instead of hard-coded interface text. +Calendar users can import a local `.ics` file into the personal calendar or +create a separate named calendar, for example `holidays-at`. A public HTTPS +URL can be imported once or kept as a subscription. The Web service resolves +and pins only public destination addresses, revalidates every HTTPS redirect, +and bounds connection time, redirect count, and response size. The Collector +applies the same public-address, HTTPS-only, timeout, redirect, and size rules +when it refreshes subscriptions. Feed-owned events are matched by iCalendar +UID so a refresh updates existing events and unlinks events removed by the +publisher. + +The classic interface includes an optional preset for the public Austrian +holiday feed from `feiertage-oesterreich.at`. Its calendar data is fetched +from the publisher and is not copied into the Bongo source or packages. Other +providers can be entered as normal HTTPS URLs. A separate calendar is +recommended for subscriptions so users can hide or remove the complete feed +without modifying their personal events. + The administrator view exposes health and a restricted set of validated actions. Full server configuration remains the responsibility of `bongo-admin` and the configuration TUI; Webmail must not become an arbitrary diff --git a/po/bongo-web/de.po b/po/bongo-web/de.po index d29074d..fa6b158 100644 --- a/po/bongo-web/de.po +++ b/po/bongo-web/de.po @@ -414,3 +414,27 @@ msgid "Passwords do not match." msgstr "Die Passwörter stimmen nicht überein." msgid "Apply the validated configuration files to the running server?" msgstr "Die geprüften Konfigurationsdateien auf den laufenden Server anwenden?" +msgid "Calendar source" +msgstr "Kalenderquelle" +msgid "Import calendar" +msgstr "Kalender importieren" +msgid "Personal calendar" +msgstr "Persönlicher Kalender" +msgid "New calendar name" +msgstr "Neuer Kalendername" +msgid "ICS file" +msgstr "ICS-Datei" +msgid "Calendar URL" +msgstr "Kalender-URL" +msgid "Import once" +msgstr "Einmal importieren" +msgid "Keep synchronized" +msgstr "Synchron halten" +msgid "Choose an ICS file or enter a public HTTPS calendar URL." +msgstr "Wählen Sie eine ICS-Datei oder geben Sie eine öffentliche HTTPS-Kalender-URL ein." +msgid "Calendar imported." +msgstr "Kalender importiert." +msgid "Calendar import failed." +msgstr "Kalenderimport fehlgeschlagen." +msgid "Austria public holidays" +msgstr "Österreichische Feiertage" diff --git a/po/bongo-web/fr.po b/po/bongo-web/fr.po index 997cb16..0efa793 100644 --- a/po/bongo-web/fr.po +++ b/po/bongo-web/fr.po @@ -1770,3 +1770,27 @@ msgid "Passwords do not match." msgstr "Les mots de passe ne correspondent pas." msgid "Apply the validated configuration files to the running server?" msgstr "Appliquer les fichiers de configuration validés au serveur en cours d’exécution ?" +msgid "Calendar source" +msgstr "Source du calendrier" +msgid "Import calendar" +msgstr "Importer un calendrier" +msgid "Personal calendar" +msgstr "Calendrier personnel" +msgid "New calendar name" +msgstr "Nom du nouveau calendrier" +msgid "ICS file" +msgstr "Fichier ICS" +msgid "Calendar URL" +msgstr "URL du calendrier" +msgid "Import once" +msgstr "Importer une fois" +msgid "Keep synchronized" +msgstr "Maintenir synchronisé" +msgid "Choose an ICS file or enter a public HTTPS calendar URL." +msgstr "Choisissez un fichier ICS ou saisissez une URL HTTPS publique de calendrier." +msgid "Calendar imported." +msgstr "Calendrier importé." +msgid "Calendar import failed." +msgstr "Échec de l’importation du calendrier." +msgid "Austria public holidays" +msgstr "Jours fériés autrichiens" diff --git a/po/bongo-web/pt_BR.po b/po/bongo-web/pt_BR.po index c75ac0f..42bfafb 100644 --- a/po/bongo-web/pt_BR.po +++ b/po/bongo-web/pt_BR.po @@ -1255,3 +1255,27 @@ msgid "Passwords do not match." msgstr "As senhas não coincidem." msgid "Apply the validated configuration files to the running server?" msgstr "Aplicar os arquivos de configuração validados ao servidor em execução?" +msgid "Calendar source" +msgstr "Fonte do calendário" +msgid "Import calendar" +msgstr "Importar calendário" +msgid "Personal calendar" +msgstr "Calendário pessoal" +msgid "New calendar name" +msgstr "Nome do novo calendário" +msgid "ICS file" +msgstr "Arquivo ICS" +msgid "Calendar URL" +msgstr "URL do calendário" +msgid "Import once" +msgstr "Importar uma vez" +msgid "Keep synchronized" +msgstr "Manter sincronizado" +msgid "Choose an ICS file or enter a public HTTPS calendar URL." +msgstr "Escolha um arquivo ICS ou informe uma URL HTTPS pública de calendário." +msgid "Calendar imported." +msgstr "Calendário importado." +msgid "Calendar import failed." +msgstr "Falha ao importar o calendário." +msgid "Austria public holidays" +msgstr "Feriados da Áustria" diff --git a/src/libs/msgapi/CMakeLists.txt b/src/libs/msgapi/CMakeLists.txt index 8df836f..9e4c366 100644 --- a/src/libs/msgapi/CMakeLists.txt +++ b/src/libs/msgapi/CMakeLists.txt @@ -50,6 +50,10 @@ if(BUILD_TESTING) add_executable(msgapi-time64-test tests/msgdate-time64-test.c) target_link_libraries(msgapi-time64-test PRIVATE bongomsgapi) add_test(NAME msgapi-time64 COMMAND msgapi-time64-test) + + add_executable(msgapi-http-address-test tests/msghttp-address-test.c) + target_link_libraries(msgapi-http-address-test PRIVATE bongomsgapi) + add_test(NAME msgapi-http-address COMMAND msgapi-http-address-test) endif() add_subdirectory(auth-backends) diff --git a/src/libs/msgapi/msgcollector.c b/src/libs/msgapi/msgcollector.c index 8495128..1234d6b 100644 --- a/src/libs/msgapi/msgcollector.c +++ b/src/libs/msgapi/msgcollector.c @@ -38,11 +38,31 @@ #include #include +#include #include "msghttp.h" static size_t write_data(void *buffer, size_t size, size_t nmemb, void *stream); BongoList *GetSubscribedCals(Connection *conn); +#define CALENDAR_DOWNLOAD_LIMIT (8U * 1024U * 1024U) + +typedef struct { + FILE *file; + size_t received; +} CalendarDownload; + +static curl_socket_t +CalendarOpenSocket(void *client, curlsocktype purpose, + struct curl_sockaddr *address) +{ + (void)client; + if (purpose != CURLSOCKTYPE_IPCXN || + !MsgHttpPublicAddress(&address->addr)) { + return CURL_SOCKET_BAD; + } + return socket(address->family, address->socktype, address->protocol); +} + typedef struct _SubscribedCal { int64_t guid; char *name; @@ -672,8 +692,15 @@ MsgCollectAllUsers(void) size_t write_data(void *buffer, size_t size, size_t nmemb, void *stream) { - fwrite(buffer, size, nmemb, stream); - return nmemb*size; + CalendarDownload *download = stream; + size_t bytes; + + if (size != 0 && nmemb > SIZE_MAX / size) return 0; + bytes = size * nmemb; + if (bytes > CALENDAR_DOWNLOAD_LIMIT - download->received) return 0; + if (fwrite(buffer, size, nmemb, download->file) != nmemb) return 0; + download->received += bytes; + return bytes; } int @@ -689,7 +716,7 @@ MsgImportIcsUrl(const char *user, CURLcode res; char filename[XPL_MAX_PATH]; char *aliasedUrl = NULL; - int ret = 0; + int ret = MSG_COLLECT_ERROR_DOWNLOAD; /* TODO: fix up url. * Protocol could be webcal://, feed://, dav://, ical://, etc. @@ -700,9 +727,33 @@ MsgImportIcsUrl(const char *user, if (curl) { int ccode; FILE *fh = XplOpenTemp("/tmp", "w+", filename); + CalendarDownload download; char cred[1024]; + if (!fh) { + curl_easy_cleanup(curl); + return MSG_COLLECT_ERROR_DOWNLOAD; + } + memset(&download, 0, sizeof(download)); + download.file = fh; curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); +#if LIBCURL_VERSION_NUM >= 0x075500 + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); +#else + curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS); +#endif + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 20L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); + curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); + curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE, + (curl_off_t)CALENDAR_DOWNLOAD_LIMIT); + curl_easy_setopt(curl, CURLOPT_USERAGENT, + "Bongo/" BONGO_VERSION " calendar subscriber"); + curl_easy_setopt(curl, CURLOPT_OPENSOCKETFUNCTION, + CalendarOpenSocket); if (aliasedUrl) { curl_easy_setopt(curl, CURLOPT_URL, aliasedUrl); } else { @@ -712,7 +763,7 @@ MsgImportIcsUrl(const char *user, curl_easy_setopt(curl, CURLOPT_VERBOSE, TRUE); #endif curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, *write_data); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, fh); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &download); if (urlUsername && urlPassword) { snprintf(cred, 1024, "%s:%s", urlUsername, urlPassword); diff --git a/src/libs/msgapi/msghttp.c b/src/libs/msgapi/msghttp.c index 211fa20..8f8f2aa 100644 --- a/src/libs/msgapi/msghttp.c +++ b/src/libs/msgapi/msghttp.c @@ -23,7 +23,56 @@ #include #include +#include #include +#include +#include +#include + +#include "msghttp.h" + +int +MsgHttpPublicAddress(const struct sockaddr *address) +{ + if (address->sa_family == AF_INET) { + const struct sockaddr_in *ipv4 = (const struct sockaddr_in *)address; + uint32_t value = ntohl(ipv4->sin_addr.s_addr); + + if ((value >> 24) == 0 || (value >> 24) == 10 || + (value >> 24) == 127 || (value >> 16) == 0xa9fe || + (value >> 20) == 0xac1 || (value >> 16) == 0xc0a8 || + (value >> 22) == 0x0191 || + (value >> 8) == 0xc00000 || (value >> 8) == 0xc00002 || + (value & 0xfffe0000U) == 0xc6120000U || + (value >> 8) == 0xc63364 || (value >> 8) == 0xcb0071 || + (value >> 28) >= 0x0e) { + return FALSE; + } + return TRUE; + } + if (address->sa_family == AF_INET6) { + const struct sockaddr_in6 *ipv6 = + (const struct sockaddr_in6 *)address; + const unsigned char *bytes = ipv6->sin6_addr.s6_addr; + + if (IN6_IS_ADDR_UNSPECIFIED(&ipv6->sin6_addr) || + IN6_IS_ADDR_LOOPBACK(&ipv6->sin6_addr) || + IN6_IS_ADDR_LINKLOCAL(&ipv6->sin6_addr) || + IN6_IS_ADDR_MULTICAST(&ipv6->sin6_addr) || + (bytes[0] & 0xfe) == 0xfc) { + return FALSE; + } + if (IN6_IS_ADDR_V4MAPPED(&ipv6->sin6_addr)) { + struct sockaddr_in mapped; + memset(&mapped, 0, sizeof(mapped)); + mapped.sin_family = AF_INET; + memcpy(&mapped.sin_addr, bytes + 12, sizeof(mapped.sin_addr)); + return MsgHttpPublicAddress((const struct sockaddr *)&mapped); + } + return TRUE; + } + return FALSE; +} CURLcode msg_curl_easy_perform_cached(CURL *handle) diff --git a/src/libs/msgapi/msghttp.h b/src/libs/msgapi/msghttp.h index 22d2de3..4722c44 100644 --- a/src/libs/msgapi/msghttp.h +++ b/src/libs/msgapi/msghttp.h @@ -23,6 +23,7 @@ #define MSGAPIHTTP_H #include +#include #ifdef __cplusplus extern "C" { @@ -30,6 +31,7 @@ extern "C" { /* from msghttp.c */ CURLcode msg_curl_easy_perform_cached(CURL *handle); +int MsgHttpPublicAddress(const struct sockaddr *address); #ifdef __cplusplus } diff --git a/src/libs/msgapi/tests/msghttp-address-test.c b/src/libs/msgapi/tests/msghttp-address-test.c new file mode 100644 index 0000000..5ad08c6 --- /dev/null +++ b/src/libs/msgapi/tests/msghttp-address-test.c @@ -0,0 +1,59 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + * To contact Novell about this file by physical or electronic mail, you + * may find current contact information at www.novell.com. + * + ****************************************************************************/ + +#include +#include +#include + +#include "../msghttp.h" + +static int +CheckAddress(int family, const char *text, int expected) +{ + struct sockaddr_storage storage; + void *destination; + + memset(&storage, 0, sizeof(storage)); + storage.ss_family = family; + destination = family == AF_INET + ? (void *)&((struct sockaddr_in *)&storage)->sin_addr + : (void *)&((struct sockaddr_in6 *)&storage)->sin6_addr; + if (inet_pton(family, text, destination) != 1 || + MsgHttpPublicAddress((const struct sockaddr *)&storage) != expected) { + fprintf(stderr, "unexpected calendar download classification: %s\n", + text); + return 0; + } + return 1; +} + +int +main(void) +{ + return !(CheckAddress(AF_INET, "127.0.0.1", 0) && + CheckAddress(AF_INET, "10.0.0.1", 0) && + CheckAddress(AF_INET, "100.64.0.1", 0) && + CheckAddress(AF_INET, "192.0.2.1", 0) && + CheckAddress(AF_INET, "93.184.216.34", 1) && + CheckAddress(AF_INET6, "::1", 0) && + CheckAddress(AF_INET6, "fd00::1", 0) && + CheckAddress(AF_INET6, "2606:2800:220:1:248:1893:25c8:1946", 1)); +} diff --git a/src/www/bongo-web.py.in b/src/www/bongo-web.py.in index f490a75..0a0fd11 100644 --- a/src/www/bongo-web.py.in +++ b/src/www/bongo-web.py.in @@ -49,11 +49,11 @@ from urllib.parse import parse_qs, urlsplit from bongo_web.auth import (LoginLimiter, authenticate, renew, revoke, valid_csrf) -from bongo_web.calendar_store import (EVENT_NAME, CalendarNotFound, CalendarPathError, - PreconditionFailed, delete_event, - event_to_dict, list_events, make_event, - parse_calendar_path, read_event, - write_event) +from bongo_web.calendar_import import fetch_ics, import_ics +from bongo_web.calendar_store import ( + CALENDAR_NAME, EVENT_NAME, CalendarNotFound, CalendarPathError, + PreconditionFailed, delete_event, event_to_dict, list_calendar_events, + list_calendars, make_event, parse_calendar_path, read_event, write_event) from bongo_web.card_store import (CARD_NAME, CardNotFound, CardPathError, CardPreconditionFailed, delete_card, card_to_dict, list_cards, make_card, @@ -95,6 +95,7 @@ PROVIDER_PRESETS = "@DATA_INSTALL_DIR@/providers/provider-presets.json" ACME_CHALLENGE_DIR = "@XPL_DEFAULT_ACME_CHALLENGE_DIR@" RUN_USER = "@BONGO_USER@" SERVER_NAME = "Bongo" +SERVER_VERSION = "@BONGO_V_STR@" STATIC_FILES = { "/assets/app.css": ("app.css", "text/css; charset=utf-8"), "/assets/app.js": ("app.js", "text/javascript; charset=utf-8"), @@ -173,6 +174,12 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail", "Attendee email addresses (one per line)", "Recurrence rule", "Reminder (minutes before)", "Attendees are stored in the iCalendar event; invitation delivery is not enabled yet.", + "Calendar source", "Import calendar", "Calendar", + "Personal calendar", "New calendar name", "ICS file", + "Calendar URL", "Import once", "Keep synchronized", + "Choose an ICS file or enter a public HTTPS calendar URL.", + "Calendar imported.", "Calendar import failed.", + "Austria public holidays", "Filters could not be loaded.", "Filter name", "Enabled", "Condition", "Value", "Action", "Sender contains", "Recipient contains", "Subject contains", "Sender domain", @@ -441,9 +448,18 @@ class BongoWebHandler(BaseHTTPRequestHandler): return try: if kind == "calendar": - values = [event_to_dict(item) for item in list_events( - session["user"], session["store_cookie"])] - result = {"events": values} + query = parse_qs(urlsplit(self.path).query) + calendar = query.get("calendar", ["personal"])[-1] + if not CALENDAR_NAME.fullmatch(calendar): + raise ValueError("invalid calendar name") + values = [event_to_dict(item) for item in list_calendar_events( + session["user"], session["store_cookie"], calendar)] + result = { + "calendar": calendar, + "calendars": list_calendars( + session["user"], session["store_cookie"]), + "events": values, + } else: values = [card_to_dict(item) for item in list_cards( session["user"], session["store_cookie"])] @@ -471,6 +487,9 @@ class BongoWebHandler(BaseHTTPRequestHandler): resource = str(data.get("resource", "")).strip() etag = str(data.get("etag", "")).strip() or None if kind == "calendar": + calendar = str(data.get("calendar", "personal")).strip() + if not CALENDAR_NAME.fullmatch(calendar): + raise ValueError("invalid calendar name") if resource and not EVENT_NAME.fullmatch(resource): raise ValueError("invalid event resource") resource = resource or os.urandom(16).hex() + ".ics" @@ -481,7 +500,7 @@ class BongoWebHandler(BaseHTTPRequestHandler): created, current = write_event( session["user"], session["store_cookie"], resource, make_event(data, existing), if_match=etag, - if_none_match=None if etag else "*") + if_none_match=None if etag else "*", calendar=calendar) else: if resource and not CARD_NAME.fullmatch(resource): raise ValueError("invalid contact resource") @@ -681,7 +700,7 @@ class BongoWebHandler(BaseHTTPRequestHandler): "user": session["user"], "host": urlsplit(base).hostname or socket.getfqdn(), "domains": self.server.config.get("login_domains", []), - "version": "@BONGO_V_STR@", + "version": SERVER_VERSION, **details, }, {"Vary": "Cookie"}) elif path.startswith("/api/mail/"): @@ -879,6 +898,9 @@ class BongoWebHandler(BaseHTTPRequestHandler): if path == "/api/calendar/events": self._organizer_api_save("calendar") return + if path == "/api/calendar/import": + self._calendar_import() + return if path == "/api/contacts": self._organizer_api_save("contacts") return @@ -1327,6 +1349,55 @@ class BongoWebHandler(BaseHTTPRequestHandler): return self._empty(HTTPStatus.NO_CONTENT, {"Vary": "Cookie"}) + def _calendar_import(self): + session = self._session() + if session is None: + self._json(HTTPStatus.UNAUTHORIZED, + {"error": "authentication required"}) + return + if not valid_csrf(session, self.headers.get("X-Bongo-CSRF", "")): + self._json(HTTPStatus.FORBIDDEN, {"error": "invalid CSRF token"}) + return + try: + data = self._request_data() + calendar = str(data.get("calendar", "")).strip() + source_type = str(data.get("source_type", "")).strip() + subscribe = bool(data.get("subscribe", False)) + source_url = "" + etag = "" + last_modified = "" + if source_type == "file": + if subscribe: + raise ValueError("uploaded calendars cannot be subscribed") + content = data.get("content") + if not isinstance(content, str): + raise ValueError("calendar content is required") + payload = content.encode("utf-8") + elif source_type == "url": + source = fetch_ics( + str(data.get("url", "")), + "Bongo/%s calendar subscriber" % SERVER_VERSION) + payload = source["payload"] + source_url = source["url"] + etag = source["etag"] + last_modified = source["last_modified"] + else: + raise ValueError("invalid calendar source") + result = import_ics( + session["user"], session["store_cookie"], calendar, payload, + source_url=source_url, subscribe=subscribe, etag=etag, + last_modified=last_modified, + product_id="-//Bongo Project//Bongo %s//EN" % SERVER_VERSION) + except (TypeError, ValueError, OverflowError, UnicodeError, + json.JSONDecodeError) as error: + self._json(HTTPStatus.BAD_REQUEST, {"error": str(error)}) + return + except RuntimeError: + logging.exception("Could not import calendar") + self._empty(HTTPStatus.INTERNAL_SERVER_ERROR) + return + self._json(HTTPStatus.CREATED, result, {"Vary": "Cookie"}) + def do_DELETE(self): path = self._path() if path == "/api/calendar/events": @@ -1444,7 +1515,8 @@ class BongoWebHandler(BaseHTTPRequestHandler): created, etag = write_event( session["user"], session["store_cookie"], resource["event"], payload, self.headers.get("If-Match"), - self.headers.get("If-None-Match")) + self.headers.get("If-None-Match"), + calendar=resource.get("calendar", "personal")) except OverflowError: self._empty(HTTPStatus.REQUEST_ENTITY_TOO_LARGE) return @@ -1598,8 +1670,18 @@ class BongoWebHandler(BaseHTTPRequestHandler): except (UnicodeError, ValueError): self._empty(HTTPStatus.BAD_REQUEST) return + calendars = None + if self._path().startswith("/calendars/"): + try: + calendars = list_calendars( + session["user"], session["store_cookie"]) + except RuntimeError: + logging.exception("Could not discover CalDAV calendars") + self._empty(HTTPStatus.INTERNAL_SERVER_ERROR) + return payload = multistatus(self._path(), session["user"], - int(depth_value), requested) + int(depth_value), requested, + calendars=calendars) if payload is None: self._empty(HTTPStatus.NOT_FOUND) return @@ -1634,8 +1716,10 @@ class BongoWebHandler(BaseHTTPRequestHandler): return try: report = parse_report(self._request_body()) - events = list_events(session["user"], session["store_cookie"], - report["start"], report["end"]) + events = list_calendar_events( + session["user"], session["store_cookie"], + resource.get("calendar", "personal"), + report["start"], report["end"]) except OverflowError: self._empty(HTTPStatus.REQUEST_ENTITY_TOO_LARGE) return diff --git a/src/www/bongo_web/calendar_import.py b/src/www/bongo_web/calendar_import.py new file mode 100644 index 0000000..ea39267 --- /dev/null +++ b/src/www/bongo_web/calendar_import.py @@ -0,0 +1,197 @@ +# /**************************************************************************** +# * +# * Copyright (c) 2001 Novell, Inc. All Rights Reserved. +# * +# * This program is free software; you can redistribute it and/or +# * modify it under the terms of version 2 of the GNU General Public License +# * as published by the Free Software Foundation. +# * +# * This program is distributed in the hope that it will be useful, +# * but WITHOUT ANY WARRANTY; without even the implied warranty of +# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# * GNU General Public License for more details. +# * +# * You should have received a copy of the GNU General Public License +# * along with this program; if not, contact Novell, Inc. +# * +# * To contact Novell about this file by physical or electronic mail, you +# * may find current contact information at www.novell.com. +# * +# ****************************************************************************/ + +"""Safe iCalendar import helpers for the Bongo web interface.""" + +from __future__ import annotations + +import hashlib +import http.client +import ipaddress +import socket +import ssl +from urllib.parse import urljoin, urlsplit + +from bongo_web.calendar_store import ( + CALENDAR_NAME, EVENT_DOCUMENT_TYPE, ical_to_json, open_store) + + +MAX_ICS_SIZE = 8 * 1024 * 1024 +MAX_REDIRECTS = 5 +FETCH_TIMEOUT = 20 + + +def _public_addresses(hostname, port): + addresses = [] + for family, socktype, protocol, unused_name, address in socket.getaddrinfo( + hostname, port, type=socket.SOCK_STREAM): + value = ipaddress.ip_address(address[0]) + if not value.is_global: + raise ValueError("calendar URL resolves to a non-public address") + addresses.append((family, socktype, protocol, address)) + if not addresses: + raise ValueError("calendar URL has no usable address") + return addresses + + +class _PinnedHttpsConnection(http.client.HTTPSConnection): + def __init__(self, hostname, port, address, timeout): + super().__init__(hostname, port, timeout=timeout, + context=ssl.create_default_context()) + self._address = address + + def connect(self): + self.sock = socket.create_connection( + (self._address[0], self._address[1]), self.timeout) + if self._tunnel_host: + self._tunnel() + self.sock = self._context.wrap_socket( + self.sock, server_hostname=self.host) + + +def fetch_ics(url, user_agent="Bongo calendar subscriber"): + """Fetch a public HTTPS calendar without allowing SSRF redirects.""" + current = str(url).strip() + for redirect in range(MAX_REDIRECTS + 1): + parsed = urlsplit(current) + if (parsed.scheme != "https" or not parsed.hostname + or parsed.username is not None or parsed.password is not None + or parsed.fragment): + raise ValueError("calendar URL must be a public HTTPS URL") + port = parsed.port or 443 + address = _public_addresses(parsed.hostname, port)[0][3] + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + connection = _PinnedHttpsConnection( + parsed.hostname, port, address, FETCH_TIMEOUT) + try: + connection.request( + "GET", path, + headers={"Accept": "text/calendar, application/ics;q=0.9", + "User-Agent": user_agent}) + response = connection.getresponse() + if response.status in (301, 302, 303, 307, 308): + location = response.getheader("Location") + response.read() + if not location or redirect == MAX_REDIRECTS: + raise ValueError("calendar URL has too many redirects") + current = urljoin(current, location) + continue + if response.status != 200: + raise ValueError( + "calendar server returned HTTP %d" % response.status) + payload = response.read(MAX_ICS_SIZE + 1) + if len(payload) > MAX_ICS_SIZE: + raise ValueError("calendar source is larger than 8 MiB") + return { + "payload": payload, + "url": current, + "etag": response.getheader("ETag", ""), + "last_modified": response.getheader("Last-Modified", ""), + } + finally: + connection.close() + raise ValueError("calendar URL redirect failed") + + +def _components(payload, product_id): + if len(payload) > MAX_ICS_SIZE: + raise ValueError("calendar source is larger than 8 MiB") + text = payload.decode("utf-8-sig", "strict") + try: + import vobject + calendars = list(vobject.readComponents(text)) + except Exception as error: + raise ValueError("invalid iCalendar source") from error + events = [] + display_name = "" + for calendar in calendars: + if not display_name and hasattr(calendar, "x_wr_calname"): + display_name = str(calendar.x_wr_calname.value) + timezones = calendar.contents.get("vtimezone", []) + for event in calendar.contents.get("vevent", []): + if not hasattr(event, "uid") or not str(event.uid.value).strip(): + raise ValueError("calendar event has no UID") + lines = [ + "BEGIN:VCALENDAR\r\n", + "VERSION:2.0\r\n", + "PRODID:" + product_id + "\r\n", + ] + lines.extend(timezone.serialize() for timezone in timezones) + lines.append(event.serialize()) + lines.append("END:VCALENDAR\r\n") + events.append((str(event.uid.value), "".join(lines).encode("utf-8"))) + if not events: + raise ValueError("calendar source contains no events") + return display_name, events + + +def import_ics(user, store_cookie, calendar_name, payload, *, + source_url="", subscribe=False, etag="", last_modified="", + client_factory=open_store, to_json=ical_to_json, + product_id="-//Bongo Project//Bongo//EN"): + """Import all VEVENTs into one existing or newly-created calendar.""" + calendar_name = str(calendar_name).strip() + if not CALENDAR_NAME.fullmatch(calendar_name): + raise ValueError("invalid calendar name") + display_name, events = _components(payload, product_id) + client = None + imported = 0 + try: + client = client_factory(user, store_cookie) + path = "/calendars/" + calendar_name + client.Create(path, existingOk=True) + for uid, event in events: + filename = "ics-" + hashlib.sha256( + (calendar_name + "\0" + uid).encode("utf-8")).hexdigest() + event_path = "/events/" + filename + document = to_json(event) + if client.Info(event_path) is None: + client.Write("/events", EVENT_DOCUMENT_TYPE, document, + filename=filename, link=path) + else: + client.Replace(event_path, document) + links = {item.filename for item in client.Links(event_path)} + if path not in links: + client.Link(path, event_path) + imported += 1 + if display_name: + client.PropSet(path, "bongo.calendar.icsname", display_name) + if subscribe: + if not source_url: + raise ValueError("subscription URL is required") + client.PropSet(path, "bongo.calendar.url", source_url) + client.PropSet(path, "bongo.calendar.etag", etag) + client.PropSet(path, "bongo.calendar.lastmodified", last_modified) + client.PropSet(path, "bongo.calendar.type", "web") + return {"calendar": calendar_name, "events": imported, + "subscribed": bool(subscribe)} + except ValueError: + raise + except Exception as error: + raise RuntimeError("could not import calendar") from error + finally: + if client is not None: + try: + client.Quit() + except Exception: + pass diff --git a/src/www/bongo_web/calendar_store.py b/src/www/bongo_web/calendar_store.py index 05e7941..cafcfa0 100644 --- a/src/www/bongo_web/calendar_store.py +++ b/src/www/bongo_web/calendar_store.py @@ -29,6 +29,7 @@ from urllib.parse import quote, unquote EVENT_NAME = re.compile(r"^[A-Za-z0-9._~-]{1,240}\.ics$") +CALENDAR_NAME = re.compile(r"^[A-Za-z0-9._~-]{1,128}$") EVENT_DOCUMENT_TYPE = 0x0003 @@ -51,20 +52,22 @@ def parse_calendar_path(path, authenticated_user): user = unquote(parts[1]) if user != authenticated_user: raise PermissionError("calendar owner differs from authenticated user") - if unquote(parts[2]) != "personal": - raise CalendarNotFound("calendar does not exist") + calendar = unquote(parts[2]) + if not CALENDAR_NAME.fullmatch(calendar): + raise CalendarPathError("invalid calendar name") if len(parts) == 3: - return {"kind": "calendar", "user": user, "calendar": "personal"} + return {"kind": "calendar", "user": user, "calendar": calendar} event = unquote(parts[3]) if not EVENT_NAME.fullmatch(event): raise CalendarPathError("invalid event resource name") - return {"kind": "event", "user": user, "calendar": "personal", + return {"kind": "event", "user": user, "calendar": calendar, "event": event, "store_path": "/events/" + event[:-4]} -def event_href(user, event): - return "/calendars/{}/personal/{}".format( - quote(user, safe="@.-_~"), quote(event, safe="._~-")) +def event_href(user, event, calendar="personal"): + return "/calendars/{}/{}/{}".format( + quote(user, safe="@.-_~"), quote(calendar, safe="._~-"), + quote(event, safe="._~-")) def open_store(user, store_cookie): @@ -178,6 +181,7 @@ def event_to_dict(item): return { "resource": item["href"].rsplit("/", 1)[-1], + "calendar": item.get("calendar", "personal"), "etag": item["etag"], "uid": _component_value(event, "uid"), "summary": _component_value(event, "summary", "(no title)"), @@ -312,8 +316,11 @@ def make_event(data, existing=None): def write_event(user, store_cookie, event, payload, if_match=None, if_none_match=None, client_factory=open_store, - to_json=ical_to_json, to_ical=json_to_ical): + to_json=ical_to_json, to_ical=json_to_ical, + calendar="personal"): """Create or replace one event and return ``(created, etag)``.""" + if not CALENDAR_NAME.fullmatch(calendar): + raise ValueError("invalid calendar name") document = to_json(payload) path = "/events/" + event[:-4] client = None @@ -332,7 +339,8 @@ def write_event(user, store_cookie, event, payload, if_match=None, client.Replace(path, document) else: client.Write("/events", EVENT_DOCUMENT_TYPE, document, - filename=event[:-4], link="/calendars/personal") + filename=event[:-4], + link="/calendars/" + calendar) return not exists, make_etag(to_ical(document)) except (PreconditionFailed, ValueError): raise @@ -363,14 +371,51 @@ def delete_event(user, store_cookie, event, if_match=None, _close(client) -def list_events(user, store_cookie, start=None, end=None, - client_factory=open_store, to_ical=json_to_ical): +def list_calendars(user, store_cookie, client_factory=open_store): client = None try: client = client_factory(user, store_cookie) - calendar = client.Info("/calendars/personal") + output = [] + for item in client.List( + "/calendars", + props=["bongo.calendar.url", "bongo.calendar.icsname"]): + name = item.filename.rstrip("/").rsplit("/", 1)[-1] + if not CALENDAR_NAME.fullmatch(name): + continue + output.append({ + "name": name, + "display_name": item.props.get( + "bongo.calendar.icsname", name), + "subscribed": bool(item.props.get("bongo.calendar.url")), + "source_url": item.props.get("bongo.calendar.url", ""), + }) + output.sort(key=lambda item: ( + item["name"] != "personal", item["display_name"].casefold())) + return output + except Exception as error: + raise RuntimeError("could not list calendars") from error + finally: + _close(client) + + +def list_events(user, store_cookie, start=None, end=None, + client_factory=open_store, to_ical=json_to_ical): + return list_calendar_events( + user, store_cookie, "personal", start=start, end=end, + client_factory=client_factory, to_ical=to_ical) + + +def list_calendar_events(user, store_cookie, calendar_name, + start=None, end=None, client_factory=open_store, + to_ical=json_to_ical): + if not CALENDAR_NAME.fullmatch(calendar_name): + raise ValueError("invalid calendar name") + client = None + try: + client = client_factory(user, store_cookie) + calendar = client.Info("/calendars/" + calendar_name) if calendar is None: - raise CalendarNotFound("personal calendar not found") + raise CalendarNotFound("calendar not found") output = [] for item in client.Events( calendar.uid, ["nmap.document", "nmap.event.calendars"], @@ -384,8 +429,9 @@ def list_events(user, store_cookie, start=None, end=None, filename += ".ics" if not EVENT_NAME.fullmatch(filename): continue - output.append({"href": event_href(user, filename), - "etag": make_etag(payload), "data": payload}) + output.append({"href": event_href(user, filename, calendar_name), + "etag": make_etag(payload), "data": payload, + "calendar": calendar_name}) return output except CalendarNotFound: raise diff --git a/src/www/bongo_web/dav.py b/src/www/bongo_web/dav.py index d6dacfc..e00034e 100644 --- a/src/www/bongo_web/dav.py +++ b/src/www/bongo_web/dav.py @@ -181,14 +181,14 @@ def _href_property(name, href, namespace=DAV): return element -def discovery_resources(user): +def discovery_resources(user, calendars=None): encoded = quote(user, safe="@.-_~") principal = f"/principals/{encoded}/" home = f"/calendars/{encoded}/" calendar = f"{home}personal/" address_home = f"/addressbooks/{encoded}/" addressbook = f"{address_home}personal/" - return { + resources = { "/calendars/": { qname(DAV, "resourcetype"): _resource_type((DAV, "collection")), qname(DAV, "displayname"): "Bongo calendars", @@ -244,6 +244,24 @@ def discovery_resources(user): qname(CS, "getctag"): "0", }, } + for item in calendars or (): + name = str(item.get("name", "")) + if not name or any(char not in + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + "0123456789._~-" for char in name): + continue + href = f"{home}{quote(name, safe='._~-')}/" + resources[href] = { + qname(DAV, "resourcetype"): _resource_type( + (DAV, "collection"), (CALDAV, "calendar")), + qname(DAV, "displayname"): str( + item.get("display_name") or name), + qname(DAV, "owner"): _href_property("owner", principal), + qname(CALDAV, "supported-calendar-component-set"): + _component_set(), + qname(CS, "getctag"): "0", + } + return resources def _component_set(): @@ -259,8 +277,8 @@ def _append_property(parent, name, value): ET.SubElement(parent, name).text = value -def multistatus(path, user, depth, requested=None): - resources = discovery_resources(user) +def multistatus(path, user, depth, requested=None, calendars=None): + resources = discovery_resources(user, calendars) if path not in resources: return None selected = [path] diff --git a/src/www/static/app.js b/src/www/static/app.js index 35d6210..4dced7f 100644 --- a/src/www/static/app.js +++ b/src/www/static/app.js @@ -189,9 +189,26 @@ function setAllDayInputTypes(allDay) { } async function loadCalendar() { - const response = await fetch("/api/calendar/events", {credentials: "same-origin"}); + const selector = document.querySelector("#calendar-select"); + const selected = selector.value || "personal"; + const response = await fetch(`/api/calendar/events?calendar=${encodeURIComponent(selected)}`, + {credentials: "same-origin"}); if (!response.ok) return; - const events = (await response.json()).events; + const result = await response.json(); + const events = result.events; + const choices = result.calendars || []; + const makeOptions = () => choices.map(item => { + const option = document.createElement("option"); + option.value = item.name; + option.textContent = item.display_name || item.name; + if (item.subscribed) option.textContent += " ↻"; + return option; + }); + selector.replaceChildren(...makeOptions()); + selector.value = choices.some(item => item.name === selected) ? selected : "personal"; + const importTarget = document.querySelector("#calendar-import-target"); + importTarget.replaceChildren(...makeOptions(), Object.assign( + document.createElement("option"), {value: "__new__", textContent: tr("New calendar name")})); document.querySelector("#calendar-list").replaceChildren(...events.map(item => { const row = document.createElement("li"); const button = document.createElement("button"); @@ -206,9 +223,12 @@ async function loadCalendar() { function editCalendarEvent(item = {}) { const form = document.querySelector("#calendar-form"); + document.querySelector("#calendar-import-form").hidden = true; document.querySelector("#calendar-resource").value = item.resource || ""; document.querySelector("#calendar-etag").value = item.etag || ""; document.querySelector("#calendar-uid").value = item.uid || ""; + document.querySelector("#calendar-event-calendar").value = + item.calendar || document.querySelector("#calendar-select").value || "personal"; document.querySelector("#calendar-summary").value = item.summary || ""; document.querySelector("#calendar-all-day").checked = Boolean(item.all_day); setAllDayInputTypes(Boolean(item.all_day)); @@ -231,6 +251,7 @@ async function saveCalendarEvent(event) { const payload = {resource: document.querySelector("#calendar-resource").value, etag: document.querySelector("#calendar-etag").value, uid: document.querySelector("#calendar-uid").value, + calendar: document.querySelector("#calendar-event-calendar").value, summary: document.querySelector("#calendar-summary").value, all_day: document.querySelector("#calendar-all-day").checked, start: document.querySelector("#calendar-start").value, @@ -247,6 +268,69 @@ async function saveCalendarEvent(event) { if (response.ok) { event.currentTarget.hidden = true; await loadCalendar(); } } +function calendarImportTarget() { + const selected = document.querySelector("#calendar-import-target").value; + return selected === "__new__" ? + document.querySelector("#calendar-import-name").value.trim() : selected; +} + +async function importCalendar(event) { + event.preventDefault(); + const file = document.querySelector("#calendar-import-file").files[0]; + const url = document.querySelector("#calendar-import-url").value.trim(); + const status = document.querySelector("#calendar-import-status"); + let payload = {calendar: calendarImportTarget(), + subscribe: document.querySelector("#calendar-import-mode").value === "subscribe"}; + if (file && url) { + status.textContent = tr("Choose an ICS file or enter a public HTTPS calendar URL."); + return; + } + if (file) { + if (payload.subscribe) { + status.textContent = tr("Choose an ICS file or enter a public HTTPS calendar URL."); + return; + } + payload.source_type = "file"; + payload.content = await file.text(); + } else if (url) { + payload.source_type = "url"; + payload.url = url; + } else { + status.textContent = tr("Choose an ICS file or enter a public HTTPS calendar URL."); + return; + } + const response = await fetch("/api/calendar/import", { + method: "POST", credentials: "same-origin", + headers: {"Content-Type": "application/json", "X-Bongo-CSRF": session.csrf_token}, + body: JSON.stringify(payload)}); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + status.textContent = error.error || tr("Calendar import failed."); + return; + } + const result = await response.json(); + status.textContent = tr("Calendar imported."); + document.querySelector("#calendar-select").value = result.calendar; + document.querySelector("#calendar-import-form").hidden = true; + await loadCalendar(); +} + +function showCalendarImport() { + document.querySelector("#calendar-form").hidden = true; + document.querySelector("#calendar-import-form").hidden = false; + document.querySelector("#calendar-import-status").textContent = ""; +} + +function useAustriaHolidayCalendar() { + document.querySelector("#calendar-import-target").value = "__new__"; + document.querySelector("#calendar-import-name").disabled = false; + document.querySelector("#calendar-import-name").value = "holidays-at"; + document.querySelector("#calendar-import-url").value = + "https://www.feiertage-oesterreich.at/kalender-download/ics/feiertage-oesterreich.ics"; + document.querySelector("#calendar-import-file").value = ""; + document.querySelector("#calendar-import-mode").value = "subscribe"; +} + async function deleteCalendarEvent() { const resource = document.querySelector("#calendar-resource").value; const etag = document.querySelector("#calendar-etag").value; @@ -1131,8 +1215,27 @@ document.querySelectorAll("a[data-section]").forEach(link => document.querySelector("#mail-folder").addEventListener("change", loadConversations); document.querySelector("#mail-refresh").addEventListener("click", loadConversations); document.querySelector("#calendar-new").addEventListener("click", () => editCalendarEvent()); +document.querySelector("#calendar-import").addEventListener("click", showCalendarImport); document.querySelector("#calendar-refresh").addEventListener("click", loadCalendar); +document.querySelector("#calendar-select").addEventListener("change", loadCalendar); document.querySelector("#calendar-form").addEventListener("submit", saveCalendarEvent); +document.querySelector("#calendar-import-form").addEventListener("submit", importCalendar); +document.querySelector("#calendar-import-target").addEventListener("change", event => { + document.querySelector("#calendar-import-name").disabled = event.currentTarget.value !== "__new__"; +}); +document.querySelector("#calendar-import-file").addEventListener("change", event => { + if (event.currentTarget.files.length) { + document.querySelector("#calendar-import-url").value = ""; + document.querySelector("#calendar-import-mode").value = "once"; + } +}); +document.querySelector("#calendar-import-url").addEventListener("input", event => { + if (event.currentTarget.value) document.querySelector("#calendar-import-file").value = ""; +}); +document.querySelector("#calendar-import-austria").addEventListener("click", useAustriaHolidayCalendar); +document.querySelector("#calendar-import-cancel").addEventListener("click", () => { + document.querySelector("#calendar-import-form").hidden = true; +}); document.querySelector("#calendar-all-day").addEventListener("change", event => setAllDayInputTypes(event.currentTarget.checked)); document.querySelector("#calendar-delete").addEventListener("click", deleteCalendarEvent); document.querySelector("#calendar-cancel").addEventListener("click", () => { document.querySelector("#calendar-form").hidden = true; }); diff --git a/src/www/static/index.html b/src/www/static/index.html index 7ae9724..3472b5b 100644 --- a/src/www/static/index.html +++ b/src/www/static/index.html @@ -109,10 +109,30 @@