Files
verdande/internal/httpapi/integrations_handlers.go
T
Kristian WindandClaude Opus 5 4aa8bd6a40
Release / Build and publish image (push) Failing after 9m54s
fix(sikkerhed): resten af gennemgangen — SSRF, tre ubegrænsede veje, og fire hemmeligheder
**H2 — SSRF gennem AI-udbyderens adresse.** `base_url` blev gemt uden kontrol og
hentet med en almindelig klient, og udbyderens svar blev givet tilbage ordret.
Det var en fungerende skanner af det interne net for enhver bruger: peg den på en
vært, læs de første tre hundrede bytes af det, der svarede. På en homelab er det
det meste af, hvad der er værd at nå.

Nu gennem `safedial`, og svarets krop går i loggen frem for i svaret. Operatøren
har stadig brug for beskeden — for en lokal model er den som regel selve
problemet — så den er der, bare kun for den, der kører instansen.

**M2 — Web Push havde tre.** Endepunktet blev gemt ukontrolleret og POSTet til på
en tidsplan, den, der ringer, selv vælger: sæt en påmindelse et minut ude, og
serveren henter adressen, hvert minut. Upserten flyttede desuden ejerskabet med
`user_id = excluded.user_id`, så enhver, der kendte en andens endepunkt, kunne
overtage det — offerets telefon ville få angriberens beskeder og ingen af sine
egne. Og `DeletePushSubscription` havde ingen ejer-kontrol.

**H3 — tre veje uden loft.** FTS-udtrykket byggede omkring halvtreds bytes pr.
ord uden grænse; en søgning på en kvart megabyte blev til to megabyte
MATCH-udtryk og seksogtyve sekunder inde i SQLite, mens den holdt på én af kun
fire til otte forbindelser. Nogle få af dem samtidig, og databasen er utilgængelig
for alle på instansen, logins inklusive. `filterquery.Parse` er kvadratisk —
halvtreds tusind led er syv sekunders CPU og seksogfyrre gigabyte allokering i én
forespørgsel — og havde hverken loft på antal eller på dybde. Og noteimporten
læste hver fil i arkivet uden grænse: 64 MB komprimeret kan blive til
seksogtres gigabyte skrevet ned på den disk, databasen ligger på.

**M1 — `comment_id` blev taget på tro.** Rettighedskontrollen så på opgaven i
adressen, mens filen blev hængt på den kommentar, formularen bad om. En viewer på
et delt projekt kan liste dets kommentarer, så hun kunne læse et id og lægge en
fil under en andens kommentar i et projekt, hun kun må læse.

**M4 — fire hemmeligheder lå i klartekst i hver sikkerhedskopi.** TOTP-koden og
AI-nøglen forsegles nu; begge konverterer sig selv næste gang de skrives.
Kalenderfeedets og mailadressens tokens gør ikke — de slås op på deres præcise
værdi og vises i fladen mere end én gang, hvilket en hash ikke kan. Det står nu
skrevet i `SECURITY.md`, som påstod det modsatte. At rette det betyder, at
adressen kun kan vises én gang, og det er en beslutning om, hvordan funktionen
virker.

Og en prøve på, at politikken overhovedet bliver sendt. Det var den, der gjorde
XSS-en i nat til indsat HTML frem for kørt script, og der var ingen prøve, der
rørte den — en foranstaltning, ingen prøve rører, kan forsvinde i en oprydning,
uden at nogen opdager det. Målt på script-src alene: `style-src 'unsafe-inline'`
står der med vilje, og en indsat stil kan gøre en side grim, ikke køre kode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 08:39:52 +02:00

475 lines
15 KiB
Go

