openwriter 0.37.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,13 +4,45 @@
4
4
  */
5
5
  import { readConfig, getActiveProfile } from './helpers.js';
6
6
  const DEFAULT_API_URL = 'https://publish.openwriter.io';
7
+ // MCP-6: the platform Bearer key is attached to every request sent to
8
+ // `api-url`. Because plugin config is writable (and was writable unauth before
9
+ // the MCP-5 gate), a hijacked `api-url` could redirect the next authenticated
10
+ // call to an attacker host and exfiltrate the key. We PIN the destination: the
11
+ // Bearer key only ever ships to an OpenWriter-controlled host (or loopback for
12
+ // local platform dev). Anything else falls back to the default host.
13
+ const ALLOWED_PUBLISH_HOSTS = new Set(['publish.openwriter.io', 'openwriter.io']);
14
+ /** True when `url` is a safe destination for the platform Bearer key. */
15
+ export function isAllowedPublishApiUrl(url) {
16
+ let u;
17
+ try {
18
+ u = new URL(url);
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ const host = u.hostname.toLowerCase();
24
+ // Local platform development over loopback (either scheme).
25
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1')
26
+ return true;
27
+ // Production: HTTPS only, OpenWriter-controlled host (incl. subdomains).
28
+ if (u.protocol !== 'https:')
29
+ return false;
30
+ return ALLOWED_PUBLISH_HOSTS.has(host) || host.endsWith('.openwriter.io');
31
+ }
7
32
  /** Get API key and URL from plugin config */
8
33
  function getPublishConfig() {
9
34
  const config = readConfig();
10
35
  const publishConfig = config.plugins?.['@openwriter/plugin-publish']?.config || {};
36
+ const rawUrl = publishConfig['api-url'] || DEFAULT_API_URL;
37
+ // Pin to an allowed destination — never let a redirected api-url carry the
38
+ // Bearer key off-host. MCP-6.
39
+ const apiUrl = isAllowedPublishApiUrl(rawUrl) ? rawUrl : DEFAULT_API_URL;
40
+ if (apiUrl !== rawUrl) {
41
+ console.warn('[connections] publish api-url not on allowlist — pinned to default host');
42
+ }
11
43
  return {
12
44
  apiKey: publishConfig['api-key'] || '',
13
- apiUrl: publishConfig['api-url'] || DEFAULT_API_URL,
45
+ apiUrl,
14
46
  };
15
47
  }
16
48
  /** Authenticated fetch to the platform API */
@@ -7,9 +7,151 @@ import multer from 'multer';
7
7
  import { existsSync, mkdirSync } from 'fs';
8
8
  import { join, extname } from 'path';
9
9
  import { randomUUID } from 'crypto';
10
+ import { isIP } from 'net';
11
+ import { lookup } from 'dns/promises';
10
12
  import { getDataDir, ensureDataDir } from './helpers.js';
11
13
  import express from 'express';
12
14
  function getImagesDir() { return join(getDataDir(), '_images'); }
15
+ // ── SSRF guard for /api/download-image (MCP-8) ──────────────────────────────
16
+ // The endpoint server-side-fetches a caller-supplied URL. Without a guard an
17
+ // attacker can point it at internal services, the cloud metadata endpoint
18
+ // (169.254.169.254), or loopback — a classic SSRF. We allow only https, block
19
+ // every private / loopback / link-local / reserved IP range (resolving DNS
20
+ // first), follow redirects manually with re-validation at each hop, and cap
21
+ // both response size and request time.
22
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB, matches the upload limit
23
+ const FETCH_TIMEOUT_MS = 15_000;
24
+ const MAX_REDIRECTS = 3;
25
+ function isBlockedIpv4(ip) {
26
+ const parts = ip.split('.').map(Number);
27
+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255))
28
+ return true;
29
+ const [a, b] = parts;
30
+ if (a === 0)
31
+ return true; // 0.0.0.0/8 "this host"
32
+ if (a === 10)
33
+ return true; // 10.0.0.0/8 private
34
+ if (a === 127)
35
+ return true; // 127.0.0.0/8 loopback
36
+ if (a === 169 && b === 254)
37
+ return true; // 169.254.0.0/16 link-local (incl. metadata 169.254.169.254)
38
+ if (a === 172 && b >= 16 && b <= 31)
39
+ return true; // 172.16.0.0/12 private
40
+ if (a === 192 && b === 168)
41
+ return true; // 192.168.0.0/16 private
42
+ if (a === 100 && b >= 64 && b <= 127)
43
+ return true; // 100.64.0.0/10 CGNAT
44
+ if (a >= 224)
45
+ return true; // 224.0.0.0/4 multicast + 240/4 reserved + 255 broadcast
46
+ return false;
47
+ }
48
+ function isBlockedIpv6(ip) {
49
+ const s = ip.toLowerCase().replace(/^\[|\]$/g, '');
50
+ // IPv4-mapped (::ffff:a.b.c.d) — classify on the embedded v4 address.
51
+ const mapped = s.match(/(?:^|:)((?:\d{1,3}\.){3}\d{1,3})$/);
52
+ if (mapped)
53
+ return isBlockedIpv4(mapped[1]);
54
+ if (s === '::1' || s === '::')
55
+ return true; // loopback / unspecified
56
+ if (s.startsWith('fe8') || s.startsWith('fe9') || s.startsWith('fea') || s.startsWith('feb'))
57
+ return true; // fe80::/10 link-local
58
+ if (s.startsWith('fc') || s.startsWith('fd'))
59
+ return true; // fc00::/7 unique-local
60
+ if (s.startsWith('ff'))
61
+ return true; // ff00::/8 multicast
62
+ return false;
63
+ }
64
+ function isBlockedAddress(ip) {
65
+ const fam = isIP(ip);
66
+ if (fam === 4)
67
+ return isBlockedIpv4(ip);
68
+ if (fam === 6)
69
+ return isBlockedIpv6(ip);
70
+ return true; // not a recognizable IP → refuse
71
+ }
72
+ /** Throw unless `rawUrl` is an https URL whose host resolves only to public addresses. */
73
+ async function assertSafeImageUrl(rawUrl) {
74
+ let parsed;
75
+ try {
76
+ parsed = new URL(rawUrl);
77
+ }
78
+ catch {
79
+ throw new Error('blocked');
80
+ }
81
+ if (parsed.protocol !== 'https:')
82
+ throw new Error('blocked');
83
+ const host = parsed.hostname.replace(/^\[|\]$/g, '');
84
+ if (isIP(host)) {
85
+ if (isBlockedAddress(host))
86
+ throw new Error('blocked');
87
+ return;
88
+ }
89
+ let addrs;
90
+ try {
91
+ addrs = await lookup(host, { all: true });
92
+ }
93
+ catch {
94
+ throw new Error('blocked');
95
+ }
96
+ if (addrs.length === 0)
97
+ throw new Error('blocked');
98
+ for (const a of addrs) {
99
+ if (isBlockedAddress(a.address))
100
+ throw new Error('blocked');
101
+ }
102
+ }
103
+ /** Fetch an image with SSRF guards: https-only, public-IP-only, manual
104
+ * redirect re-validation, size cap, and timeout. Returns the response for a
105
+ * caller to read (the caller still enforces the byte cap while streaming). */
106
+ async function safeImageFetch(initialUrl) {
107
+ let current = initialUrl;
108
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
109
+ await assertSafeImageUrl(current);
110
+ const controller = new AbortController();
111
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
112
+ let response;
113
+ try {
114
+ response = await fetch(current, { redirect: 'manual', signal: controller.signal });
115
+ }
116
+ finally {
117
+ clearTimeout(timer);
118
+ }
119
+ if (response.status >= 300 && response.status < 400) {
120
+ const location = response.headers.get('location');
121
+ if (!location)
122
+ return response;
123
+ current = new URL(location, current).toString(); // re-validated at loop top
124
+ continue;
125
+ }
126
+ return response;
127
+ }
128
+ throw new Error('blocked');
129
+ }
130
+ /** Read a response body into a Buffer, aborting (returns null) once `max` bytes
131
+ * are exceeded so a server that lies about content-length can't blow past the cap. */
132
+ async function readCapped(response, max) {
133
+ if (!response.body) {
134
+ const buf = Buffer.from(await response.arrayBuffer());
135
+ return buf.length > max ? null : buf;
136
+ }
137
+ const reader = response.body.getReader();
138
+ const chunks = [];
139
+ let total = 0;
140
+ while (true) {
141
+ const { done, value } = await reader.read();
142
+ if (done)
143
+ break;
144
+ if (value) {
145
+ total += value.length;
146
+ if (total > max) {
147
+ await reader.cancel().catch(() => { });
148
+ return null;
149
+ }
150
+ chunks.push(value);
151
+ }
152
+ }
153
+ return Buffer.concat(chunks);
154
+ }
13
155
  function ensureImagesDir() {
14
156
  ensureDataDir();
15
157
  const dir = getImagesDir();
@@ -61,8 +203,17 @@ export function createImageRouter() {
61
203
  res.status(400).json({ error: 'No URL provided' });
62
204
  return;
63
205
  }
206
+ let response;
207
+ try {
208
+ response = await safeImageFetch(url);
209
+ }
210
+ catch {
211
+ // SSRF guard rejected the URL (bad scheme, private/blocked host, too many
212
+ // redirects). Generic message — never echo the resolved host or reason.
213
+ res.status(400).json({ error: 'URL not allowed' });
214
+ return;
215
+ }
64
216
  try {
65
- const response = await fetch(url);
66
217
  if (!response.ok) {
67
218
  res.status(400).json({ error: 'Failed to fetch image' });
68
219
  return;
@@ -72,14 +223,25 @@ export function createImageRouter() {
72
223
  res.status(400).json({ error: 'URL is not an image' });
73
224
  return;
74
225
  }
226
+ // Reject early if the server declares an over-limit size.
227
+ const declaredLen = Number(response.headers.get('content-length'));
228
+ if (Number.isFinite(declaredLen) && declaredLen > MAX_IMAGE_BYTES) {
229
+ res.status(400).json({ error: 'Image too large' });
230
+ return;
231
+ }
75
232
  const ext = contentType.includes('jpeg') || contentType.includes('jpg') ? '.jpg'
76
233
  : contentType.includes('gif') ? '.gif'
77
234
  : contentType.includes('webp') ? '.webp'
78
235
  : '.png';
236
+ // Stream with a hard byte cap so a lying/streaming server can't exhaust disk.
237
+ const buffer = await readCapped(response, MAX_IMAGE_BYTES);
238
+ if (!buffer) {
239
+ res.status(400).json({ error: 'Image too large' });
240
+ return;
241
+ }
79
242
  ensureImagesDir();
80
243
  const filename = `${randomUUID().slice(0, 8)}${ext}`;
81
244
  const filePath = join(getImagesDir(), filename);
82
- const buffer = Buffer.from(await response.arrayBuffer());
83
245
  const { writeFileSync } = await import('fs');
84
246
  writeFileSync(filePath, buffer);
85
247
  const src = `/_images/${filename}`;
@@ -45,6 +45,109 @@ const __dirname = dirname(__filename);
45
45
  let runtimePort = 5050;
46
46
  export function getRuntimePort() { return runtimePort; }
47
47
  export function getBaseUrl() { return `http://localhost:${runtimePort}`; }
48
+ // ---- Trust boundary (anti-DNS-rebinding + CSRF) — MCP-5 ----
49
+ // The HTTP API binds to loopback and historically trusted "localhost = the
50
+ // user." That is false: with no Host/Origin validation a remote website can
51
+ // reach this API via DNS rebinding (its page rebinds a hostname to 127.0.0.1,
52
+ // the browser keeps sending the attacker's Host/Origin) and drive every
53
+ // mutating route — switch profiles, enable plugins, rewrite plugin config.
54
+ // The middleware below is the single gate for ALL routes. adr: see MCP-5.
55
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
56
+ /** Split a Host header into hostname + optional port, handling [::1]:port. */
57
+ function splitHostHeader(hostHeader) {
58
+ if (hostHeader.startsWith('[')) {
59
+ const close = hostHeader.indexOf(']');
60
+ if (close === -1)
61
+ return { host: hostHeader };
62
+ const host = hostHeader.slice(1, close);
63
+ const rest = hostHeader.slice(close + 1);
64
+ return { host, port: rest.startsWith(':') ? rest.slice(1) : undefined };
65
+ }
66
+ const colon = hostHeader.lastIndexOf(':');
67
+ if (colon === -1)
68
+ return { host: hostHeader };
69
+ return { host: hostHeader.slice(0, colon), port: hostHeader.slice(colon + 1) };
70
+ }
71
+ /** True when the Host header names loopback on the port we are serving. */
72
+ function isAllowedHost(hostHeader, port) {
73
+ if (!hostHeader)
74
+ return false;
75
+ const { host, port: p } = splitHostHeader(hostHeader.trim());
76
+ if (!LOOPBACK_HOSTS.has(host.toLowerCase()))
77
+ return false;
78
+ if (p !== undefined && p !== String(port))
79
+ return false;
80
+ return true;
81
+ }
82
+ /** True when an Origin/Referer URL is same-origin (loopback on our port). */
83
+ function isAllowedOrigin(value, port) {
84
+ if (!value)
85
+ return false;
86
+ try {
87
+ const u = new URL(value);
88
+ if (!LOOPBACK_HOSTS.has(u.hostname.toLowerCase()))
89
+ return false;
90
+ if (u.port && u.port !== String(port))
91
+ return false;
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ const STATE_CHANGING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
99
+ /**
100
+ * Global security gate, applied before any route or body parsing.
101
+ * (a) Host allowlist — the anti-DNS-rebinding control.
102
+ * (b) Origin/Referer same-origin check on state-changing methods (CSRF).
103
+ * Browsers always send Origin on cross-origin POST, so a rebound page is
104
+ * rejected here too. A *missing* Origin is allowed only for state-changing
105
+ * requests from non-browser local clients (the client-mode MCP-over-HTTP
106
+ * proxy uses curl-style requests with no Origin); the Host gate still
107
+ * bounds those to loopback.
108
+ * (c) Restrictive security headers on every response.
109
+ */
110
+ function securityGate(port) {
111
+ return (req, res, next) => {
112
+ res.setHeader('X-Content-Type-Options', 'nosniff');
113
+ res.setHeader('X-Frame-Options', 'DENY');
114
+ res.setHeader('Referrer-Policy', 'no-referrer');
115
+ res.setHeader('Content-Security-Policy', [
116
+ "default-src 'self'",
117
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
118
+ "style-src 'self' 'unsafe-inline'",
119
+ "img-src 'self' data: blob: https:",
120
+ "font-src 'self' data:",
121
+ "connect-src 'self' ws: wss:",
122
+ "frame-ancestors 'none'",
123
+ "base-uri 'self'",
124
+ "object-src 'none'",
125
+ ].join('; '));
126
+ if (!isAllowedHost(req.headers.host, port)) {
127
+ res.status(403).json({ error: 'Forbidden' });
128
+ return;
129
+ }
130
+ if (STATE_CHANGING.has(req.method)) {
131
+ const origin = req.headers.origin;
132
+ const referer = req.headers.referer;
133
+ if (origin) {
134
+ if (!isAllowedOrigin(origin, port)) {
135
+ res.status(403).json({ error: 'Forbidden' });
136
+ return;
137
+ }
138
+ }
139
+ else if (referer) {
140
+ if (!isAllowedOrigin(referer, port)) {
141
+ res.status(403).json({ error: 'Forbidden' });
142
+ return;
143
+ }
144
+ }
145
+ // No Origin and no Referer: non-browser local client; Host gate above
146
+ // already constrained it to loopback. Allow.
147
+ }
148
+ next();
149
+ };
150
+ }
48
151
  export async function startHttpServer(options = {}) {
49
152
  const port = options.port || 5050;
50
153
  runtimePort = port;
@@ -55,6 +158,11 @@ export async function startHttpServer(options = {}) {
55
158
  initLogger();
56
159
  logger.info('state', 'server-boot', `OpenWriter starting on port ${port}`, { port });
57
160
  const app = express();
161
+ // Trust boundary FIRST — reject cross-host / cross-origin before body
162
+ // parsing or any route handler runs. Covers every route, including the
163
+ // unauth state-changers /api/profiles/switch, /api/plugins/enable,
164
+ // /api/plugins/config, and the universal /api/mcp-call dispatcher. MCP-5.
165
+ app.use(securityGate(port));
58
166
  app.use(express.json({ limit: '10mb' }));
59
167
  // API routes for direct HTTP access (fallback if WS not available)
60
168
  app.get('/api/status', (_req, res) => {
@@ -69,7 +177,13 @@ export async function startHttpServer(options = {}) {
69
177
  }));
70
178
  res.json({ tools });
71
179
  });
72
- // MCP-over-HTTP: allows client-mode terminals to proxy tool calls
180
+ // MCP-over-HTTP: allows client-mode terminals to proxy tool calls.
181
+ // MCP-4: this dispatches ANY MCP tool with the user's platform credentials,
182
+ // so it MUST never be reachable cross-origin. That guarantee is provided by
183
+ // the global securityGate above (Host allowlist + Origin/Referer check on
184
+ // POST) — a DNS-rebound page cannot satisfy either. No tool is intentionally
185
+ // exposed beyond same-origin/local callers here; any future tool that must
186
+ // never be driven over HTTP should be denylisted at this entry point.
73
187
  app.post('/api/mcp-call', async (req, res) => {
74
188
  const { tool: toolName, arguments: args } = req.body;
75
189
  // Wrap the call in a request ID scope so every event logged during
@@ -201,6 +201,13 @@ const READ_PAD_MAX_WORDS = 2000;
201
201
  * learns the new behavior without repeating the explanation. Resets on
202
202
  * server restart. */
203
203
  let firstTruncationShown = false;
204
+ /** MCP-9: metadata keys an agent must NEVER set via set_metadata. `autoAccept`
205
+ * governs the human accept/reject gate — letting the agent write it via
206
+ * open-ended frontmatter would self-grant auto-accept and bypass human review.
207
+ * These are operator-only (set through the UI toggle path). The metadata
208
+ * surface is otherwise intentionally open-ended, so this is a denylist of the
209
+ * finite, enumerable privileged keys rather than an allowlist of content keys. */
210
+ const AGENT_FORBIDDEN_METADATA_KEYS = new Set(['autoAccept']);
204
211
  export const TOOL_REGISTRY = [
205
212
  {
206
213
  name: 'read_pad',
@@ -913,8 +920,23 @@ export const TOOL_REGISTRY = [
913
920
  docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
914
921
  metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
915
922
  },
916
- handler: async ({ docId, metadata: updates }) => {
923
+ handler: async ({ docId, metadata: rawUpdates }) => {
917
924
  const target = resolveDocTarget(docId);
925
+ // MCP-9: strip control keys. `autoAccept` governs the human accept/reject
926
+ // gate — an agent that could set it via set_metadata would self-grant
927
+ // auto-accept and bypass human review entirely. The approval-mode flag is
928
+ // operator-only (UI toggle → setDocAutoAccept / setWorkspaceAutoAccept).
929
+ // Stripping covers both set AND remove: deleting an explicit
930
+ // `autoAccept: false` would re-enable workspace-inherited auto-accept.
931
+ const updates = {};
932
+ const blockedKeys = [];
933
+ for (const [key, value] of Object.entries(rawUpdates)) {
934
+ if (AGENT_FORBIDDEN_METADATA_KEYS.has(key)) {
935
+ blockedKeys.push(key);
936
+ continue;
937
+ }
938
+ updates[key] = value;
939
+ }
918
940
  const setKeys = [];
919
941
  const removed = [];
920
942
  for (const [key, value] of Object.entries(updates)) {
@@ -988,6 +1010,8 @@ export const TOOL_REGISTRY = [
988
1010
  parts.push(`set: ${keys.join(', ')}`);
989
1011
  if (removed.length > 0)
990
1012
  parts.push(`removed: ${removed.join(', ')}`);
1013
+ if (blockedKeys.length > 0)
1014
+ parts.push(`ignored (operator-only): ${blockedKeys.join(', ')}`);
991
1015
  return { content: [{ type: 'text', text: `Metadata updated (${parts.join('; ')})` }] };
992
1016
  },
993
1017
  },
@@ -7,6 +7,23 @@ import { discoverPlugins, loadPluginModule } from './plugin-discovery.js';
7
7
  import { registerPluginTools, removePluginTools } from './mcp.js';
8
8
  import { readConfig, saveConfig, getDataDir } from './helpers.js';
9
9
  import { broadcastPluginsChanged } from './ws.js';
10
+ import { isAllowedPublishApiUrl } from './connections.js';
11
+ // MCP-2: plugin config holds raw secrets (publish ow_live_ key, X OAuth1
12
+ // tokens, GitHub PAT, Gemini key). These must never cross the HTTP API. We
13
+ // redact any config value whose KEY names a secret before it leaves the
14
+ // server. Returned in place of the value is a sentinel that the settings UI
15
+ // renders as "set"; updateConfig() treats an echoed sentinel as "unchanged"
16
+ // so a naive save round-trip can never clobber the real secret with the mask.
17
+ const SECRET_KEY_RE = /(key|secret|token|pat|password|auth|credential|bearer)/i;
18
+ const REDACTED_SECRET = '__OW_SECRET_REDACTED__';
19
+ /** Mask secret-valued config fields for safe transport over the API. */
20
+ function redactConfig(config) {
21
+ const out = {};
22
+ for (const [k, v] of Object.entries(config)) {
23
+ out[k] = SECRET_KEY_RE.test(k) && v ? REDACTED_SECRET : v;
24
+ }
25
+ return out;
26
+ }
10
27
  export class PluginManager {
11
28
  app;
12
29
  plugins = new Map();
@@ -101,7 +118,22 @@ export class PluginManager {
101
118
  const managed = this.plugins.get(name);
102
119
  if (!managed)
103
120
  return { success: false, error: `Plugin "${name}" not found` };
104
- managed.config = { ...managed.config, ...values };
121
+ // Drop echoed redaction sentinels — the API never hands out real secrets
122
+ // (see redactConfig), so a value equal to the sentinel means "unchanged".
123
+ // Keeping the spread merge then preserves the stored secret. MCP-2.
124
+ const incoming = {};
125
+ for (const [k, v] of Object.entries(values)) {
126
+ if (v === REDACTED_SECRET)
127
+ continue;
128
+ incoming[k] = v;
129
+ }
130
+ // MCP-6: a hijacked publish `api-url` redirects the Bearer key off-host.
131
+ // Reject writes that point it anywhere but an allowed destination. The
132
+ // load-bearing pin lives in connections.ts; this rejects bad writes early.
133
+ if (typeof incoming['api-url'] === 'string' && incoming['api-url'] && !isAllowedPublishApiUrl(incoming['api-url'])) {
134
+ return { success: false, error: 'Invalid api-url: must point to an OpenWriter publish host' };
135
+ }
136
+ managed.config = { ...managed.config, ...incoming };
105
137
  this.savePluginState();
106
138
  return { success: true };
107
139
  }
@@ -113,7 +145,7 @@ export class PluginManager {
113
145
  description: m.discovered.description,
114
146
  enabled: m.enabled,
115
147
  configSchema: m.configSchema,
116
- config: m.config,
148
+ config: redactConfig(m.config), // MCP-2: never leak raw secrets over the API
117
149
  source: m.discovered.source,
118
150
  displayName: m.discovered.displayName,
119
151
  category: m.discovered.category,
@@ -4,7 +4,7 @@
4
4
  * Manifests live in ~/.openwriter/_workspaces/*.json.
5
5
  */
6
6
  import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
7
- import { join } from 'path';
7
+ import { join, resolve, isAbsolute, sep } from 'path';
8
8
  import { randomUUID } from 'crypto';
9
9
  import matter from 'gray-matter';
10
10
  import trash from 'trash';
@@ -17,8 +17,44 @@ import { addDocToContainer, addContainer as addContainerToTree, removeNode, move
17
17
  // ============================================================================
18
18
  // INTERNAL HELPERS
19
19
  // ============================================================================
20
+ /**
21
+ * Resolve a workspace manifest filename to an absolute path that is GUARANTEED
22
+ * to live inside the active profile's `_workspaces/` directory.
23
+ *
24
+ * The `filename` originates from MCP tool args (`wsFile`, `filename`), so it is
25
+ * untrusted. Without this guard a value like `../../OtherProfile/_workspaces/x.json`
26
+ * or an absolute path would escape the active profile and read/write/delete
27
+ * another profile's manifests (and, via the doc files they reference, another
28
+ * profile's documents). Profile scoping in OpenWriter is enforced by anchoring
29
+ * every manifest path under `getWorkspacesDir()` (which encodes the active
30
+ * profile) — so containment IS profile scoping. Escaping containment is the
31
+ * only way to cross profiles, and this resolver makes that impossible.
32
+ *
33
+ * Rules: no separators, no `..`, not absolute, no null byte, must end in
34
+ * `.json`, never the reserved `_order.json`. Then a `path.resolve` +
35
+ * prefix-containment assert is the authoritative backstop.
36
+ *
37
+ * adr: (MCP-7 — workspace path traversal + profile scoping)
38
+ */
20
39
  function workspacePath(filename) {
21
- return join(getWorkspacesDir(), filename);
40
+ if (!filename || typeof filename !== 'string') {
41
+ throw new Error('Invalid workspace identifier');
42
+ }
43
+ if (filename.includes('\0') ||
44
+ filename.includes('/') ||
45
+ filename.includes('\\') ||
46
+ filename.includes('..') ||
47
+ isAbsolute(filename) ||
48
+ !filename.endsWith('.json') ||
49
+ filename === '_order.json') {
50
+ throw new Error('Invalid workspace identifier');
51
+ }
52
+ const baseDir = resolve(getWorkspacesDir());
53
+ const resolved = resolve(baseDir, filename);
54
+ if (resolved !== baseDir && !resolved.startsWith(baseDir + sep)) {
55
+ throw new Error('Invalid workspace identifier');
56
+ }
57
+ return resolved;
22
58
  }
23
59
  /**
24
60
  * Migrate workspace-level tags into document frontmatter.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",