Fix(mobile) terminal replay, reset artifacts, and preview detection (#1383)

* fix terminal rendering and preview detection

* Fix bot comments

* fix: protect terminal preview URL probe

---------

Co-authored-by: Konstantin Zolin <zolin_ka@vk.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
kostazol
2026-05-23 13:15:16 +03:00
committed by GitHub
co-authored by Konstantin Zolin Bohdan Triapitsyn
parent ca33c6ae57
commit 1d36995c47
9 changed files with 337 additions and 89 deletions
@@ -238,6 +238,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `DELETE /api/passkeys/:id`
- `POST /api/auth/reset`
- `GET /connect`
- `POST /api/system/probe-url`
- `app.use('/api', ...)` auth/tunnel guard
- `registerSettingsUtilityRoutes(app, dependencies)`: registers small settings utility endpoints:
- `GET /api/config/themes`
+1
View File
@@ -71,6 +71,7 @@ export const createBootstrapRuntime = (dependencies) => {
}
registerAuthAndAccessRoutes(app, {
express,
tunnelAuthController,
uiAuthController,
readSettingsFromDiskMigrated,
@@ -1,3 +1,27 @@
const parseLoopbackUrl = (rawUrl) => {
if (typeof rawUrl !== 'string') {
return null;
}
let url;
try {
url = new URL(rawUrl);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null;
}
const host = url.hostname;
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && host !== '0.0.0.0') {
return null;
}
return url;
};
export const registerServerStatusRoutes = (app, dependencies) => {
const {
express,
@@ -239,16 +263,26 @@ export const registerServerStatusRoutes = (app, dependencies) => {
return res.status(500).json({ error: (error && error.message) || 'Failed to allocate port' });
}
});
};
export const registerAuthAndAccessRoutes = (app, dependencies) => {
const {
express,
tunnelAuthController,
uiAuthController,
readSettingsFromDiskMigrated,
normalizeTunnelSessionTtlMs,
} = dependencies;
const requireApiAuth = async (req, res, next) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return tunnelAuthController.requireTunnelSession(req, res, next);
}
return uiAuthController.requireAuth(req, res, next);
};
app.get('/auth/session', async (req, res) => {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
@@ -398,13 +432,33 @@ export const registerAuthAndAccessRoutes = (app, dependencies) => {
}
});
app.post('/api/system/probe-url', express.json({ limit: '16kb' }), async (req, res, next) => {
try {
await requireApiAuth(req, res, async () => {
const url = parseLoopbackUrl(req.body?.url);
if (!url) {
return res.status(400).json({ ok: false, error: 'Invalid loopback URL' });
}
try {
const response = await fetch(url.toString(), {
method: 'GET',
redirect: 'manual',
signal: AbortSignal.timeout(1500),
});
return res.json({ ok: response.ok, status: response.status });
} catch (error) {
return res.json({ ok: false, error: error?.message || 'Probe failed' });
}
});
} catch (error) {
next(error);
}
});
app.use('/api', async (req, res, next) => {
try {
const requestScope = tunnelAuthController.classifyRequestScope(req);
if (requestScope === 'tunnel' || requestScope === 'unknown-public') {
return tunnelAuthController.requireTunnelSession(req, res, next);
}
await uiAuthController.requireAuth(req, res, next);
await requireApiAuth(req, res, next);
} catch (err) {
next(err);
}
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js';
import { registerAuthAndAccessRoutes, registerCommonRequestMiddleware, registerServerStatusRoutes } from './core-routes.js';
describe('core-routes', () => {
it('should call gracefulShutdown with exitProcess: true on /api/system/shutdown', async () => {
@@ -39,4 +39,48 @@ describe('core-routes', () => {
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
});
it('should require API auth before probing loopback preview URLs', async () => {
const app = express();
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
registerAuthAndAccessRoutes(app, {
express,
tunnelAuthController: {
classifyRequestScope: () => 'local',
requireTunnelSession: vi.fn(),
getTunnelSessionFromRequest: vi.fn(),
clearTunnelSessionCookie: vi.fn(),
exchangeBootstrapToken: vi.fn(),
},
uiAuthController: {
requireAuth: (_req, res) => res.status(401).json({ error: 'Unauthorized' }),
handleSessionStatus: vi.fn(),
handleSessionCreate: vi.fn(),
handlePasskeyStatus: vi.fn(),
handlePasskeyAuthenticationOptions: vi.fn(),
handlePasskeyAuthenticationVerify: vi.fn(),
handlePasskeyRegistrationOptions: vi.fn(),
handlePasskeyRegistrationVerify: vi.fn(),
handlePasskeyList: vi.fn(),
handlePasskeyRevoke: vi.fn(),
handleResetAuth: vi.fn(),
},
readSettingsFromDiskMigrated: vi.fn(async () => ({})),
normalizeTunnelSessionTtlMs: vi.fn(),
});
try {
await request(app)
.post('/api/system/probe-url')
.send({ url: 'http://127.0.0.1:5173/' })
.expect(401);
expect(fetchMock).not.toHaveBeenCalled();
} finally {
globalThis.fetch = originalFetch;
}
});
});