Skip to content

Security: dapplo/Lernkicker

Security

docs/security.md

Security concept

As of 2026-10-25. Lernkicker is public through Tailscale Funnel, e.g. at https://lernkicker.<tailnet>.ts.net, and stores data about children from several families. So: the address is no secret, every access needs a login, and each family only sees its own data.

Items marked (planned) are not built yet.

Situation and threats

  • The address is public. Every HTTPS certificate for *.ts.net is in the public Certificate Transparency logs; scanners find the name within minutes. Security must never depend on nobody knowing the address.
  • Attackers from the internet: guessing passwords or PINs, exploiting known bugs, using the NAS as a way into the home network.
  • Curious users: a parent trying to see other children's results.
  • Children: a child trying to reach the parent view or the solutions (harmless, but worth considering).
  • Data protection: data about other people's children; the operator (Robin) is responsible for it (GDPR).

1. Network and operations

Measure Implementation
Only the app is public Funnel forwards only port 443 to the app container. DSM, SSH and other NAS services stay invisible.
Isolate the container Its own Tailscale node with tag:lernkicker; the tailnet policy gives this tag no access to other devices. The app container publishes no ports and shares the Tailscale container's network, so it is not reachable from the LAN. Docker does not block outgoing connections to the home network; the app only makes them to the internet (calendar import, Telegram) and refuses private addresses itself (section 6). A router or NAS firewall rule can block the home network completely.
Harden the container Runs as a non-root user (chiseled .NET image, uid 1654), read-only file system (read_only: true), the only writable folder is /data (plus a /tmp tmpfs), cap_drop: [ALL], no-new-privileges.
TLS Tailscale terminates TLS in its own container. The app sends HSTS.
Real client IP For rate limits the app reads X-Forwarded-For only from the local Tailscale proxy (ASP.NET Core trusts loopback by default).
Kill switch tailscale funnel off in the sidecar (or removing AllowFunnel from the serve config) makes the app private again at once; it stays reachable inside the tailnet.
Updates Dependabot (.github/dependabot.yml) proposes NuGet, base image and Actions updates weekly; pull the new image in Container Manager.
Backup Hyper Backup encrypts the data/ folder (SQLite database, data protection keys).

2. Login

Without a login, only the app's static files, /api/status, the setup on a fresh server and /api/format/* answer; the last holds the JSON schemas and the prompt for AI assistants (docs/ai-prompt/), no data.

No open registration. Accounts only come from invitations, except the very first one: on a fresh server (no admin yet) the setup page creates the admin. It only answers inside the tailnet (TailnetOnlyFilter), only while no admin exists (checked under a lock, so two visitors at once cannot both become admin), and is rate limited like the login. Funnel is off by default, so between starting the container and filling in the page only devices in your own tailnet can reach it. Do the setup right after the first start, before turning on Funnel. A fresh server can also start from a backup (POST /api/setup/restore, same rules): the zip must carry the backup manifest and a SQLite database with Lernkicker's migrations; key files are only taken as flat keys/*.xml names, so no path from the zip reaches the file system.

Backups (GET /api/admin/backup, admin, tailnet only) contain the whole database and the Data Protection keys, i.e. everything needed to read all data and decrypt the Telegram token. Treat the file like the data folder: store it encrypted.

Who How Protection
Admin Email + password; passkey or TOTP 2FA (planned) With the Tailscale sidecar the admin API only answers inside the tailnet: requests through Funnel carry the Tailscale-Funnel-Request header, and TailnetOnlyFilter answers them with 404. TSDProxy does not send that header, so behind TSDProxy with Funnel on the admin API is protected by the login alone.
AI assistant, script API key of a family (Authorization: Bearer lk_…), only for /mcp See below: hashed, per family, optionally read-only, revocable, rate limited per key
Parent Invitation link (single use, valid 7 days), then email + password (at least 12 characters; check against leaked passwords (planned)) Locked for 15 minutes after 5 failed attempts, rate limit per IP
Child A parent shows a QR code; scanning it pairs the child's phone (single-use code, valid 10 minutes). A logged-in parent can also pair the device they are using with one tap ("Use this device"): the browser creates such a code and redeems it at once, so the parent login stands in for the QR code. Then a 4-digit PIN. A parent who learns themselves ("Learn myself") uses their own adult profile without any device or PIN: that session is tied to their parent login and ends with it, or when the profile is deleted or no longer theirs. A PIN is useless without a paired device. After 5 wrong PINs the device is locked until a parent unlocks it. Parents see paired devices and can remove them; a removed or locked device ends the child's session at once.

Technology: ASP.NET Core Identity. The device token is 256 random bits and stored only as a hash; invitation and pairing codes are stored as hashes too. Cookies are Secure, HttpOnly, SameSite=Strict, so another site cannot make the browser send them (this is the protection against cross-site requests; the API has no forms). The device cookie is limited to /api/device. Data protection keys live in /data/keys, so logins survive restarts and image updates.

