Skip to content

Add Nextcloud extension#207

Open
nachtschatt3n wants to merge 1 commit intovicinaehq:mainfrom
nachtschatt3n:add-nextcloud-extension
Open

Add Nextcloud extension#207
nachtschatt3n wants to merge 1 commit intovicinaehq:mainfrom
nachtschatt3n:add-nextcloud-extension

Conversation

@nachtschatt3n
Copy link
Copy Markdown

Nextcloud

Search files, contacts, calendar events, and mail in your Nextcloud instance directly from Vicinae.

Features

  • Files — search files and folders, open in browser or copy path
  • Contacts — lazy-loads full vCard details (email, phone, org, address, birthday, website)
  • Calendar — lazy-loads event details from CalDAV (when, location, organizer, status, description); opens the correct occurrence in Nextcloud Calendar
  • Mail — lazy-loads full message body via the Nextcloud Mail API (from, to, cc, date, attachments, body)

Setup

Requires a Nextcloud app token (Settings → Security → App passwords). Works with any Nextcloud instance that has the Contacts, Calendar, and/or Mail apps installed — results only appear for apps that are enabled.

Test plan

  • Configure extension with a Nextcloud instance URL, username, and app token
  • Search returns results across all four providers (files, contacts, events, mail)
  • Selecting a contact lazy-loads vCard details
  • Selecting a calendar event lazy-loads ICS details and opens correct occurrence
  • Selecting an email lazy-loads full message body
  • Missing apps (e.g. no Mail installed) degrade gracefully with no section shown

🤖 Generated with Claude Code

