From a38f3dcec31504e875cdfb3c50d31d4756d8e719 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 8 Sep 2026 08:06:54 +0800 Subject: [PATCH] Require browser login for scheduled-task HTTP APIs. List/run/pause/resume/delete now return 401 without UDS cookies; hide sidebar chrome when logged out. uds-auth still validates the session store on the same routes. Co-authored-by: Cursor --- lib/client.js | 32 +++++++++++++++++++++ lib/host.js | 71 ++++++++++++++++++++++++++++++++++++----------- package.json | 2 +- test/host.test.js | 34 +++++++++++++++++++++++ 4 files changed, 122 insertions(+), 17 deletions(-) diff --git a/lib/client.js b/lib/client.js index c782fa5..2e233c6 100644 --- a/lib/client.js +++ b/lib/client.js @@ -160,6 +160,21 @@ window.__ModuleLoader__.load({ const NS = 'dsh-ops-cron' const inject = ['slots', 'locale', 'settingsScope'] const API = '/dsh-ops-cron' + + function readCookie(name) { + const parts = String(document.cookie || '').split(';') + for (const part of parts) { + const idx = part.indexOf('=') + if (idx < 0) continue + if (part.slice(0, idx).trim() !== name) continue + try { return decodeURIComponent(part.slice(idx + 1).trim()) } catch { return part.slice(idx + 1).trim() } + } + return null + } + function hasUdsLoginCookie() { + return !!(readCookie('PORTALSSOUser') || readCookie('ZTEDPGSSOUser') || readCookie('UDS_FALLBACK_USER')) + } + const LOCALE_NS = 'settings.dshCronTasks' const TITLE_PREFIX = '定时任务 · ' const listSnapshot = { @@ -518,6 +533,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba } async function api(path, options = {}) { + if (!hasUdsLoginCookie() && path !== '/health') { + throw new Error('登录后才能使用定时任务') + } const response = await fetch(`${API}${path}`, { ...options, headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }, @@ -1525,6 +1543,15 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const place = () => { const found = findNewSessionButton() if (!found) return + if (!hasUdsLoginCookie()) { + if (entry && entry.isConnected) entry.remove() + if (listRoot && listRoot.isConnected) listRoot.remove() + if (cronOn) { + cronOn = false + setCronMode(findSidebarRoot(found), false, entry, t) + } + return + } const sidebar = findSidebarRoot(found) const region = findRegionArea(sidebar) let anchor = found @@ -1612,6 +1639,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba } place() + const onAuth = () => { place() } + window.addEventListener('focus', onAuth) + window.addEventListener('uds-auth-changed', onAuth) let boots = 0 const boot = () => { boots += 1 @@ -1621,6 +1651,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba requestAnimationFrame(boot) const timer = setInterval(place, 4000) return () => { + window.removeEventListener('focus', onAuth) + window.removeEventListener('uds-auth-changed', onAuth) observer.disconnect() clearInterval(timer) if (placeRaf) cancelAnimationFrame(placeRaf) diff --git a/lib/host.js b/lib/host.js index 0eeb026..26efc8d 100644 --- a/lib/host.js +++ b/lib/host.js @@ -52,11 +52,50 @@ function parseUrl(req) { } } + +function parseCookieHeader(header, name) { + if (!header || typeof header !== 'string') return null + for (const part of header.split(';')) { + const idx = part.indexOf('=') + if (idx < 0) continue + if (part.slice(0, idx).trim() !== name) continue + try { + return decodeURIComponent(part.slice(idx + 1).trim()) + } catch { + return part.slice(idx + 1).trim() + } + } + return null +} + +/** Browser UDS / fallback login cookies (validated more strictly by uds-auth when present). */ +function browserEmpNo(request) { + const cookie = request?.headers?.cookie || '' + return parseCookieHeader(cookie, 'PORTALSSOUser') + || parseCookieHeader(cookie, 'ZTEDPGSSOUser') + || parseCookieHeader(cookie, 'UDS_FALLBACK_USER') +} + +function requireBrowserLogin(request, write) { + if (!isTrustedApiRequest(request)) { + write(403, { ok: false, error: 'forbidden' }) + return false + } + if (!browserEmpNo(request)) { + write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' }) + return false + } + return true +} + function isTrustedApiRequest(request) { const host = request.headers.host ?? '' if (!host) return false const hostname = host.split(':')[0].replace(/^\[|\]$/g, '') if ((request.headers['sec-fetch-site'] ?? '') === 'cross-site') return false + // Allow same-origin LAN / non-loopback Host (login still required separately). + const site = request.headers['sec-fetch-site'] ?? '' + if (site === 'same-origin' || site === 'same-site') return true const origin = request.headers.origin if (origin !== undefined && origin !== 'null') { try { @@ -391,14 +430,14 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/settings` && method === 'GET') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const state = await snapshot() write(200, { ok: true, settings: state.settings }) return } if (path === `${API_PREFIX}/settings` && method === 'PUT') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const body = await readJsonBody(req) const settings = await updateSettings(body) write(200, { ok: true, settings }) @@ -406,7 +445,7 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/models` && method === 'GET') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const catalog = typeof sessionPort?.listModels === 'function' ? await sessionPort.listModels() : { groups: [], current: null } @@ -415,7 +454,7 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/presets` && method === 'GET') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const catalog = typeof sessionPort?.listPresets === 'function' ? await sessionPort.listPresets() : { items: [], current: null } @@ -424,7 +463,7 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/workspaces` && method === 'GET') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const workspaces = typeof sessionPort?.listWorkspaces === 'function' ? await sessionPort.listWorkspaces() : [] @@ -433,7 +472,7 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/im-catalog` && method === 'GET') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const dshIm = getDshIm() if (!dshIm || typeof dshIm.listDeliveryCatalog !== 'function') { write(200, { @@ -463,14 +502,14 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/jobs` && method === 'GET') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const state = await snapshot() write(200, { ok: true, jobs: listJobs(state).map(jobView) }) return } if (path === `${API_PREFIX}/jobs` && method === 'POST') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const body = await readJsonBody(req) const job = await createJob(body) write(200, { ok: true, job }) @@ -482,14 +521,14 @@ export function createHostService(options = {}) { const jobId = decodeURIComponent(jobMatch[1]) const rest = jobMatch[2] || '' if (method === 'GET' && !rest) { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const state = await snapshot() const job = getJob(state, jobId) if (!job) return write(404, { ok: false, error: 'job not found' }) write(200, { ok: true, job: jobView(job) }) return } - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return if (method === 'PATCH' && !rest) { const body = await readJsonBody(req) const job = await updateJob(jobId, body) @@ -520,7 +559,7 @@ export function createHostService(options = {}) { const openMatch = path.match(new RegExp(`^${API_PREFIX}/runs/([^/]+)/open$`)) if (openMatch && method === 'POST') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const runId = decodeURIComponent(openMatch[1]) const state = await snapshot() const run = (state.runs || []).find((row) => row.id === runId) @@ -535,7 +574,7 @@ export function createHostService(options = {}) { const adoptMatch = path.match(new RegExp(`^${API_PREFIX}/sessions/([^/]+)/adopt$`)) if (adoptMatch && method === 'POST') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const sessionId = decodeURIComponent(adoptMatch[1]) if (!sessionId) return write(400, { ok: false, error: 'sessionId required' }) if (typeof sessionPort?.adoptSession !== 'function') { @@ -547,14 +586,14 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/conceal` && method === 'POST') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const hidden = await concealKnownSessions() write(200, { ok: true, hidden }) return } if (path === `${API_PREFIX}/history` && method === 'GET') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const state = await snapshot() const jobId = url.searchParams.get('jobId') || undefined write(200, { ok: true, runs: listHistory(state, jobId).map(runView) }) @@ -562,7 +601,7 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/preview` && method === 'POST') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const body = await readJsonBody(req) const settings = (await snapshot()).settings const schedule = validateSchedule(body.schedule || body, body.timezone || settings.timezone) @@ -572,7 +611,7 @@ export function createHostService(options = {}) { } if (path === `${API_PREFIX}/workspace-visible` && method === 'GET') { - if (!isTrustedApiRequest(req)) return write(403, { ok: false, error: 'forbidden' }) + if (!requireBrowserLogin(req, write)) return const state = await snapshot() const listed = url.searchParams.getAll('id') write(200, { diff --git a/package.json b/package.json index d4dd5c0..e47a203 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-ops-cron", "description": "Scheduled tasks for DeepSeek Harness: Agent cron_* tools + sidebar 定时任务, with DSH or WhatsApp/IM delivery per job.", - "version": "0.1.7", + "version": "0.1.8", "private": false, "type": "module", "repository": { diff --git a/test/host.test.js b/test/host.test.js index a77bd24..6c4f613 100644 --- a/test/host.test.js +++ b/test/host.test.js @@ -80,6 +80,8 @@ async function jsonRequest(base, path, options = {}) { accept: 'application/json', origin: base, host: new URL(base).host, + 'sec-fetch-site': 'same-origin', + cookie: options.anonymous ? '' : 'PORTALSSOUser=tester', ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers, }, @@ -191,6 +193,38 @@ test('overlap skip writes a skipped history row instead of a second session', as assert.equal(inflight, 1) }) + +test('http api: anonymous list/run-now is rejected', async (t) => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const service = createHostService({ + filePath: join(dir, 'store.json'), + now: () => Date.parse('2026-08-24T01:00:00.000Z'), + sessionPort: { + async createAndPrompt() { return { sessionId: 'x', status: 'succeeded', summary: 'ok' } }, + async archiveSession() {}, + }, + }) + await service.createJob({ + name: 'secret job', + prompt: 'do not leak', + schedule: { kind: 'cron', expr: '0 9 * * *', timezone: 'UTC' }, + }) + const http = await listen(service) + t.after(() => http.close()) + const listed = await jsonRequest(http.url, '/dsh-ops-cron/jobs', { anonymous: true }) + assert.equal(listed.status, 401) + assert.equal(listed.body.error, 'login_required') + const jobs = await jsonRequest(http.url, '/dsh-ops-cron/jobs') + assert.equal(jobs.status, 200) + const jobId = jobs.body.jobs[0].id + const ran = await jsonRequest(http.url, `/dsh-ops-cron/jobs/${jobId}/run`, { + method: 'POST', + anonymous: true, + }) + assert.equal(ran.status, 401) +}) + test('shipped HTTP handler: create, list, run-now, history', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-ops-cron-')) t.after(() => rm(dir, { recursive: true, force: true }))