API keys for AI assistants and scripts (/mcp, docs/mcp.md): a parent creates them on the account page, named, optionally read-only. A key is lk_ plus 256 random bits, shown once and stored only as SHA-256 hash (with its last 4 characters, so parents can tell keys apart). It belongs to the family and the parent who created it: deleting that parent or the family removes it, and any parent of the family can revoke it, which takes effect with the next request. A key only works on /mcp (its own authentication scheme, ApiKey; the parent and child policies ignore it) and the MCP endpoint only takes keys, no cookies, so a web page cannot make a browser use it. A read-only key gets neither the list of the tools that change data nor their calls (an authorization policy per tool). Through the key, an assistant can do what a parent does with the own family's learner profiles (add a child, let a parent of the family learn, rename or remove a profile), quizzes, lessons, assignments, results and calendar, and nothing else: no parent accounts or invitations, PINs, pairing or devices (so a key can't log in as a child), sharing, Telegram, calendar subscriptions (which fetch from the internet) or administration. Removing a profile is marked destructive, so MCP clients ask before calling it. Requests are rate limited per key; the time of the last use is stored (at most once a minute). With Funnel on, /mcp is public like the rest of the app, which cloud assistants need; Lernkicker:Mcp:Enabled=false removes it.

Forgotten password without email (planned): the admin creates a single-use reset link (valid 1 hour) for the parent and sends it the way the invitation went.

3. Permissions and family isolation

  • Every table with family data has a FamilyId. A global EF Core query filter limits every query to the logged-in user's family; the use cases also check that every id belongs to that family.

  • Every endpoint requires a policy (admin, parent, child). Child endpoints never return solutions: the PWA sends the answers, the server grades them and only then returns solutions and explanations.

  • Ids exposed to the outside are random GUIDs, never sequential numbers.

  • Integration tests (FamilyIsolationTests, for MCP McpTests) check that family A cannot read or change family B's data. New endpoints and MCP tools need such a test.

  • The admin manages families but sees no results of other families' children.

  • Shared library: a parent can share a quiz or lesson with all families on the same server. This is the one place where data crosses families, and only the file crosses: other parents see the title, subject, grade, size and sharing date, can preview it as a child would see it, and can copy it into their own library. They never see who shared it, the sharing family's children, assignments or results. The repository methods that cross families (ListSharedAnyFamilyAsync, FindSharedAnyFamilyAsync) only return shared, current items. A copy belongs to the copying family; stopping to share hides the original but leaves copies. SharedLibraryTests cover this.

  • Playing with others: nothing is unlocked until a parent does it per child, and two children of different families only meet when both families allow it (to everyone, to the other family, or to that child). A child only sees the first name and family name of children they may play with, and nothing else of the other family. Parents see the names of the other families on the server (to pick them) and the children whose parents opened them to their child. Matches are the only entity shared between families; every read and move checks that the caller sits at the table, and a table someone may not join answers like one that does not exist. The live connection (/hubs/play) needs a child's login and only carries "something changed"; the data itself comes through the API. PlayTests cover this.

