zelune 1.4.0 → 1.5.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,47 +4,197 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
4
4
  import type { Plugin } from 'vite';
5
5
  import {
6
6
  discoverScreens,
7
+ getActiveProjectRoot,
7
8
  inspectElement,
8
9
  patchElement,
10
+ reorderElement,
9
11
  undoLastPatch,
10
12
  redoLastPatch,
11
13
  getHistoryStatus,
12
14
  type PatchPayload,
15
+ type ReorderPayload,
13
16
  } from './astEngine';
14
17
 
15
- async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
18
+ const MAX_BODY_BYTES = 1_000_000;
19
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
20
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
21
+
22
+ export function isLoopbackHostHeader(hostHeader: string | undefined): boolean {
23
+ if (!hostHeader) return false;
24
+ const trimmed = hostHeader.trim().toLowerCase();
25
+ if (trimmed.startsWith('[')) {
26
+ const closeIdx = trimmed.indexOf(']');
27
+ if (closeIdx === -1) return false;
28
+ const ipv6Host = trimmed.slice(0, closeIdx + 1);
29
+ return LOOPBACK_HOSTS.has(ipv6Host);
30
+ }
31
+ const hostname = trimmed.split(':')[0];
32
+ return LOOPBACK_HOSTS.has(hostname);
33
+ }
34
+
35
+ export function verifyApiRequestSecurity(req: {
36
+ method?: string;
37
+ headers: Record<string, string | string[] | undefined>;
38
+ }): { allowed: boolean; reason?: string } {
39
+ const host = typeof req.headers.host === 'string' ? req.headers.host : undefined;
40
+ if (!isLoopbackHostHeader(host)) {
41
+ return {
42
+ allowed: false,
43
+ reason: 'Forbidden Host header: Local API only accepts loopback connections (127.0.0.1, localhost, [::1])',
44
+ };
45
+ }
46
+
47
+ const secFetchSite =
48
+ typeof req.headers['sec-fetch-site'] === 'string'
49
+ ? req.headers['sec-fetch-site'].toLowerCase()
50
+ : undefined;
51
+ if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
52
+ return {
53
+ allowed: false,
54
+ reason: `Forbidden cross-site request (Sec-Fetch-Site: ${secFetchSite})`,
55
+ };
56
+ }
57
+
58
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined;
59
+ if (origin) {
60
+ try {
61
+ const parsedOrigin = new URL(origin);
62
+ if (!LOOPBACK_HOSTS.has(parsedOrigin.hostname.toLowerCase())) {
63
+ return {
64
+ allowed: false,
65
+ reason: `Forbidden cross-origin request from ${origin}`,
66
+ };
67
+ }
68
+ } catch {
69
+ return {
70
+ allowed: false,
71
+ reason: 'Malformed Origin header',
72
+ };
73
+ }
74
+ }
75
+
76
+ if (req.method === 'POST') {
77
+ const contentType =
78
+ typeof req.headers['content-type'] === 'string'
79
+ ? req.headers['content-type'].toLowerCase()
80
+ : '';
81
+ if (!contentType.startsWith('application/json')) {
82
+ return {
83
+ allowed: false,
84
+ reason: 'POST requests to /api/* must specify Content-Type: application/json',
85
+ };
86
+ }
87
+ }
88
+
89
+ return { allowed: true };
90
+ }
91
+
92
+ export function sanitizeLayoutPayload(
93
+ input: unknown
94
+ ): Record<string, { x: number; y: number; width: number; height: number }> {
95
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
96
+ throw new Error('Invalid layout payload: expected an object map of screen coordinates');
97
+ }
98
+
99
+ const sanitized: Record<string, { x: number; y: number; width: number; height: number }> =
100
+ Object.create(null);
101
+ const entries = Object.entries(input as Record<string, unknown>);
102
+ if (entries.length > 200) {
103
+ throw new Error('Invalid layout payload: too many screen entries');
104
+ }
105
+
106
+ for (const [screenId, rawBox] of entries) {
107
+ if (!screenId || FORBIDDEN_KEYS.has(screenId) || screenId.length > 120) {
108
+ throw new Error(`Invalid screen key in layout payload: "${screenId}"`);
109
+ }
110
+ if (!rawBox || typeof rawBox !== 'object' || Array.isArray(rawBox)) {
111
+ throw new Error(`Invalid layout coordinates for screen "${screenId}"`);
112
+ }
113
+ const box = rawBox as Record<string, unknown>;
114
+ const x = Number(box.x);
115
+ const y = Number(box.y);
116
+ const width = Number(box.width);
117
+ const height = Number(box.height);
118
+
119
+ if (
120
+ !Number.isFinite(x) ||
121
+ !Number.isFinite(y) ||
122
+ !Number.isFinite(width) ||
123
+ !Number.isFinite(height) ||
124
+ Math.abs(x) > 100_000 ||
125
+ Math.abs(y) > 100_000 ||
126
+ width < 100 ||
127
+ width > 5_000 ||
128
+ height < 100 ||
129
+ height > 5_000
130
+ ) {
131
+ throw new Error(`Out-of-bounds layout coordinates for screen "${screenId}"`);
132
+ }
133
+
134
+ sanitized[screenId] = {
135
+ x: Math.round(x),
136
+ y: Math.round(y),
137
+ width: Math.round(width),
138
+ height: Math.round(height),
139
+ };
140
+ }
141
+
142
+ return sanitized;
143
+ }
144
+
145
+ export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
16
146
  return new Promise((resolve, reject) => {
17
147
  let raw = '';
18
- req.on('data', (chunk) => {
19
- raw += chunk.toString();
20
- if (raw.length > 1_000_000) {
148
+ let byteCount = 0;
149
+ let aborted = false;
150
+
151
+ req.on('data', (chunk: Buffer | string) => {
152
+ if (aborted) return;
153
+ const chunkBytes =
154
+ typeof chunk === 'string' ? Buffer.byteLength(chunk, 'utf-8') : chunk.length;
155
+ byteCount += chunkBytes;
156
+
157
+ if (byteCount > MAX_BODY_BYTES) {
158
+ aborted = true;
159
+ req.destroy();
21
160
  reject(new Error('Request payload too large'));
161
+ return;
22
162
  }
163
+
164
+ raw += typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
23
165
  });
166
+
24
167
  req.on('end', () => {
168
+ if (aborted) return;
25
169
  try {
26
170
  resolve(raw ? (JSON.parse(raw) as T) : ({} as T));
27
171
  } catch (err) {
28
172
  reject(err);
29
173
  }
30
174
  });
31
- req.on('error', reject);
175
+
176
+ req.on('error', (err) => {
177
+ if (!aborted) {
178
+ aborted = true;
179
+ reject(err);
180
+ }
181
+ });
32
182
  });
33
183
  }
34
184
 
35
185
  function sendJson(res: ServerResponse, status: number, payload: unknown) {
36
186
  res.statusCode = status;
37
187
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
188
+ res.setHeader('X-Content-Type-Options', 'nosniff');
38
189
  res.end(JSON.stringify(payload));
39
190
  }
40
191
 
41
192
  export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
42
- const layoutFilePath = path.resolve(workspaceRoot, '.canvas-layout.json');
43
-
44
193
  async function resolveConnectedProjectName(): Promise<string> {
45
- const dirName = path.basename(workspaceRoot);
194
+ const activeRoot = getActiveProjectRoot(workspaceRoot);
195
+ const dirName = path.basename(activeRoot);
46
196
  try {
47
- const appJsonRaw = await fs.readFile(path.join(workspaceRoot, 'app.json'), 'utf-8');
197
+ const appJsonRaw = await fs.readFile(path.join(activeRoot, 'app.json'), 'utf-8');
48
198
  const appJson = JSON.parse(appJsonRaw) as {
49
199
  expo?: { name?: string };
50
200
  name?: string;
@@ -55,7 +205,7 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
55
205
  // No app.json present
56
206
  }
57
207
  try {
58
- const pkgRaw = await fs.readFile(path.join(workspaceRoot, 'package.json'), 'utf-8');
208
+ const pkgRaw = await fs.readFile(path.join(activeRoot, 'package.json'), 'utf-8');
59
209
  const pkg = JSON.parse(pkgRaw) as { name?: string };
60
210
  if (pkg?.name && pkg.name.toLowerCase() !== 'zelune') {
61
211
  return pkg.name;
@@ -74,6 +224,13 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
74
224
  return next();
75
225
  }
76
226
 
227
+ const securityCheck = verifyApiRequestSecurity(req);
228
+ if (!securityCheck.allowed) {
229
+ return sendJson(res, 403, { ok: false, error: securityCheck.reason });
230
+ }
231
+
232
+ const activeRoot = getActiveProjectRoot(workspaceRoot);
233
+ const layoutFilePath = path.resolve(activeRoot, '.canvas-layout.json');
77
234
  const urlObj = new URL(req.url, 'http://127.0.0.1');
78
235
  const pathname = urlObj.pathname;
79
236
 
@@ -114,6 +271,20 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
114
271
  });
115
272
  }
116
273
 
274
+ // POST /api/reorder
275
+ if (req.method === 'POST' && pathname === '/api/reorder') {
276
+ const body = await readJsonBody<ReorderPayload>(req);
277
+ const result = await reorderElement(workspaceRoot, body);
278
+ return sendJson(res, 200, {
279
+ ok: true,
280
+ element: result.element,
281
+ history: {
282
+ undoAvailable: result.undoAvailable,
283
+ redoAvailable: result.redoAvailable,
284
+ },
285
+ });
286
+ }
287
+
117
288
  // POST /api/undo
118
289
  if (req.method === 'POST' && pathname === '/api/undo') {
119
290
  const result = await undoLastPatch(workspaceRoot);
@@ -130,7 +301,8 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
130
301
  if (req.method === 'GET' && pathname === '/api/layout') {
131
302
  try {
132
303
  const raw = await fs.readFile(layoutFilePath, 'utf-8');
133
- return sendJson(res, 200, { ok: true, layout: JSON.parse(raw) });
304
+ const validated = sanitizeLayoutPayload(JSON.parse(raw));
305
+ return sendJson(res, 200, { ok: true, layout: validated });
134
306
  } catch {
135
307
  return sendJson(res, 200, { ok: true, layout: null });
136
308
  }
@@ -139,7 +311,8 @@ export function viteCanvasApiMiddleware(workspaceRoot: string): Plugin {
139
311
  // POST /api/layout
140
312
  if (req.method === 'POST' && pathname === '/api/layout') {
141
313
  const body = await readJsonBody<Record<string, unknown>>(req);
142
- await fs.writeFile(layoutFilePath, JSON.stringify(body, null, 2), 'utf-8');
314
+ const validated = sanitizeLayoutPayload(body);
315
+ await fs.writeFile(layoutFilePath, JSON.stringify(validated, null, 2), 'utf-8');
143
316
  return sendJson(res, 200, { ok: true });
144
317
  }
145
318