diff --git a/.changeset/refactor-attach-root-url-595.md b/.changeset/refactor-attach-root-url-595.md new file mode 100644 index 0000000..403889e --- /dev/null +++ b/.changeset/refactor-attach-root-url-595.md @@ -0,0 +1,5 @@ +--- +"@ait-co/devtools": patch +--- + +디버그 대시보드 사용자 URL을 `/attach?u=` 에서 루트 `/`(server-state 렌더)로 수렴 (#595) — 주소창·히스토리에서 TOTP at=·tunnel host 노출 표면 제거. `/attach?u=` 라우트는 back-compat으로 유지. diff --git a/src/mcp/__tests__/qr-browser.test.ts b/src/mcp/__tests__/qr-browser.test.ts index 0917cb9..3fcab88 100644 --- a/src/mcp/__tests__/qr-browser.test.ts +++ b/src/mcp/__tests__/qr-browser.test.ts @@ -1,7 +1,9 @@ /** - * QR HTTP 서버 + browser open 기능 테스트 (issue #244): + * QR HTTP 서버 + browser open 기능 테스트 (issue #244, #595): * - startQrHttpServer: /attach → HTML (base64 inline QR + 스캔 절차 + 진단 체크리스트) * - startQrHttpServer: /qr.png → image/png + PNG magic bytes + * - buildAttachPageUrl: 루트 `/` URL 반환 (#595 — 시크릿 없는 주소창 노출) + * - openQrInBrowser: httpUrl이 루트 `/`이고 `?u=`/`at=`를 포함하지 않음 * - openQrInBrowser: platform별 fallback chain, 1차 실패 → 2차 호출 * - openQrInBrowser: 모두 실패 시 URL + stderr 안내 * - SECRET-HANDLING: at= 코드가 stderrSummary에서 redact되는지 @@ -87,12 +89,13 @@ describe('canOpenBrowser', () => { describe('startQrHttpServer', () => { it('GET /attach → HTML with base64 inline QR + scan steps + diagnostic checklist + attachUrl', async () => { + // buildAttachPageUrl은 루트 `/`를 반환하므로 (#595), /attach 라우트를 직접 테스트. const { startQrHttpServer } = await import('../qr-http-server.js'); const srv = await startQrHttpServer(); const attachUrl = 'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.trycloudflare.com'; - const pageUrl = srv.buildAttachPageUrl(attachUrl); + const pageUrl = `http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`; const res = await fetch(pageUrl, { headers: { 'Accept-Language': 'ko' } }); expect(res.status).toBe(200); @@ -144,15 +147,17 @@ describe('startQrHttpServer', () => { await srv.close(); }); - it('buildAttachPageUrl encodes attachUrl into /attach?u= query', async () => { + it('buildAttachPageUrl returns root / without query string (#595 — 시크릿 노출 표면 축소)', async () => { const { startQrHttpServer } = await import('../qr-http-server.js'); const srv = await startQrHttpServer(); const attachUrl = 'intoss-private://app?_deploymentId=abc&debug=1&relay=wss://r.tc.com'; const url = srv.buildAttachPageUrl(attachUrl); - expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/attach\?u=/); - expect(url).toContain(encodeURIComponent(attachUrl)); + // 루트 `/`를 반환해야 한다 — 쿼리 없음, at= 없음. + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/); + expect(url).not.toContain('?u='); + expect(url).not.toContain('at='); await srv.close(); }); @@ -385,6 +390,26 @@ describe('openQrInBrowser', () => { expect(result.stderrSummary).not.toContain('ABC123'); expect(result.stderrSummary).toContain('at='); }); + + // #595: buildAttachPageUrl 이 루트 `/`를 반환하므로, 실제 caller가 넘기는 httpUrl은 루트 URL이다. + // openQrInBrowser 자체는 인자를 에코하므로, 루트 URL을 넘겼을 때 결과가 시크릿 없는 루트임을 검증. + it('#595: httpUrl이 루트 `/`이면 결과에 ?u= / at= 포함되지 않음', async () => { + setPlatform('darwin'); + const { spawnSync } = await import('node:child_process'); + (spawnSync as ReturnType).mockReturnValue({ status: 0, stderr: '', error: null }); + + const rootUrl = 'http://127.0.0.1:12345/'; + const pngUrl = 'http://127.0.0.1:12345/qr.png?u=intoss-private%3A%2F%2Fapp'; + const result = await openQrInBrowser(rootUrl, pngUrl); + + expect(result.opened).toBe(true); + expect(result.httpUrl).toBe(rootUrl); + // httpUrl에 시크릿 포함 금지 — 쿼리 파라미터 없음 + expect(result.httpUrl).not.toContain('?u='); + expect(result.httpUrl).not.toContain('at='); + // pngUrl은 여전히 ?u= 를 가질 수 있음 (stateless 이미지 헬퍼) + expect(result.pngUrl).toContain('/qr.png'); + }); }); // --------------------------------------------------------------------------- diff --git a/src/mcp/__tests__/qr-http-server.test.ts b/src/mcp/__tests__/qr-http-server.test.ts index 369f6a5..3048483 100644 --- a/src/mcp/__tests__/qr-http-server.test.ts +++ b/src/mcp/__tests__/qr-http-server.test.ts @@ -723,7 +723,9 @@ describe('startQrHttpServer — SSE /events', () => { // --------------------------------------------------------------------------- describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주입)', () => { - it('GET /attach → HTML (dashboard 주입 시에도 동일)', async () => { + it('buildAttachPageUrl → 루트 `/` 반환 (#595), GET / → 200 HTML', async () => { + // #595: buildAttachPageUrl이 루트 URL을 반환한다 — 시크릿 없는 주소창 노출. + // 루트 `/`는 buildDashboardHtml(server-state 렌더)을 서빙한다. const srv = await startQrHttpServer(() => ({ tunnel: { up: true, wssUrl: null }, pages: [], @@ -732,11 +734,39 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주 try { const attachUrl = 'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.tc.com'; - const res = await fetch(srv.buildAttachPageUrl(attachUrl), { + const pageUrl = srv.buildAttachPageUrl(attachUrl); + // buildAttachPageUrl은 루트 `/`를 반환해야 한다. + expect(pageUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/$/); + expect(pageUrl).not.toContain('?u='); + + const res = await fetch(pageUrl, { headers: { 'Accept-Language': 'ko' }, }); expect(res.status).toBe(200); const html = await res.text(); + // 루트 Dashboard HTML — "Attach QR" 섹션이 있음. + expect(html).toContain('Attach QR'); + } finally { + await srv.close(); + } + }); + + it('GET /attach?u= → HTML (back-compat 라우트 유지)', async () => { + // /attach?u= 라우트는 back-compat으로 유지됨 (#595). + const srv = await startQrHttpServer(() => ({ + tunnel: { up: true, wssUrl: null }, + pages: [], + attachUrl: null, + })); + try { + const attachUrl = + 'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.tc.com'; + const res = await fetch( + `http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`, + { headers: { 'Accept-Language': 'ko' } }, + ); + expect(res.status).toBe(200); + const html = await res.text(); expect(html).toContain('QR 스캔'); } finally { await srv.close(); @@ -758,9 +788,10 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주 }); // ── /attach page — SSE injection & id="attach-section" (Defect 1 fix, #435) ─ + // back-compat: /attach?u= 라우트는 #595 이후에도 유지됨. 이 테스트들은 직접 URL로 검증. it('GET /attach HTML contains EventSource(/events) SSE script (#435)', async () => { - // buildAttachHtml now injects buildSseScript so the /attach page subscribes + // buildAttachHtml injects buildSseScript so the /attach page subscribes // to /events and can update the QR img live — avoiding expired TOTP at= codes. const srv = await startQrHttpServer(() => ({ tunnel: { up: true, wssUrl: null }, @@ -770,9 +801,11 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주 try { const attachUrl = 'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.tc.com'; - const res = await fetch(srv.buildAttachPageUrl(attachUrl), { - headers: { 'Accept-Language': 'ko' }, - }); + // /attach?u= 직접 사용 (back-compat 라우트, #595 이후에도 유지됨). + const res = await fetch( + `http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`, + { headers: { 'Accept-Language': 'ko' } }, + ); expect(res.status).toBe(200); const html = await res.text(); // SSE subscription script must be present. @@ -794,8 +827,9 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주 try { const attachUrl = 'intoss-private://aitc-sdk-example?_deploymentId=test-uuid&debug=1&relay=wss%3A%2F%2Fx.tc.com'; + // /attach?u= 직접 사용 (back-compat 라우트, #595 이후에도 유지됨). const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrl), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`, { headers: { 'Accept-Language': 'ko' }, }) ).text(); @@ -820,7 +854,10 @@ describe('startQrHttpServer — 기존 라우트 공존 (getDashboardState 주 try { const markerCode = '123456'; // 6-digit stand-in (not a real TOTP value) const attachUrl = `intoss-private://app?_deploymentId=test&debug=1&relay=wss%3A%2F%2Fx.tc.com&at=${markerCode}`; - const html = await (await fetch(srv.buildAttachPageUrl(attachUrl))).text(); + // /attach?u= 직접 사용 (back-compat 라우트, #595 이후에도 유지됨). + const html = await ( + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`) + ).text(); // The at= code is inside attachUrl (url-box + QR src placeholder) — not a // standalone field. No text node outside the url or QR img should contain // a bare 6-digit code as an isolated token. @@ -1067,6 +1104,7 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', ( // ── /attach 표면 ───────────────────────────────────────────────────────── it('GET /attach — .url-row + .copy-btn + id="url-box" 포함', async () => { + // back-compat 라우트 직접 테스트 (#595). const srv = await startQrHttpServer(() => ({ tunnel: { up: true, wssUrl: null }, pages: [], @@ -1074,7 +1112,7 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', ( })); try { const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrlDummy), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, { headers: { 'Accept-Language': 'ko' }, }) ).text(); @@ -1089,6 +1127,7 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', ( it('GET /attach — id="url-section" 섹션에 url-row 포함 (url-box는 #url-section에만)', async () => { // 이중 표시 방지 (#458): url-box가 #attach-section 안에 없고 // #url-section 안에만 있어야 한다. + // back-compat 라우트 직접 테스트 (#595). const srv = await startQrHttpServer(() => ({ tunnel: { up: true, wssUrl: null }, pages: [], @@ -1096,7 +1135,7 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', ( })); try { const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrlDummy), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, { headers: { 'Accept-Language': 'ko' }, }) ).text(); @@ -1117,6 +1156,7 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', ( it('GET /attach — SSE 스크립트가 attach 표면 분기(img src 교체 + #url-box textContent 갱신) 포함', async () => { // /attach 표면: innerHTML 전체 교체가 아니라 img src 교체 + url-box textContent만 갱신해야 // SSE push 때 url-box가 이중으로 생기지 않는다(#458 핵심 수정). + // back-compat 라우트 직접 테스트 (#595). const srv = await startQrHttpServer(() => ({ tunnel: { up: true, wssUrl: null }, pages: [], @@ -1124,7 +1164,7 @@ describe('startQrHttpServer — url-box click-to-copy + 복사 버튼 (#458)', ( })); try { const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrlDummy), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, { headers: { 'Accept-Language': 'ko' }, }) ).text(); @@ -1174,10 +1214,12 @@ describe('startQrHttpServer — /attach mode-aware chrome 분기 (#468)', () => attachUrl: string, lang: 'ko' | 'en', ): Promise { + // /attach?u= 직접 사용 — back-compat 라우트 (#595 이후에도 유지됨). + // mode-aware 카피 분기는 /attach 라우트에서 렌더되므로 직접 URL로 검증한다. const srv = await startQrHttpServer(() => makeState(mode, attachUrl)); try { return await ( - await fetch(srv.buildAttachPageUrl(attachUrl), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrl)}`, { headers: { 'Accept-Language': lang }, }) ).text(); @@ -1451,6 +1493,10 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () = const attachUrlDummy = 'intoss-private://aitc-sdk-example?_deploymentId=test-inspector&debug=1&relay=wss%3A%2F%2Fx.tc.com'; + // #595: 이 테스트들은 /attach?u= 라우트를 직접 사용 (back-compat 유지됨). + // buildAttachPageUrl은 루트 `/`를 반환하지만 /attach 라우트의 inspector 동작을 검증하려면 + // 직접 URL이 필요하다. + it('getDirectInspectorUrl 주입 + pages.length > 0 → "디버그 툴 열기" 활성 버튼(ko)', async () => { const srv = await startQrHttpServer( () => ({ @@ -1468,7 +1514,7 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () = ); try { const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrlDummy), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, { headers: { 'Accept-Language': 'ko' }, }) ).text(); @@ -1501,7 +1547,7 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () = ); try { const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrlDummy), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, { headers: { 'Accept-Language': 'ko' }, }) ).text(); @@ -1522,7 +1568,7 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () = })); try { const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrlDummy), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, { headers: { 'Accept-Language': 'ko' }, }) ).text(); @@ -1545,7 +1591,7 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () = ); try { const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrlDummy), { + await fetch(`http://127.0.0.1:${srv.port}/attach?u=${encodeURIComponent(attachUrlDummy)}`, { headers: { 'Accept-Language': 'ko' }, }) ).text(); @@ -1577,9 +1623,10 @@ describe('startQrHttpServer — /attach 페이지 inspector 버튼 (#544)', () = capturedPort = srv.port; try { const html = await ( - await fetch(srv.buildAttachPageUrl(attachUrlDummy), { - headers: { 'Accept-Language': 'ko' }, - }) + await fetch( + `http://127.0.0.1:${capturedPort}/attach?u=${encodeURIComponent(attachUrlDummy)}`, + { headers: { 'Accept-Language': 'ko' } }, + ) ).text(); // href는 /inspector 안정 URL (127.0.0.1 로컬, 시크릿 없음) expect(html).toContain(`http://127.0.0.1:${capturedPort}/inspector`); diff --git a/src/mcp/qr-http-server.ts b/src/mcp/qr-http-server.ts index 8a8e624..43afd92 100644 --- a/src/mcp/qr-http-server.ts +++ b/src/mcp/qr-http-server.ts @@ -894,8 +894,13 @@ export async function startQrHttpServer( return { port, - buildAttachPageUrl(attachUrl: string): string { - return `http://127.0.0.1:${port}/attach?u=${encodeURIComponent(attachUrl)}`; + buildAttachPageUrl(_attachUrl: string): string { + // 사용자 대면 URL을 루트 `/`로 수렴 (#595). + // 같은 데몬이 attachUrl을 이미 server-state(getDashboardState)로 보유하므로 + // `/attach?u=` 쿼리는 redundant하다. + // SECRET-HANDLING: 브라우저에 열리는 URL에서 tunnel host·relay wss·TOTP at= 제거. + // /attach?u= 라우트 자체는 back-compat으로 유지(기존 인쇄된 링크 보호). + return `http://127.0.0.1:${port}/`; }, // 안정 인스펙터 진입점 URL (issue #530) — 클릭 시 302 redirect (TOTP 클릭 시점 mint). // URL 자체에 시크릿 없음 → 대시보드/stdout/로그 어디든 출력 가능. diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index c0b0a2a..6034d24 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -932,13 +932,13 @@ export function canOpenBrowser(): boolean { * Result of `openQrInBrowser`. * * HTTP URL 기반으로 재구현 — tmp 파일 없음. `httpUrl`이 브라우저에 전달되는 URL이다. - * SECRET-HANDLING: `httpUrl`은 127.0.0.1 로컬 전용이며 at= 코드 값을 직접 담지 않는다 - * (attachUrl은 /attach?u= query로 전달되어 서버 메모리에서만 처리). + * SECRET-HANDLING: `httpUrl`은 127.0.0.1 로컬 전용이며 tunnel host·relay wss·TOTP at= 코드를 + * 담지 않는다 (#595). attachUrl은 server-state(getDashboardState)로만 보유된다. */ export interface OpenQrInBrowserResult { /** `true` if the browser was successfully opened. */ opened: boolean; - /** `http://127.0.0.1:/attach?u=...` — 브라우저에 전달된 URL. */ + /** `http://127.0.0.1:/` — 브라우저에 전달되는 루트 URL (#595). */ httpUrl: string; /** `http://127.0.0.1:/qr.png?u=...` — PNG fallback URL. */ pngUrl: string; @@ -1002,7 +1002,7 @@ function isLaunchFailureStderr(stderr: string): boolean { } /** - * 로컬 HTTP 서버 URL(`http://127.0.0.1:/attach?u=...`)을 OS 기본 브라우저로 연다. + * 로컬 HTTP 서버 루트 URL(`http://127.0.0.1:/`)을 OS 기본 브라우저로 연다 (#595). * * platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다 * (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` + @@ -1010,11 +1010,11 @@ function isLaunchFailureStderr(stderr: string): boolean { * * SECRET-HANDLING: * - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답). - * - httpUrl/pngUrl은 127.0.0.1 로컬 전용. + * - httpUrl은 `http://127.0.0.1:/`(루트, 시크릿 없음). pngUrl은 127.0.0.1 로컬 전용. * - stderr 캡처 결과에서 at= 코드 값을 redact한 후 stderrSummary에 포함. * - attachUrl, deploymentId, TOTP 코드를 stdout/stderr/로그에 직접 출력 금지. * - * @param httpUrl - `http://127.0.0.1:/attach?u=` HTTP URL. + * @param httpUrl - `http://127.0.0.1:/` 루트 URL (시크릿 없음, #595). * @param pngUrl - `http://127.0.0.1:/qr.png?u=` PNG fallback URL. */ export async function openQrInBrowser(