Search files, contacts, calendar events, and mail in a Nextcloud
instance. Details are lazy-loaded on selection (vCards for contacts,
ICS for calendar events, full message body for mail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@SiriusCrain
Copy link
Copy Markdown

  1. If server name is entered without http/s it should append them, in other case it will fail to parse
  2. It shows mail http 500 (can be related to Hetzner logic of handling email server)
image

@SiriusCrain
Copy link
Copy Markdown

SiriusCrain commented Mar 23, 2026

Also security review from Claude (since code was generated with the same tool)

Security Review — search-nextcloud.tsx


🔴 HIGH — Credential exfiltration via server-controlled URLs

All three lazy-fetch functions send the user's Authorization: Basic credentials to URLs constructed from data returned by the Nextcloud API, with no validation that those URLs match the configured instance:

ts
// vCardUrlFrom — only checks path contains "/dav/addressbooks/", easily spoofed:
// e.g. https://evil.com/dav/addressbooks/anything → credentials sent to evil.com
const base = thumbnailUrl.split("?")[0];
if (!base.includes("/dav/addressbooks/")) return null;
return base; // ← fetched with Authorization header

// calDavUrlFrom — origin extracted from resourceUrl, then base64-decoded path appended
// malicious server can return resourceUrl with a different origin entirely
const origin = resourceUrl.match(/^(https?:\/\/[^/]+)/)?.[1];
return `${origin}${encodedPath}`; // ← fetched with Authorization header

// mailApiUrlFrom — same issue
const origin = resourceUrl.match(/^(https?:\/\/[^/]+)/)?.[1];
return `${origin}/apps/mail/api/messages/${m[1]}/body`; // ← fetched with Authorization header

A malicious or compromised Nextcloud server can return resourceUrl/thumbnailUrl values pointing to any host, and the extension will happily forward the user's credentials there.

Fix — validate the constructed URL's origin against the configured preference URL before fetching:

ts
function isSameOrigin(fetchUrl: string, configuredUrl: string): boolean {
  try {
    return new URL(fetchUrl).origin === new URL(configuredUrl).origin;
  } catch {
    return false;
  }
}

// Before any lazy fetch:
if (!isSameOrigin(vcardUrl, url)) return;
```

---

### 🔴 HIGH — SSRF via `thumbnailUrl` / `resourceUrl`

Same root cause as above but from the SSRF angle. A Nextcloud server (or a MitM) could return URLs pointing to internal services:
```
thumbnailUrl: "http://localhost:8080/dav/addressbooks/steal"
resourceUrl:  "http://192.168.1.1/apps/mail/box/1/thread/1"

The extension would probe internal network services with no restriction on destination host or scheme.


🟡 MEDIUM — Dead origin variable (silent logic error)

In handleSelectionChange, the mail block computes origin but never uses it — it was presumably meant to validate the apiUrl but was forgotten:

ts
const origin = mail.resourceUrl.match(/^(https?:\/\/[^/]+)/)?.[1] ?? "";
const headers = { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true" };
fetch(apiUrl, { headers })  // ← origin never checked

This is likely a leftover from a refactor. It should either be removed or wired into the origin validation fix above.


🟡 MEDIUM — Incomplete HTML sanitization in mail bodies

The HTML stripping is regex-based and can be bypassed by malformed tags. These specifically survive:

ts
// </script > (trailing space) bypasses:
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")  // ← <\/script> doesn't match </script >

// Nested/malformed tags like <<script>> or <scr\nipt> may survive:
.replace(/<[^>]+>/g, "")  // ← requires > to close, malformed tags can escape

Since Vicinae renders the result as markdown (not in a browser), classic XSS is not possible. But injected markdown (rogue headers # Pwned, links [click](http://evil.com), or images ![](http://tracker.com/pixel)) will render as-is. Use a proper HTML-to-text library like html-to-text or @html-to-text/html-to-text instead of regex stripping.


🟡 MEDIUM — Crash on malformed API response

ts
return data.ocs.data.entries ?? []
//              ^^^^ throws if data.ocs is undefined or null

The ?? only guards the final .entries access. If the Nextcloud server returns an unexpected response shape, this throws a TypeError mid-search. It's caught by the outer try/catch and shown as a toast, but it should be handled more defensively:

ts
return data?.ocs?.data?.entries ?? [];

🟡 MEDIUM — No fetch timeout

All four fetch call sites have no timeout. A slow, hung, or intentionally delayed server will leave the extension in an infinite loading state with no recovery path:

ts
// Apply to all fetches:
fetch(url, {
  headers: { ... },
  signal: AbortSignal.timeout(10_000), // 10s timeout
})

🔵 LOW — Missing URL normalization (already reported)

The configured url preference is used raw — no https:// enforcement and no trailing slash stripping. Covered in the previous message.


🔵 LOW — getPreferenceValues() called twice per interaction

In handleSelectionChange, credentials are re-read on every selection change event (once for each of contacts/events/mails), whereas the search useEffect reads them once. Minor inefficiency, not a security issue, but worth consolidating.

Severity Issue
🔴 HIGH Credentials forwarded to server-controlled URLs (vCard, CalDAV, Mail API)
🔴 HIGH SSRF — no host/scheme restriction on lazy-fetch targets
🟡 MEDIUM Dead origin variable — origin check was coded but not wired up
🟡 MEDIUM Regex HTML stripping bypassable → markdown injection in mail body
🟡 MEDIUM Crash on malformed API response (data.ocs.data.entries)
🟡 MEDIUM No fetch timeout on any request
🔵 LOW Missing URL protocol normalization
🔵 LOW Redundant getPreferenceValues() calls

The two HIGH issues share the same root fix: validate that all lazily-fetched URLs share the same origin as the user-configured url preference before sending the request.

@nachtschatt3n
Copy link
Copy Markdown
Author

  1. If server name is entered without http/s it should append them, in other case it will fail to parse
    I disagree that is unexpected, you should enter http or https whatever you use.
  1. It shows mail http 500 (can be related to Hetzner logic of handling email server)
    The extension uses the nextcloud mail api that should not be tied to Hetzner. Do you have more info?

@nachtschatt3n
Copy link
Copy Markdown
Author

Also security review from Claude (since code was generated with the same tool)

Security Review — search-nextcloud.tsx

🔴 HIGH — Credential exfiltration via server-controlled URLs

All three lazy-fetch functions send the user's Authorization: Basic credentials to URLs constructed from data returned by the Nextcloud API, with no validation that those URLs match the configured instance:

ts

// vCardUrlFrom — only checks path contains "/dav/addressbooks/", easily spoofed:
// e.g. https://evil.com/dav/addressbooks/anything → credentials sent to evil.com
const base = thumbnailUrl.split("?")[0];
if (!base.includes("/dav/addressbooks/")) return null;
return base; // ← fetched with Authorization header

// calDavUrlFrom — origin extracted from resourceUrl, then base64-decoded path appended
// malicious server can return resourceUrl with a different origin entirely
const origin = resourceUrl.match(/^(https?:\/\/[^/]+)/)?.[1];
return `${origin}${encodedPath}`; // ← fetched with Authorization header

// mailApiUrlFrom — same issue
const origin = resourceUrl.match(/^(https?:\/\/[^/]+)/)?.[1];
return `${origin}/apps/mail/api/messages/${m[1]}/body`; // ← fetched with Authorization header

A malicious or compromised Nextcloud server can return resourceUrl/thumbnailUrl values pointing to any host, and the extension will happily forward the user's credentials there.

Fix — validate the constructed URL's origin against the configured preference URL before fetching:

ts

function isSameOrigin(fetchUrl: string, configuredUrl: string): boolean {
  try {
    return new URL(fetchUrl).origin === new URL(configuredUrl).origin;
  } catch {
    return false;
  }
}

// Before any lazy fetch:
if (!isSameOrigin(vcardUrl, url)) return;

🔴 HIGH — SSRF via thumbnailUrl / resourceUrl

Same root cause as above but from the SSRF angle. A Nextcloud server (or a MitM) could return URLs pointing to internal services:

thumbnailUrl: "http://localhost:8080/dav/addressbooks/steal"
resourceUrl:  "http://192.168.1.1/apps/mail/box/1/thread/1"

The extension would probe internal network services with no restriction on destination host or scheme.

🟡 MEDIUM — Dead origin variable (silent logic error)

In handleSelectionChange, the mail block computes origin but never uses it — it was presumably meant to validate the apiUrl but was forgotten:

ts

const origin = mail.resourceUrl.match(/^(https?:\/\/[^/]+)/)?.[1] ?? "";
const headers = { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true" };
fetch(apiUrl, { headers })  // ← origin never checked

This is likely a leftover from a refactor. It should either be removed or wired into the origin validation fix above.

🟡 MEDIUM — Incomplete HTML sanitization in mail bodies

The HTML stripping is regex-based and can be bypassed by malformed tags. These specifically survive:

ts

// </script > (trailing space) bypasses:
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")  // ← <\/script> doesn't match </script >

// Nested/malformed tags like <<script>> or <scr\nipt> may survive:
.replace(/<[^>]+>/g, "")  // ← requires > to close, malformed tags can escape

Since Vicinae renders the result as markdown (not in a browser), classic XSS is not possible. But injected markdown (rogue headers # Pwned, links [click](http://evil.com), or images ![](http://tracker.com/pixel)) will render as-is. Use a proper HTML-to-text library like html-to-text or @html-to-text/html-to-text instead of regex stripping.

🟡 MEDIUM — Crash on malformed API response

ts

return data.ocs.data.entries ?? []
//              ^^^^ throws if data.ocs is undefined or null

The ?? only guards the final .entries access. If the Nextcloud server returns an unexpected response shape, this throws a TypeError mid-search. It's caught by the outer try/catch and shown as a toast, but it should be handled more defensively:

ts

return data?.ocs?.data?.entries ?? [];

🟡 MEDIUM — No fetch timeout

All four fetch call sites have no timeout. A slow, hung, or intentionally delayed server will leave the extension in an infinite loading state with no recovery path:

ts

// Apply to all fetches:
fetch(url, {
  headers: { ... },
  signal: AbortSignal.timeout(10_000), // 10s timeout
})

🔵 LOW — Missing URL normalization (already reported)

The configured url preference is used raw — no https:// enforcement and no trailing slash stripping. Covered in the previous message.

🔵 LOW — getPreferenceValues() called twice per interaction

In handleSelectionChange, credentials are re-read on every selection change event (once for each of contacts/events/mails), whereas the search useEffect reads them once. Minor inefficiency, not a security issue, but worth consolidating.

Severity Issue
🔴 HIGH Credentials forwarded to server-controlled URLs (vCard, CalDAV, Mail API)
🔴 HIGH SSRF — no host/scheme restriction on lazy-fetch targets
🟡 MEDIUM Dead origin variable — origin check was coded but not wired up
🟡 MEDIUM Regex HTML stripping bypassable → markdown injection in mail body
🟡 MEDIUM Crash on malformed API response (data.ocs.data.entries)
🟡 MEDIUM No fetch timeout on any request
🔵 LOW Missing URL protocol normalization
🔵 LOW Redundant getPreferenceValues() calls
The two HIGH issues share the same root fix: validate that all lazily-fetched URLs share the same origin as the user-configured url preference before sending the request.

I will have a look.

  1. Credentials forwarded to server-controlled URLs (vCard, CalDAV, Mail API). The thread model is that if someone took over the nextcloud server it can redirect to a different one that is stealing the auth? Why if I have the server allready then I have the Auth as well ?

  2. Thats the same as above I dont see the value in the restriction, just more complexity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants