|
1 | | -import { readFile } from 'node:fs/promises'; |
| 1 | +import { readFile, realpath } from 'node:fs/promises'; |
2 | 2 | import path_module from 'node:path'; |
3 | 3 | import type { CoreLogger } from '@open-codesign/core'; |
4 | 4 | import type Database from 'better-sqlite3'; |
@@ -97,6 +97,7 @@ export interface WorkspaceResolution { |
97 | 97 | mime: string; |
98 | 98 | designId: string; |
99 | 99 | relPath: string; |
| 100 | + workspacePath: string; |
100 | 101 | } |
101 | 102 |
|
102 | 103 | export interface WorkspaceResolveResult { |
@@ -163,7 +164,31 @@ export function resolveWorkspaceUrl( |
163 | 164 | return { ok: false, error: 'unsupported_mime' }; |
164 | 165 | } |
165 | 166 |
|
166 | | - return { ok: true, value: { absPath, mime, designId, relPath } }; |
| 167 | + return { |
| 168 | + ok: true, |
| 169 | + value: { absPath, mime, designId, relPath, workspacePath: normalizedWorkspace }, |
| 170 | + }; |
| 171 | +} |
| 172 | + |
| 173 | +export async function resolveWorkspaceRealPath( |
| 174 | + resolution: WorkspaceResolution, |
| 175 | +): Promise<WorkspaceResolveResult | WorkspaceResolveFailure> { |
| 176 | + const realWorkspace = await realpath(resolution.workspacePath); |
| 177 | + const realAbsPath = await realpath(resolution.absPath); |
| 178 | + const sep = path_module.sep; |
| 179 | + const isInside = |
| 180 | + realAbsPath === realWorkspace || realAbsPath.startsWith(`${realWorkspace}${sep}`); |
| 181 | + if (!isInside) { |
| 182 | + return { ok: false, error: 'traversal' }; |
| 183 | + } |
| 184 | + return { |
| 185 | + ok: true, |
| 186 | + value: { |
| 187 | + ...resolution, |
| 188 | + absPath: realAbsPath, |
| 189 | + workspacePath: realWorkspace, |
| 190 | + }, |
| 191 | + }; |
167 | 192 | } |
168 | 193 |
|
169 | 194 | export function registerWorkspaceScheme(): void { |
@@ -225,15 +250,24 @@ export function registerWorkspaceProtocolHandler(opts: RegisterWorkspaceProtocol |
225 | 250 | } |
226 | 251 |
|
227 | 252 | try { |
228 | | - const data = await readFile(result.value.absPath); |
229 | | - const isHtml = result.value.mime.startsWith('text/html'); |
| 253 | + const realPathResult = await resolveWorkspaceRealPath(result.value); |
| 254 | + if (!realPathResult.ok) { |
| 255 | + logger.warn('workspace.protocol.reject', { url: request.url, error: realPathResult.error }); |
| 256 | + return new Response(realPathResult.error, { |
| 257 | + status: 403, |
| 258 | + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, |
| 259 | + }); |
| 260 | + } |
| 261 | + |
| 262 | + const data = await readFile(realPathResult.value.absPath); |
| 263 | + const isHtml = realPathResult.value.mime.startsWith('text/html'); |
230 | 264 | const body: string | Uint8Array = isHtml |
231 | 265 | ? `${data.toString('utf8')}${WORKSPACE_NAV_INTERCEPT_SCRIPT}` |
232 | 266 | : (data as unknown as Uint8Array); |
233 | 267 | return new Response(body, { |
234 | 268 | status: 200, |
235 | 269 | headers: { |
236 | | - 'Content-Type': result.value.mime, |
| 270 | + 'Content-Type': realPathResult.value.mime, |
237 | 271 | // Iframe is sandboxed (opaque origin); permissive CORS keeps fetch() |
238 | 272 | // and dynamic <script> imports working when pages need them. |
239 | 273 | 'Access-Control-Allow-Origin': '*', |
|
0 commit comments