Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ MCP_AUTH_TOKEN=
# Finalize / legally-binding write tools (issue invoices). IRREVERSIBLE. Default: false
# LEXWARE_ENABLE_FINALIZE=false

# upload-file-from-url: fetch a file from a share link server-side and store it in
# Lexware. OFF by default, and the only tool that makes this server originate an outbound
# request to an address the model chose. Requires the drafts tier (it will not turn it on).
# The connection is pinned to the address that passed the checks, so DNS cannot answer
# differently between the check and the connection. Default: false
# LEXWARE_ENABLE_URL_UPLOAD=false

# Hosts upload-file-from-url may fetch from, comma-separated. Unset = the Microsoft
# file-sharing defaults below. Setting it REPLACES those defaults (so they can be opted
# out of); an EMPTY value blocks every host, which is how you switch the fetcher off
# without unregistering it. Matched on a dot boundary: evilsharepoint.com never passes
# as sharepoint.com.
# LEXWARE_UPLOAD_ALLOWED_HOSTS=sharepoint.com,onedrive.live.com,1drv.ms,graph.microsoft.com

# ---- Server ----
# Listen port. Cloud Run injects this automatically. Default: 8080
# PORT=8080
Expand Down
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,65 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.12]

The server-side URL fetcher held back in 0.1.11 — now with its DNS-rebinding TOCTOU closed by
connection-level IP pinning. Based on the contribution by
[@gutencoder](https://github.com/gutencoder) ([#39]); a review pass on top tightened the filename
handling and a few edges (see **Fixed** below).

### Added
- **`upload-file-from-url`, with the DNS-rebinding TOCTOU closed** — the tool held back from
[#34] in 0.1.11, returning with the connection-level IP pinning that release asked for. It fetches a
file from a share link server-side and stores it in Lexware, so a receipt already sitting in
OneDrive/SharePoint reaches the books without its bytes passing through the model context.
**Off by default** (`LEXWARE_ENABLE_URL_UPLOAD=false`) and gated separately from the drafts tier it
writes through: it is the only tool that makes this server originate an outbound request to a
destination the model chose, and that is not something to acquire as a side effect of enabling
drafts. Setting it without the drafts tier warns rather than silently doing nothing.
- **`LEXWARE_UPLOAD_ALLOWED_HOSTS`** — the hosts that tool may fetch from, comma-separated, matched on
a dot boundary (`evilsharepoint.com` never passes as `sharepoint.com`). Unset means the built-in
Microsoft file-sharing list; a configured value **replaces** it rather than extending, so those
domains can be opted out of; an **empty** value blocks every host, which is how the fetcher is
switched off without unregistering it (and warns, so a typo is not mistaken for an open door).

### Security
- **The connection is pinned to the address that was checked.** The 0.1.11 note recorded why the
fetcher was withheld: its guards resolved the host, validated every returned address, and then let
`fetch` resolve the name a second time when it opened the socket — so the address that was
*approved* and the address that was *connected to* came from two different lookups, and a DNS
answer that changed in between (short TTL, or deliberate rebinding) walked past a check that
looked correct. `src/uploads/pinned-fetch.ts` removes the second lookup: the socket is given the
addresses the check just approved and never resolves anything. There is no longer a window between
the two, because there is no longer a second lookup to disagree with the first.
- Implemented on `node:https`'s `lookup` hook rather than an undici dispatcher, so **no runtime
dependency is added** to a server that fronts accounting data — and no second copy of undici
enters the process beside the one backing `fetch`.
- **TLS is untouched.** SNI and certificate validation still bind to the hostname; only the address
dialled comes from the pin. A test reads the SNI out of the raw ClientHello on the wire to hold
that property, rather than trusting the client to report on itself.
- Sockets are never pooled across requests (`keepAlive: false`, a fresh agent per request), so a
connection opened for a differently vetted request cannot be reused.
- The allow-list and per-hop address checks from #34 are unchanged and still apply first; pinning is a
third layer, not a replacement for either.

### Fixed
- **The stored filename now goes through the same sanitizer the ticket flow uses.** The model-supplied
`filename` override and the URL's own basename previously reached Lexware and the logs unsanitized —
only the `Content-Disposition` name was cleaned. A trailing-slash URL (`…/x/`) with no other name
produced an **empty** filename (`"" ?? "download.bin"` keeps the empty string); a `filename` of
`../../etc/passwd`, an embedded CRLF, or a 300-character string passed straight through. All three
candidates now run through `sanitizeFilename` — empty degrades to `download.bin`, and the URL
basename is percent-decoded first.
- **URLs carrying embedded credentials are refused** (`https://user:pass@host/…`), on the first URL and
every redirect hop. The `node:https` transport would otherwise turn userinfo into an
`Authorization: Basic` header on the wire; the `fetch` it replaced refused such URLs, and that
refusal is restored.
- **A leading dot on an allow-list entry (`.sharepoint.com`) no longer silently blocks everything** —
it is stripped, since subdomain matching is already on a dot boundary.

[#39]: https://github.com/marselsel/lexware-mcp/pull/39

## [0.1.11]

Upload a receipt without pushing its bytes through the model context. Based on the
Expand Down
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ Related projects — local (stdio) Lexware MCP servers:

## Capabilities

62 tools across three tiers you enable via environment variables:
62 tools across three tiers you enable via environment variables, plus one opt-in tool
outside them (`upload-file-from-url`, see below):

| Tier | Default | What it covers |
|------|---------|----------------|
Expand All @@ -42,6 +43,35 @@ Related projects — local (stdio) Lexware MCP servers:

Set `LEXWARE_READ_ONLY=true` to force read-only (overrides the flags above).

#### `upload-file-from-url` — outside the tiers, off by default

One tool sits outside this table: `upload-file-from-url` fetches a file from a share link
**server-side** and stores it in Lexware, which is how a receipt already sitting in
OneDrive/SharePoint gets into the books without a round trip through the model. It is
enabled with `LEXWARE_ENABLE_URL_UPLOAD=true` and needs the drafts tier (it will not turn
that tier on for you).

It has its own switch because it has its own risk: it is the only tool that makes this
server originate an outbound request to a destination the model chose — server-side
request forgery, in the general case. Three things bound it, applied at **every** redirect
hop:

1. **A host allow-list**, matched on a dot boundary so `evilsharepoint.com` cannot pass as
`sharepoint.com`. Configure with `LEXWARE_UPLOAD_ALLOWED_HOSTS`; unset means the
Microsoft file-sharing defaults, a set value replaces them, an empty value blocks
everything.
2. **A resolved-address check** rejecting loopback, private, link-local (including the
`169.254.169.254` metadata endpoint), CGNAT, multicast and reserved space, in every
IPv4, IPv6 and IPv4-in-IPv6 spelling.
3. **Connection pinning.** The socket connects to an address from the very lookup that
step 2 approved, rather than letting the HTTP client resolve the name again. Without
this, steps 1 and 2 describe one lookup and the connection uses another, and a DNS
answer that changes in between (rebinding) slips past a check that looked correct.
TLS is unaffected: the certificate is still validated against the hostname.

Only `https` is accepted, the download is capped at 20 MB and 30 s, and redirects are
followed manually — at most three — so no hop skips the checks.

### What that looks like in practice

> *"Summarize my open invoices for this quarter — who still owes what?"*
Expand Down Expand Up @@ -151,6 +181,8 @@ LEXWARE_API_KEY=... MCP_AUTH_TOKEN=... npm start
| `LEXWARE_READ_ONLY` | `false` | Register only read tools (hard override) |
| `LEXWARE_ENABLE_DRAFTS` | `true` | Enable create-draft tools |
| `LEXWARE_ENABLE_FINALIZE` | `false` | Enable finalize / legally-binding tools (also enables Drafts) |
| `LEXWARE_ENABLE_URL_UPLOAD` | `false` | Enable `upload-file-from-url` (server-side fetch). Requires the Drafts tier; does not enable it |
| `LEXWARE_UPLOAD_ALLOWED_HOSTS` | Microsoft file-sharing hosts | Hosts `upload-file-from-url` may fetch from, comma-separated. Replaces the defaults; empty blocks everything |
| `LEXWARE_API_BASE_URL` | `https://api.lexware.io` | API base URL |
| `LEXWARE_APP_BASE_URL` | `https://app.lexware.de` | Web-app base for document deeplinks |
| `PORT` | `8080` | Listen port (your platform may inject this) |
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "lexware-mcp",
"version": "0.1.11",
"version": "0.1.12",
"private": false,
"license": "MIT",
"description": "Open-source, self-hostable MCP server for the Lexware Office API",
Expand Down
67 changes: 66 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
* any Skybridge/Express imports so it can be unit-tested in isolation.
*/

// The allow-list default lives with the fetcher that enforces it, so the documented
// default and the applied default cannot drift apart.
import { DEFAULT_ALLOWED_HOSTS } from "./uploads/fetch-url.js";

/** Minimum length for `MCP_AUTH_TOKEN`. A 32-hex-char token is 32 chars. */
export const MIN_TOKEN_LENGTH = 16;

Expand All @@ -24,6 +28,14 @@ export interface Capabilities {
drafts: boolean;
/** Finalize / legally-binding write tools. */
finalize: boolean;
/**
* `upload-file-from-url`, the server-side URL fetcher. Off by default and gated
* separately from the rest of the drafts tier, because it is the one tool that makes
* this server originate outbound requests to a location the model chose — a class of
* risk (SSRF) the other write tools simply do not have. An operator who wants drafts
* should not silently get an outbound fetcher along with them.
*/
urlUpload: boolean;
}

/**
Expand Down Expand Up @@ -98,6 +110,13 @@ export interface Config {
* that resolves nowhere but inside the container.
*/
publicBaseUrl: string;
/**
* Hosts `upload-file-from-url` may fetch from (`LEXWARE_UPLOAD_ALLOWED_HOSTS`,
* comma-separated). Unset means the built-in Microsoft file-sharing list; see
* {@link resolveUploadAllowedHosts} for why setting it REPLACES rather than extends,
* and why an empty value blocks everything.
*/
uploadAllowedHosts: string[];
port: number;
debugLogging: boolean;
capabilities: Capabilities;
Expand Down Expand Up @@ -208,6 +227,32 @@ function resolvePublicBaseUrl(env: NodeJS.ProcessEnv, port: number): string {
return `http://127.0.0.1:${boundPort >= 1 && boundPort <= 65535 ? boundPort : port}`;
}

/**
* Hosts `upload-file-from-url` may fetch from.
*
* Three properties, each chosen deliberately:
*
* - **Unset means the built-in Microsoft file-sharing list**, so an operator who never
* touches the variable behaves exactly as if it did not exist.
* - **A configured list REPLACES the defaults, it does not extend them.** Extending
* would make Microsoft's domains impossible to opt out of, which is the wrong default
* for a self-hosted server that may have nothing to do with M365.
* - **An empty value blocks every host**, disabling the tool. An allow-list that cannot
* be emptied cannot be used to switch the feature off, and "empty means allow
* everything" would turn a typo into an open SSRF surface. Hence `??` and not `||`.
*/
function resolveUploadAllowedHosts(env: NodeJS.ProcessEnv): string[] {
const raw = env.LEXWARE_UPLOAD_ALLOWED_HOSTS;
if (raw === undefined) return DEFAULT_ALLOWED_HOSTS;
return raw
.split(",")
// Strip a leading dot (the cookie/Java `.sharepoint.com` convention): isAllowedHost
// already matches subdomains on a dot boundary, so a `.`-prefixed entry would match
// NOTHING and silently block the very host the operator meant to allow.
.map((h) => h.trim().toLowerCase().replace(/^\.+/, ""))
.filter(Boolean);
}

/** Resolve how `/mcp` is authenticated, failing closed if nothing is configured. */
function resolveAuth(env: NodeJS.ProcessEnv): AuthConfig {
const issuerRaw = env.OAUTH_ISSUER?.trim();
Expand Down Expand Up @@ -338,8 +383,26 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
// irreversible create-finalized-* tools (no safe draft path). Never allow that.
const draftsRequested = parseBool(env.LEXWARE_ENABLE_DRAFTS, true);
const enableDrafts = readOnly ? false : draftsRequested || enableFinalize;
// Opt-in, and only meaningful inside the drafts tier (it writes a file to Lexware).
// Unlike finalize→drafts, this one does NOT pull drafts up: an outbound fetcher is
// not something to enable as a side effect of a flag about uploads.
const urlUploadRequested = parseBool(env.LEXWARE_ENABLE_URL_UPLOAD, false);
const enableUrlUpload = enableDrafts && urlUploadRequested;
const uploadAllowedHosts = resolveUploadAllowedHosts(env);

const warnings: string[] = [];
if (urlUploadRequested && !enableDrafts) {
warnings.push(
"LEXWARE_ENABLE_URL_UPLOAD=true has no effect: upload-file-from-url writes a file to Lexware and " +
"lives in the drafts tier, which is disabled (LEXWARE_READ_ONLY / LEXWARE_ENABLE_DRAFTS).",
);
}
if (enableUrlUpload && uploadAllowedHosts.length === 0) {
warnings.push(
"LEXWARE_ENABLE_URL_UPLOAD=true but LEXWARE_UPLOAD_ALLOWED_HOSTS is empty — every host is blocked, " +
"so upload-file-from-url is registered but will refuse every URL.",
);
}
if (!readOnly && !draftsRequested && enableFinalize) {
warnings.push(
"LEXWARE_ENABLE_DRAFTS=false was overridden to true because LEXWARE_ENABLE_FINALIZE=true — the " +
Expand Down Expand Up @@ -370,9 +433,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
),
auth,
publicBaseUrl: resolvePublicBaseUrl(env, port),
uploadAllowedHosts,
port,
debugLogging: parseBool(env.LEXWARE_DEBUG_LOGGING, false),
capabilities: { read: true, drafts: enableDrafts, finalize: enableFinalize },
capabilities: { read: true, drafts: enableDrafts, finalize: enableFinalize, urlUpload: enableUrlUpload },
warnings,
};
}
Expand All @@ -382,6 +446,7 @@ export function describeCapabilities(config: Config): string {
const tiers = ["read"];
if (config.capabilities.drafts) tiers.push("drafts");
if (config.capabilities.finalize) tiers.push("finalize");
if (config.capabilities.urlUpload) tiers.push("url-upload");
const auth =
config.auth.mode === "oauth"
? "oauth"
Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const client = new LexwareClient({
const server = new McpServer(
{
name: "lexware-office",
version: "0.1.11",
version: "0.1.12",
},
{ capabilities: {} },
);
Expand Down
8 changes: 8 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { registerFileReadTools, registerFileWriteTools } from "./files.js";
import { registerProfileTools } from "./profile.js";
import { registerReferenceReadTools } from "./reference.js";
import { registerUploadTools } from "./uploads.js";
import { registerUrlUploadTool } from "./url-upload.js";
import { registerVoucherWriteTools } from "./vouchers.js";

/**
Expand Down Expand Up @@ -55,6 +56,13 @@ export function registerTools(
registerUploadTools(server, uploadTickets, config.publicBaseUrl);
}

// Gated separately from the drafts tier, not nested inside it: config.capabilities
// already resolves urlUpload to false whenever drafts are off, so the flat check
// states the actual precondition instead of restating it in two places.
if (capabilities.urlUpload) {
registerUrlUploadTool(server, client, config.uploadAllowedHosts);
}

// Finalize / sensitive & irreversible tier (off by default).
if (capabilities.finalize) {
registerDocumentFinalizeTools(server, client);
Expand Down
Loading