4. Input and uploads

  • Quizzes: JSON only, at most 256 KB, validated against schema/quiz-v1.schema.json and the content rules (QuizRules). Invalid files are rejected with a list of issues.
  • All quiz text is rendered as text, never as HTML (Blazor encodes by default; no MarkupString for quiz content). A quiz cannot bring script into someone else's browser, even when shared.
  • Content Security Policy: default-src 'self', scripts only from the app itself plus 'wasm-unsafe-eval' for WebAssembly, no inline scripts or styles, no CDNs, no trackers, frame-ancestors 'none'.
  • The server only returns error codes, never text built from input.
  • Pictures of quizzes and lessons: uploaded with the JSON (zip or several files), at most 2 MB each and 20 MB per file, 100 files per upload, 25 MB per request. Zip entries with absolute paths, \, : or .. are refused, and unpacking stops at the size limit whatever the zip claims. Formats are recognised by their content: PNG, JPEG, GIF, WebP, and SVG only after cleaning (SvgSanitizer: XML read without DTD processing, an allowlist of drawing elements and attributes in the SVG namespace, no scripts, event handlers, foreignObject, embedded images, animations, or href/url() that leave the file, no @import). The parents' page re-encodes large photos in the browser, which drops EXIF data such as GPS.
  • Pictures are stored in the database once per SHA-256 and served from /api/media/{hash} only to a parent or child whose family uses the picture, or when a current quiz or lesson in the shared library uses it. Responses carry Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox and nosniff, so an SVG opened directly can run or load nothing; in the app pictures are <img> elements, where browsers never run SVG scripts or fetch outside resources. Quiz content never links pictures from other servers (the app's CSP allows only img-src 'self' data:).
  • Uploads through MCP (upload_quiz, upload_lesson) go through the same checks as the API: size limits, schema and content rules, picture checks and SVG cleaning. Pictures come as base64; the total per call is limited like an upload request.

5. Telegram

  • The bot only reads private chats. A chat that is not linked can send a link code (as /start <code>, as the bare code or as the whole link) and otherwise only gets a fixed hint how to connect; nothing about it is stored. Linked chats get the commands /next and /stop.
  • Link codes are random (like the pairing codes), single use, valid 10 minutes, stored only as a hash and only created from the logged-in parent view. Creating a new one removes the old ones. Linking moves a chat that was linked before, so one chat belongs to one parent.
  • Messages contain only what the parent sees on the parents' page anyway: child names, quiz titles, points, marks and dates. They are sent as plain text, never Markdown or HTML, so names and titles cannot inject formatting or links. Telegram stores these messages on its servers (privacy notice).
  • /stop in the chat or "Trennen" on the parents' page removes the link at once; deleting a parent or family removes it too.
  • In BotFather: disable joining groups (/setjoingroups → Disable).
  • The bot token is set by the admin on the admin page (tailnet only), checked with Telegram and stored encrypted with ASP.NET Core Data Protection (the keys in data/keys, like the cookies). It is write-only: the API never returns it. Whoever has the data folder can decrypt it, so backups of it must be encrypted (Hyper Backup with encryption, docs/operations.md). Alternatively it comes from a file (Lernkicker:Telegram:BotTokenFile, a Docker secret), which then wins. Never in the repository, the image or .env. Without a token Telegram is off.
  • Long polling: Telegram never needs to reach the app, and no port is opened. Outgoing connections only go to api.telegram.org.
  • Children on Telegram (planned): only when a parent has turned on "Telegram allowed"; turning it off unlinks at once.

6. Calendar

Feed for the parents' calendar app

  • Each child has at most one secret ICS address with a 256-bit random token; only its SHA-256 hash is stored, so the link is shown once. Calendar apps fetch it without a login. A new link replaces the old one, and the parent can turn it off.
  • The feed only contains the child's first name (in the calendar name), titles, dates, times and places: no results, no PINs, no other children.
  • It is rate limited per IP (Lernkicker:Limits:CalendarRequestsPerMinute) and an unknown token is a plain 404.

Importing subscribed calendars (SSRF)

A parent can paste any URL, and the server fetches it. That must not become a way into the home network or the tailnet:

  • Only http, https and webcal (read as https) URLs, no user name or password in the URL, at most 2000 characters.
  • Every connection, including each of at most 3 redirects, resolves the host and refuses loopback, private (10/8, 172.16/12, 192.168/16), carrier-grade NAT and Tailscale (100.64/10, fd7a:115c:a1e0::/48 via fc00::/7), link-local and cloud metadata (169.254/16, fe80::/10), multicast, reserved and NAT64 addresses, also as IPv4-mapped IPv6. The check runs in the socket connect callback, on the address actually used, so DNS rebinding does not get around it. No proxy is used.
  • At most 2 MB, 15 seconds, and the answer must be an ICS calendar; at most 1000 imported dates, 5 subscriptions per child, and the import endpoints are rate limited.
  • Parents only see the host of a subscription and an error code, never the response body.
  • Imported text is shown as text, never as HTML, and clipped to the field lengths. Imported dates are read-only.

7. Rate limits and monitoring

  • ASP.NET Core rate limiter per IP for parent logins and invitations (Lernkicker:Limits:LoginsPerMinute) and for pairing and PIN login on the child's device (Lernkicker:Limits:ChildLoginsPerMinute). Calendar imports and the ICS feed: Lernkicker:Limits:CalendarRequestsPerMinute. Requests with an API key to /mcp: Lernkicker:Limits:ApiKeyRequestsPerMinute per key. Limits for uploads (planned).
  • Failed logins and lockouts are logged, without passwords, PINs or children's answers.
  • Alerts to the admin via Telegram on suspicious patterns (planned), e.g. more than 20 failures in 10 minutes or a device lock.

8. Data protection

  • As little data as possible: for children (and adult learners) only a first name or nickname and the grade (none for adults), no date of birth, no photo. For parents an email address (as login name), a display name and the UI language.
  • Consent: when redeeming the invitation the parent accepts a short privacy notice (/privacy: who is responsible, what is stored, where, how to delete it).
  • Deletion: parents can delete a child with all results; the admin can delete a whole family. Deletion removes everything from the database at once; backups expire after their retention time. Export as JSON (planned).
  • Retention (planned): results older than two school years are deleted automatically.
  • No third parties: no analytics, no external fonts. Telegram only for families who turn it on.
  • A written privacy notice is advisable; this concept is not legal advice.

Email

Email is not needed to start. The app creates invitation (and later reset) links, and the admin sends them personally (messenger, in person). Notifications will go through Telegram.

If the app should send email later (invitations, "forgot password", a weekly report for parents without Telegram), it needs an SMTP account:

Option What is needed Notes
Existing mailbox (e.g. Gmail, GMX, mailbox.org) SMTP server, user, app password Quickest. The sender is that mailbox. Mind daily limits.
Delivery service (e.g. Brevo, Mailjet, Postmark) Account, API key or SMTP credentials, own domain with SPF and DKIM records Most reliable delivery; the small free tiers are enough here.
Own mail server on the NAS – Not recommended: mail from home connections often ends up as spam.

In the app: sending with MailKit, credentials as a Docker secret, sender e.g. lernkicker@<your-domain>. Without SMTP configured, the app shows the links for copying instead.

There aren't any published security advisories