From bcc2d036b39f8ea1a7cb20e9e760dffb3ca9d6c8 Mon Sep 17 00:00:00 2001 From: Shadowbroker <43977454+BigBodyCobain@users.noreply.github.com> Date: Wed, 20 May 2026 20:59:40 -0600 Subject: [PATCH] [security] Close tg12 auth-bypass chain #249, #254, #255 (#263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External audit by @tg12 found three coupled vulnerabilities in the Next.js admin-auth surface that together let any webpage the operator visits trigger arbitrary privileged backend calls: #249/#254 — Cross-origin webpages can have process.env.ADMIN_KEY injected into their forwarded backend requests just by issuing fetch('http://localhost:3000/api/wormhole/...') from a browser tab the operator has open. Full identity-takeover CSRF. #255 — When ADMIN_KEY is unset on the server (the default in .env.example), the admin session route fell through to GET /api/settings/privacy-profile to "verify" the user- supplied key. That endpoint is public; it always returns 200 for any X-Admin-Key value. So arbitrary attacker keys minted full admin session cookies on default installs. Both fixes preserve every legitimate UX path. Origin-header gating is transparent to browser tabs on the dashboard's own host, transparent to Tauri/native shells (no Origin), and transparent to server-to- server callers (no Origin). Only cross-origin browser fetches with a foreign Origin lose the injection. frontend/src/app/api/[...path]/route.ts Adds isSameOriginOrNonBrowser() — checks the Origin header against the request's own Host. Allow if no Origin (native/server-to- server), allow if Origin host == Host host (same-origin), reject otherwise. The admin-key injection now requires EITHER a valid session cookie (auth) OR same-origin-or-non-browser (CSRF guard). frontend/src/app/api/admin/session/route.ts verifyAdminKey() simplified to local-only string comparison. When ADMIN_KEY is configured, the supplied key must match exactly. When ADMIN_KEY is unset, minting is refused entirely with a clear message pointing the operator at the backend's auto-trust-loopback behavior (SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR=1, the Docker default — local users keep working without a session). The previous round-trip to /api/settings/privacy-profile was both the source of the bug AND useless on its own merits (the endpoint is public). Removing it makes the validation honest about what it's checking. Tests: frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts (new, 12) Cross-origin fetch to sensitive route → no admin-key injection Cross-origin POST to sensitive route → no admin-key injection Same-origin fetch → admin-key injection works No-Origin (server-to-server / native) → admin-key injection works Valid session cookie on cross-origin → cookie auth wins Malformed Origin → conservative reject Non-sensitive routes unaffected Mint with ADMIN_KEY unset → refused (no fetch happens) Empty key → 400 Mint with matching ADMIN_KEY → success Mint with mismatched key → 403 Mint never round-trips to the backend (local-only validation) frontend/src/__tests__/desktop/adminSessionBoundary.test.ts (updated) Three tests updated to reflect the new local-only validation contract. The previous tests asserted fetchMock.toHaveBeenCalled which validated the now-removed (and broken) backend round-trip. Full frontend suite: 707 passed, 72 files. No regressions. Credit: @tg12 for the report. The cross-origin CSRF angle was non-obvious — they specifically called out that the proxy's admin-key injection was an open door for any page running in the operator's browser, which is exactly the right framing. Co-authored-by: Claude Opus 4.7 --- .../desktop/adminSessionBoundary.test.ts | 50 +-- .../proxy/proxyAuthBypassChain.test.ts | 328 ++++++++++++++++++ frontend/src/app/api/[...path]/route.ts | 59 +++- frontend/src/app/api/admin/session/route.ts | 65 ++-- 4 files changed, 446 insertions(+), 56 deletions(-) create mode 100644 frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts diff --git a/frontend/src/__tests__/desktop/adminSessionBoundary.test.ts b/frontend/src/__tests__/desktop/adminSessionBoundary.test.ts index 7e8fc2e..68b8d8b 100644 --- a/frontend/src/__tests__/desktop/adminSessionBoundary.test.ts +++ b/frontend/src/__tests__/desktop/adminSessionBoundary.test.ts @@ -45,12 +45,12 @@ describe('admin/session boundary hardening', () => { }); it('accepts a verified admin key and reports the minted session as present', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); + // Issue #255 fix: the route no longer round-trips to the backend + // to "verify" the key (the previous implementation called a public + // endpoint that always returned 200, so any key was accepted when + // ADMIN_KEY was unset). Local string comparison is the only + // validation, so we don't mock fetch and don't assert it was called. + const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const req = new NextRequest('http://localhost/api/admin/session', { @@ -65,7 +65,8 @@ describe('admin/session boundary hardening', () => { expect(res.status).toBe(200); expect(cookie).toContain('sb_admin_session='); expect(res.headers.get('cache-control')).toContain('no-store'); - expect(fetchMock).toHaveBeenCalledTimes(1); + // Validation is local-only — no backend round-trip should happen. + expect(fetchMock).not.toHaveBeenCalled(); const getReq = new NextRequest('http://localhost/api/admin/session', { method: 'GET', @@ -88,12 +89,8 @@ describe('admin/session boundary hardening', () => { }); it('invalidates the previous admin session token when a new one is minted', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); + // Issue #255 fix: no backend round-trip. Validation is local-only. + const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const firstReq = new NextRequest('http://localhost/api/admin/session', { @@ -135,21 +132,25 @@ describe('admin/session boundary hardening', () => { ); const newBody = await newSessionCheck.json(); expect(newBody.hasSession).toBe(true); - expect(fetchMock).toHaveBeenCalledTimes(2); + // Local validation only — backend should not be called during minting. + expect(fetchMock).not.toHaveBeenCalled(); }); - it('rejects session minting when frontend admin key is set but backend has no configured admin key', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ detail: 'Forbidden — admin key not configured' }), { - status: 403, - headers: { 'Content-Type': 'application/json' }, - }), - ); + it('refuses session minting when frontend ADMIN_KEY env var is unset (#255)', async () => { + // Issue #255 (tg12): previously, when ADMIN_KEY was unset the route + // fell through to a public backend endpoint that always returned + // 200, so any user-supplied key minted a full admin session. The + // fix is to refuse minting entirely when ADMIN_KEY is unconfigured + // and surface a clear message pointing the operator at the + // backend's auto-trust-loopback behavior. + process.env.ADMIN_KEY = ''; + + const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const req = new NextRequest('http://localhost/api/admin/session', { method: 'POST', - body: JSON.stringify({ adminKey: 'top-secret' }), + body: JSON.stringify({ adminKey: 'any-key-an-attacker-supplies' }), headers: { 'Content-Type': 'application/json' }, }); @@ -158,8 +159,11 @@ describe('admin/session boundary hardening', () => { expect(res.status).toBe(403); expect(body.ok).toBe(false); - expect(body.detail).toBe('Forbidden — admin key not configured'); + expect(String(body.detail)).toMatch(/no admin key configured/i); expect(res.headers.get('set-cookie')).toBeNull(); + // Crucially: no backend round-trip happens. The previous broken + // verifyAgainstBackend() call must NOT be re-introduced. + expect(fetchMock).not.toHaveBeenCalled(); }); it('does not forward raw x-admin-key headers through the sensitive proxy path', async () => { diff --git a/frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts b/frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts new file mode 100644 index 0000000..99d46af --- /dev/null +++ b/frontend/src/__tests__/proxy/proxyAuthBypassChain.test.ts @@ -0,0 +1,328 @@ +/** + * Regression coverage for the auth-bypass chain audited by @tg12 in + * issues #249, #254, and #255. + * + * #249 / #254 — Cross-origin webpages must not have the operator's + * server-side ADMIN_KEY injected into their forwarded requests. The + * proxy enforces a CSRF guard by checking the Origin header against + * the request's own Host header. Same-origin (the dashboard itself), + * Tauri/native shells (no Origin), and authenticated session cookies + * are all allowed; cross-origin browser fetches with a foreign Origin + * are rejected. + * + * #255 — Admin session minting must require ADMIN_KEY to be configured + * AND the supplied key to match exactly. The previous implementation + * round-tripped to a public backend endpoint (/api/settings/privacy- + * profile) which always returns 200, so any key value would mint a + * full admin session when ADMIN_KEY was unset on the server. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +import { GET as proxyGet, POST as proxyPost } from '@/app/api/[...path]/route'; +import { POST as postAdminSession } from '@/app/api/admin/session/route'; + + +function capturedHeaders(fetchMock: ReturnType): Headers { + const forwarded = fetchMock.mock.calls[0]?.[1]; + return new Headers((forwarded as RequestInit | undefined)?.headers); +} + + +describe('proxy CSRF guard on admin-key injection (#249/#254)', () => { + const ADMIN_KEY = 'env-side-admin-key-32-chars-min!!!!!'; + const originalAdminKey = process.env.ADMIN_KEY; + const originalBackendUrl = process.env.BACKEND_URL; + + beforeEach(() => { + process.env.ADMIN_KEY = ADMIN_KEY; + process.env.BACKEND_URL = 'http://127.0.0.1:8000'; + vi.restoreAllMocks(); + }); + + afterEach(() => { + process.env.ADMIN_KEY = originalAdminKey; + process.env.BACKEND_URL = originalBackendUrl; + vi.restoreAllMocks(); + }); + + it('cross-origin GET to a sensitive route does NOT inject X-Admin-Key', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + // Hostile-webpage CSRF: Origin is a different site than Host. + const req = new NextRequest('http://localhost:3000/api/wormhole/identity', { + method: 'GET', + headers: { + host: 'localhost:3000', + origin: 'http://evil.example', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['wormhole', 'identity'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull(); + }); + + it('cross-origin POST to a sensitive route does NOT inject X-Admin-Key', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost:3000/api/wormhole/identity/bootstrap', { + method: 'POST', + body: '{}', + headers: { + host: 'localhost:3000', + origin: 'http://attacker.example', + 'content-type': 'application/json', + }, + }); + await proxyPost(req, { + params: Promise.resolve({ path: ['wormhole', 'identity', 'bootstrap'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull(); + }); + + it('same-origin request (Origin matches Host) DOES inject X-Admin-Key', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost:3000/api/wormhole/identity', { + method: 'GET', + headers: { + host: 'localhost:3000', + origin: 'http://localhost:3000', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['wormhole', 'identity'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + + it('no Origin header (native shell, server-to-server, curl) DOES inject X-Admin-Key', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost:3000/api/settings/wormhole', { + method: 'GET', + headers: { + host: 'localhost:3000', + // no Origin + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['settings', 'wormhole'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + + it('cross-origin request with a valid session cookie STILL injects (cookie auth wins)', async () => { + // Mint a session first (against the real handler). + const mintReq = new NextRequest('http://localhost:3000/api/admin/session', { + method: 'POST', + body: JSON.stringify({ adminKey: ADMIN_KEY }), + headers: { + host: 'localhost:3000', + 'content-type': 'application/json', + }, + }); + const mintRes = await postAdminSession(mintReq); + const cookieHeader = mintRes.headers.get('set-cookie') || ''; + const cookie = cookieHeader.split(';')[0]; + + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + // Now hit a sensitive route from a foreign Origin but WITH the cookie. + // Since the cookie itself is SameSite=strict, a real cross-origin + // browser fetch wouldn't carry it — but if the operator deliberately + // forwards their session (e.g. CLI tool), it should work. + const req = new NextRequest('http://localhost:3000/api/wormhole/identity', { + method: 'GET', + headers: { + host: 'localhost:3000', + origin: 'http://evil.example', + cookie, + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['wormhole', 'identity'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY); + }); + + it('malformed Origin header is treated as not-same-origin (conservative)', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost:3000/api/wormhole/identity', { + method: 'GET', + headers: { + host: 'localhost:3000', + origin: 'not-a-real-origin', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['wormhole', 'identity'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull(); + }); + + it('cross-origin to a non-sensitive route is unaffected (no injection either way)', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + // /api/health is not sensitive — no admin-key injection happens at all. + const req = new NextRequest('http://localhost:3000/api/health', { + method: 'GET', + headers: { + host: 'localhost:3000', + origin: 'http://evil.example', + }, + }); + await proxyGet(req, { + params: Promise.resolve({ path: ['health'] }), + }); + + expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBeNull(); + }); +}); + + +describe('admin session minting refuses arbitrary keys when ADMIN_KEY unset (#255)', () => { + const originalAdminKey = process.env.ADMIN_KEY; + const originalBackendUrl = process.env.BACKEND_URL; + + beforeEach(() => { + delete process.env.ADMIN_KEY; + process.env.BACKEND_URL = 'http://127.0.0.1:8000'; + vi.restoreAllMocks(); + }); + + afterEach(() => { + process.env.ADMIN_KEY = originalAdminKey; + process.env.BACKEND_URL = originalBackendUrl; + vi.restoreAllMocks(); + }); + + it('refuses to mint a session when ADMIN_KEY is unset on the server', async () => { + // Even if the (previously-relied-on) public endpoint returned 200, + // the new logic must not accept the key — it does local validation only. + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost:3000/api/admin/session', { + method: 'POST', + body: JSON.stringify({ adminKey: 'literally-anything-an-attacker-sends' }), + headers: { 'content-type': 'application/json' }, + }); + const res = await postAdminSession(req); + + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.ok).toBe(false); + expect(String(body.detail)).toMatch(/no admin key configured/i); + + // No session cookie should have been set + expect(res.headers.get('set-cookie')).toBeNull(); + + // The buggy round-trip to the public endpoint must no longer happen + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses an empty key with 400 (Missing admin key)', async () => { + const req = new NextRequest('http://localhost:3000/api/admin/session', { + method: 'POST', + body: JSON.stringify({ adminKey: '' }), + headers: { 'content-type': 'application/json' }, + }); + const res = await postAdminSession(req); + expect(res.status).toBe(400); + }); +}); + + +describe('admin session minting still works when ADMIN_KEY is set (#255 regression)', () => { + const ADMIN_KEY = 'configured-admin-key-32-chars-min!!!!'; + const originalAdminKey = process.env.ADMIN_KEY; + const originalBackendUrl = process.env.BACKEND_URL; + + beforeEach(() => { + process.env.ADMIN_KEY = ADMIN_KEY; + process.env.BACKEND_URL = 'http://127.0.0.1:8000'; + vi.restoreAllMocks(); + }); + + afterEach(() => { + process.env.ADMIN_KEY = originalAdminKey; + process.env.BACKEND_URL = originalBackendUrl; + vi.restoreAllMocks(); + }); + + it('mints a session when the supplied key matches the configured ADMIN_KEY', async () => { + const req = new NextRequest('http://localhost:3000/api/admin/session', { + method: 'POST', + body: JSON.stringify({ adminKey: ADMIN_KEY }), + headers: { 'content-type': 'application/json' }, + }); + const res = await postAdminSession(req); + + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toBeTruthy(); + }); + + it('rejects a non-matching key with 403', async () => { + const req = new NextRequest('http://localhost:3000/api/admin/session', { + method: 'POST', + body: JSON.stringify({ adminKey: 'wrong-key-attempted-by-attacker' }), + headers: { 'content-type': 'application/json' }, + }); + const res = await postAdminSession(req); + + expect(res.status).toBe(403); + expect(res.headers.get('set-cookie')).toBeNull(); + }); + + it('does NOT round-trip to a backend endpoint for verification (local-only validation)', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + const req = new NextRequest('http://localhost:3000/api/admin/session', { + method: 'POST', + body: JSON.stringify({ adminKey: ADMIN_KEY }), + headers: { 'content-type': 'application/json' }, + }); + await postAdminSession(req); + + // The previous implementation did a fetch to verify against the + // backend; the fix removes that round-trip because the backend + // endpoint it called was public anyway. Local string-compare suffices. + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 2f0df18..ba8cfd0 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -77,6 +77,48 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean { return false; } +/** + * CSRF guard for the server-side admin-key injection (issues #249 / #254). + * + * The proxy injects ``process.env.ADMIN_KEY`` into the forwarded + * X-Admin-Key header for sensitive backend routes. Without an origin + * check, any cross-origin webpage the operator visits could fire + * ``fetch('http://localhost:3000/api/wormhole/identity/bootstrap')`` and + * have that request get the operator's admin key injected for free — + * full identity-takeover CSRF. + * + * We allow injection when ANY of these is true: + * - The request carries a valid admin session cookie (already auth'd) + * - The Origin header is absent (server-to-server fetch, Tauri/Electron + * native shells, curl/cli — none of these are browser-CSRF surfaces) + * - The Origin header host matches the request's own Host (genuine + * same-origin browser fetch from our own dashboard) + * + * If Origin is present AND doesn't match Host, the caller is a hostile + * cross-origin webpage. We refuse to inject the admin key. The backend + * then sees the request without auth and rejects it via + * require_local_operator — exactly the desired outcome. + */ +function isSameOriginOrNonBrowser(req: NextRequest): boolean { + const origin = req.headers.get('origin'); + if (!origin) { + // No Origin header = server-to-server / native shell / older browser + // doing a same-origin GET. CSRF requires the attacker to control a + // page running in a browser, which always sends Origin on the + // dangerous methods. Treat missing Origin as not-CSRF. + return true; + } + try { + const originUrl = new URL(origin); + const host = req.headers.get('host') || ''; + if (!host) return false; + return originUrl.host.toLowerCase() === host.toLowerCase(); + } catch { + // Malformed Origin header — be conservative. + return false; + } +} + async function proxy(req: NextRequest, pathSegments: string[]): Promise { try { const isMesh = pathSegments[0] === 'mesh'; @@ -192,8 +234,23 @@ async function proxy(req: NextRequest, pathSegments: string[]): Promise { - const backendUrl = process.env.BACKEND_URL ?? 'http://127.0.0.1:8000'; - const verifyAgainstBackend = async (): Promise< - { ok: true } | { ok: false; detail: string } - > => { - try { - const res = await fetch(`${backendUrl}/api/settings/privacy-profile`, { - method: 'GET', - headers: { 'X-Admin-Key': adminKey }, - cache: 'no-store', - }); - if (res.ok) return { ok: true }; - const data = await res.json().catch(() => ({})); - return { - ok: false, - detail: String(data?.detail || data?.message || 'Unable to verify admin key'), - }; - } catch { - return { - ok: false, - detail: 'Unable to verify admin key against backend', - }; - } - }; - +/** + * Verify an operator-supplied admin key before minting a session cookie. + * + * Issue #255: the previous implementation, when ADMIN_KEY was unset on + * the server, fell through to verifying against the backend by GET-ing + * /api/settings/privacy-profile. That endpoint is public — it returns + * 200 for any X-Admin-Key value (or none at all) — so the fallback + * accepted *arbitrary* keys and minted full admin sessions for them. + * + * Fix: require ADMIN_KEY to be configured before any session can be + * minted, and do the validation locally instead of round-tripping to a + * potentially-public endpoint. If ADMIN_KEY is unset, the backend + * already auto-trusts loopback / docker-bridge callers via + * require_local_operator + SHADOWBROKER_TRUST_DOCKER_BRIDGE_LOCAL_OPERATOR, + * so legitimate local users keep working — they just don't get (and + * don't need) a privileged session cookie. + */ +async function verifyAdminKey( + adminKey: string, +): Promise<{ ok: true } | { ok: false; detail: string }> { const configuredAdmin = String(process.env.ADMIN_KEY || '').trim(); - if (configuredAdmin) { - if (adminKey !== configuredAdmin) { - return { ok: false, detail: 'Invalid admin key' }; - } - return verifyAgainstBackend(); + if (!configuredAdmin) { + return { + ok: false, + detail: + 'No admin key configured on the server. Local-host requests are ' + + 'already auto-trusted by the backend — no session is needed. ' + + 'To enable session-based admin auth, set ADMIN_KEY in the backend ' + + 'environment and restart.', + }; } - - return verifyAgainstBackend(); + if (adminKey !== configuredAdmin) { + return { ok: false, detail: 'Invalid admin key' }; + } + return { ok: true }; } export async function POST(req: NextRequest) {