package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/kristianwind/verdande/internal/ai"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/quickadd"
"github.com/kristianwind/verdande/internal/safedial"
"github.com/kristianwind/verdande/internal/store"
)
// --- mail to task -------------------------------------------------------------------
type mailAddressResponse struct {
Address string `json:"address"`
// Configured says whether a mail server has been set up at all. Without one
// the address is still minted and still valid — the token is real, and the
// endpoint that receives mail works — but nothing on the internet routes to
// it yet, and an interface that shows the address without saying so is
// promising something that will not happen.
Configured bool `json:"configured"`
}
// handleGetMailAddress returns the personal address that turns an email into a
// task, minting the token on first ask.
//
// The token goes in the local part rather than the domain — todo+<token>@domain —
// because that is what a single Mailcow alias can route without a wildcard domain
// or a DNS change per user.
func (s *Server) handleGetMailAddress(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
token, err := s.db.EnsureMailToken(r.Context(), user.ID)
if err != nil {
s.internal(w, r, "mail token", err)
return
}
writeJSON(w, http.StatusOK, mailAddressResponse{
Address: s.mailAddress(token),
Configured: s.cfg.SMTP.Host != "",
})
}
func (s *Server) handleRotateMailAddress(w http.ResponseWriter, r *http.Request) {
token, err := auth.NewToken()
if err != nil {
s.internal(w, r, "generate mail token", err)
return
}
if err := s.db.SetMailToken(r.Context(), userFrom(r.Context()).ID, token); err != nil {
s.internal(w, r, "set mail token", err)
return
}
writeJSON(w, http.StatusOK, mailAddressResponse{
Address: s.mailAddress(token),
Configured: s.cfg.SMTP.Host != "",
})
}
func (s *Server) mailAddress(token string) string {
domain := feedDomain(s.cfg.BaseURL)
if from := s.cfg.SMTP.From; strings.Contains(from, "@") {
// The mail server's own domain, which is the one that can actually route
// this — the web address may well be a tunnel hostname that receives no mail.
//
// Except when it is the default. VERDANDE_SMTP_FROM falls back to
// verdande@localhost, so an instance with no mail server at all was
// handing out todo+…@localhost: an address that looks real, cannot
// possibly receive anything, and gives no hint which of the two it is.
// The public hostname is at least a domain that exists.
if d := from[strings.Index(from, "@")+1:]; d != "" && d != "localhost" {
domain = d
}
}
return "todo+" + token + "@" + domain
}
type inboundMailRequest struct {
// To is the address the mail was sent to, from which the token is read.
To string `json:"to"`
From string `json:"from"`
Subject string `json:"subject"`
Body string `json:"body"`
}
// handleInboundMail turns a delivered email into a task.
//
// Mailcow delivers to this endpoint over LMTP or a small forwarding script; either
// way what arrives here is already-parsed mail. Authentication is the token in the
// recipient address, which is the only credential an inbound mail can carry.
//
// The subject is parsed with the quick-add parser, so "Fakturer Anders p1 #Firma"
// in a subject line does what it does everywhere else in the app.
func (s *Server) handleInboundMail(w http.ResponseWriter, r *http.Request) {
var req inboundMailRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
token := mailToken(req.To)
if token == "" {
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
return
}
user, err := s.db.UserByMailToken(r.Context(), token)
if err != nil {
// Deliberately identical to a malformed address: an inbound endpoint that
// distinguishes them is an oracle for which tokens are live.
writeError(w, http.StatusNotFound, CodeNotFound, "no such address")
return
}
subject := strings.TrimSpace(req.Subject)
if subject == "" {
subject = "(uden emne)"
}
parsed := quickadd.Parse(subject, time.Now().In(userLocation(user.Timezone)), user.Locale)
content := parsed.Content
if strings.TrimSpace(content) == "" {
content = subject
}
projectID := ""
if parsed.Project != "" {
if id, err := s.db.ProjectByName(r.Context(), user.ID, parsed.Project); err == nil {
projectID = id
}
}
if projectID == "" {
if projectID, err = s.db.InboxID(r.Context(), user.ID); err != nil {
s.internal(w, r, "inbox", err)
return
}
}
task := &store.Task{
ProjectID: projectID, Content: content, Priority: parsed.Priority,
Description: strings.TrimSpace(req.Body), DueDate: parsed.DueDate,
RecurrenceRule: parsed.Recurrence, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), task, parsed.Labels); err != nil {
s.internal(w, r, "create task from mail", err)
return
}
s.publish(projectID, "task.created", toTaskJSON(*task))
s.log.Info("task created from mail", "user", user.ID, "from", req.From)
writeJSON(w, http.StatusCreated, map[string]string{"task_id": task.ID})
}
// mailToken reads the token out of "Name <todo+TOKEN@domain>".
func mailToken(address string) string {
if i := strings.LastIndex(address, "<"); i >= 0 {
address = address[i+1:]
}
address = strings.TrimSuffix(strings.TrimSpace(address), ">")
local, _, ok := strings.Cut(address, "@")
if !ok {
return ""
}
_, token, ok := strings.Cut(local, "+")
if !ok {
return ""
}
return token
}
// --- AI ---------------------------------------------------------------------------------
type aiSettings struct {
Provider string `json:"provider"`
BaseURL string `json:"base_url"`
Model string `json:"model"`
// APIKey is written but never read back — see handleGetAISettings.
APIKey string `json:"api_key,omitempty"`
// HasKey tells the settings page whether a key is stored, without showing it.
HasKey bool `json:"has_key"`
}
func (s *Server) handleGetAISettings(w http.ResponseWriter, r *http.Request) {
cfg, err := s.aiConfig(r, userFrom(r.Context()).ID)
if err != nil {
s.internal(w, r, "ai settings", err)
return
}
// The key is never sent back. A settings page that repopulates a password
// field is a settings page that will eventually leak one into a screenshot.
writeJSON(w, http.StatusOK, aiSettings{
Provider: string(cfg.Provider), BaseURL: cfg.BaseURL,
Model: cfg.Model, HasKey: cfg.APIKey != "",
})
}
func (s *Server) handleSetAISettings(w http.ResponseWriter, r *http.Request) {
var req aiSettings
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
switch ai.Provider(req.Provider) {
case ai.ProviderAnthropic, ai.ProviderOpenAI, ai.ProviderGoogle, ai.ProviderCompatible, "":
default:
writeFieldErrors(w, map[string]string{
"provider": "must be anthropic, openai, google or compatible",
})
return
}
// The address is checked where it is typed, as well as when it is dialled.
//
// safedial refuses a private destination at connect time and is the actual
// wall; this is so the person entering it hears about it now rather than
// discovering it later as a failed summary. A scheme other than http or https
// is refused outright — file:// and gopher:// are not providers.
if url := strings.TrimSpace(req.BaseURL); url != "" {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
writeFieldErrors(w, map[string]string{"base_url": "must start with http:// or https://"})
return
}
if reason := safedial.CheckURL(url); reason != "" {
writeFieldErrors(w, map[string]string{"base_url": reason})
return
}
req.BaseURL = url
}
existing, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, r, "ai settings", err)
return
}
// An empty key means "leave it alone", not "delete it" — otherwise saving a
// changed model would silently clear the key.
key := existing.APIKey
if req.APIKey != "" {
key = req.APIKey
}
settings := map[string]any{
"provider": req.Provider, "base_url": req.BaseURL,
"model": req.Model, "api_key": key,
}
if err := s.db.SetUserSettings(r.Context(), user.ID, "ai", settings); err != nil {
s.internal(w, r, "save ai settings", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) aiConfig(r *http.Request, userID string) (ai.Config, error) {
values, err := s.db.UserSettings(r.Context(), userID, "ai")
if err != nil {
return ai.Config{}, err
}
str := func(key string) string {
v, _ := values[key].(string)
return v
}
return ai.Config{
Provider: ai.Provider(str("provider")),
APIKey: str("api_key"),
BaseURL: str("base_url"),
Model: str("model"),
}, nil
}
// handleAISplit breaks a task into sub-tasks and creates them.
func (s *Server) handleAISplit(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
taskID := chi.URLParam(r, "taskID")
role, err := store.TaskRole(r.Context(), s.db, taskID, user.ID)
if err != nil || !role.CanEdit() {
writeError(w, http.StatusNotFound, CodeNotFound, "not found")
return
}
cfg, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, r, "ai settings", err)
return
}
if !cfg.Configured() {
// Not an error the user did anything to cause: the feature is simply off.
writeError(w, http.StatusConflict, CodeAINotConfigured,
"no AI provider is configured")
return
}
task, err := s.db.GetTask(r.Context(), taskID, user.ID)
if err != nil {
s.storeError(w, r, "get task", err)
return
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
subtasks, err := ai.New(cfg).SplitIntoSubtasks(ctx, task.Content, task.Description, user.Locale)
if err != nil {
s.log.Warn("ai split", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
created := make([]taskJSON, 0, len(subtasks))
for _, content := range subtasks {
child := &store.Task{
ProjectID: task.ProjectID, ParentID: task.ID, Content: content,
Priority: task.Priority, CreatedBy: user.ID,
}
if err := s.db.CreateTask(r.Context(), child, nil); err != nil {
s.internal(w, r, "create subtask", err)
return
}
created = append(created, toTaskJSON(*child))
}
s.activity(r, task.ProjectID, task.ID, "task.split", map[string]any{"count": len(created)})
writeJSON(w, http.StatusCreated, map[string]any{"subtasks": created})
}
// handleAISummary writes a short prioritisation note over what is outstanding.
func (s *Server) handleAISummary(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
cfg, err := s.aiConfig(r, user.ID)
if err != nil {
s.internal(w, r, "ai settings", err)
return
}
if !cfg.Configured() {
writeError(w, http.StatusConflict, CodeAINotConfigured,
"no AI provider is configured")
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{Limit: 100})
if err != nil {
s.internal(w, r, "list tasks", err)
return
}
if len(tasks) == 0 {
writeJSON(w, http.StatusOK, map[string]string{"summary": "Der er ingen åbne opgaver."})
return
}
lines := make([]string, 0, len(tasks))
for _, t := range tasks {
line := "- " + t.Content
if t.DueDate != "" {
line += " (forfalder " + t.DueDate + ")"
}
if t.Priority < 4 {
line += " [P" + string(rune('0'+t.Priority)) + "]"
}
lines = append(lines, line)
}
ctx, cancel := contextWithTimeout(r, 90*time.Second)
defer cancel()
summary, err := ai.New(cfg).WeeklySummary(ctx, lines, user.Locale)
if err != nil {
s.log.Warn("ai summary", "err", err)
writeError(w, StatusUpstreamRefused, CodeInternal, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
}
// --- Gmail ----------------------------------------------------------------------------
type gmailSettings struct {
Connected bool `json:"connected"`
Email string `json:"email,omitempty"`
// Trigger is what causes a task to be created: a starred message, a label, or
// both. One-way by design — unstarring does nothing to the task.
Trigger string `json:"trigger"`
Label string `json:"label,omitempty"`
}
func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail mailbox", err)
return
}
if box == nil {
writeJSON(w, http.StatusOK, gmailSettings{})
return
}
// The mailbox address, fetched again if it is missing.
//
// It is stored at connect time, and the call that fetches it can fail — a slow
// token, a scope Google had not applied yet. That failure used to be a log line
// and an empty string, so the page said "an unknown account" and went on saying
// it forever: the only way back was to disconnect and reconnect, which nobody
// would guess. Asking again here costs one request on a page nobody opens
// often, and it means the wrong answer lasts until the next look.
email := box.Username
if email == "" && box.RefreshToken != "" {
if fetched, err := s.gmailProfile(r.Context(), box); err == nil {
email = fetched
} else {
s.log.Warn("gmail profile", "err", err)
}
}
writeJSON(w, http.StatusOK, gmailSettings{
Connected: box.RefreshToken != "",
Email: email,
Trigger: box.Trigger,
Label: box.Label,
})
}
func (s *Server) handleSetGmail(w http.ResponseWriter, r *http.Request) {
var req gmailSettings
if err := decodeJSON(w, r, &req); err != nil {
return
}
user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail mailbox", err)
return
}
if box == nil {
// Choosing what makes a task before connecting is allowed, and the choice is
// kept: the row is the mailbox with no credential in it yet, which reads as
// not connected everywhere that asks and is skipped by the sweep.
box = &store.Mailbox{UserID: user.ID, Kind: "gmail"}
}
box.Trigger = req.Trigger
box.Label = req.Label
if err := s.db.SaveMailbox(r.Context(), box); err != nil {
s.internal(w, r, "save gmail settings", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDisconnectGmail forgets the connection, tokens and all. The tasks it made
// stay: they are the person's work now, not the mailbox's.
func (s *Server) handleDisconnectGmail(w http.ResponseWriter, r *http.Request) {
user := userFrom(r.Context())
box, err := s.db.MailboxOfKind(r.Context(), user.ID, "gmail")
if err != nil {
s.internal(w, r, "gmail mailbox", err)
return
}
if box != nil {
if err := s.db.DeleteMailbox(r.Context(), user.ID, box.ID); err != nil {
s.internal(w, r, "disconnect gmail", err)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
var _ = json.Marshal