Skip to content

P0: close unauthenticated /api/operator/* + drop client-trusted approval - #33

Open
EpicStarAi wants to merge 2 commits into
backup/operator-ui-account-fetchfrom
fix/operator-auth-guard
Open

P0: close unauthenticated /api/operator/* + drop client-trusted approval#33
EpicStarAi wants to merge 2 commits into
backup/operator-ui-account-fetchfrom
fix/operator-auth-guard

Conversation

@EpicStarAi

Copy link
Copy Markdown
Owner

P0 security fix — operator bridge auth gate

The entire /api/operator/* branch was outside the auth gate. Only qclaw/status checked a session; [...path] (verbatim backend proxy), command, confirm, and schedule were reachable with no cookie. command/schedule took accountId from the request body, and schedule forwarded a hardcoded operatorApproved: true.

Changes

  • middleware: matcher now covers /api/operator/:path* (no-store + surface coverage).
  • telegramGuard: restored the binding-backed resolveBoundAccount — this backup branch had regressed it to a null stub, so the tree did not compile.
  • [...path]: getPrincipal() → 401 on GET+POST (covers /status, /production/*, /ops/*, /accounts/*, … all served by this catch-all).
  • command / schedule: account resolved SERVER-SIDE via resolveBoundAccount; client accountId ignored; no owner-matched binding → 403.
  • confirm / schedule: enforce telegramMutationsEnabled() → 403 when false.
  • schedule: operatorApproved is server-set and only reachable AFTER the auth+mutation+binding gate (mirrors /api/telegram/binding/send); never from the body.
  • OperatorOffice: removed fictional NOVIKOVA 💋 / authorizationStateReady / TDLib 1.8.64; renders honest "account not connected" from live data.

Verification

  • build ✓ · typecheck ✓ · lint ✓ (exit 0) · tests ✓ 5/5 (incl. "forged operatorApproved rejected server-side").
  • Live on candidate build: no cookie & invalid cookie → 401; /login200; no-store applied to operator responses.

Owner-isolation gate resolveBoundAccount verified line-by-line: DB-only by principal workspace, authState=ready only, client accountId ignored, forbidden ids (main/novikova/7369372055) rejected, workspace mismatch → mismatch.

🤖 Generated with Claude Code

EpicStarAi and others added 2 commits July 19, 2026 18:25
…usted approval

P0: the entire /api/operator/* branch was outside the auth gate. Only
qclaw/status checked a session; [...path] (verbatim backend proxy),
command, confirm and schedule were reachable with no cookie, and
command/schedule took accountId from the request body while schedule
forwarded a hardcoded operatorApproved:true.

- middleware matcher now covers /api/operator/:path* (no-store + surface).
- Restore binding-backed resolveBoundAccount in telegramGuard (this backup
  branch had regressed it to a null stub, so the tree did not compile).
- getPrincipal() -> 401 in [...path], command, confirm, schedule.
- command/schedule resolve the account SERVER-SIDE via resolveBoundAccount;
  client accountId is ignored; no owner-matched binding -> 403.
- confirm/schedule enforce telegramMutationsEnabled() -> 403 when false.
- schedule's operatorApproved is server-set and only reachable AFTER the
  auth+mutation+binding gate (mirrors /api/telegram/binding/send); never
  taken from the body.
- OperatorOffice: drop the fictional NOVIKOVA / authorizationStateReady /
  TDLib 1.8.64 identity; render honest "account not connected" from live
  qclaw/status data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ator matcher

The prod release (qclaw-release-20260717-1130) carries a public-host redirect
(PUBLIC_REDIRECT_HOSTS / clonePublicRedirectUrl) that the backup base branch
lacked. Rebase the middleware on the live prod version and add ONLY the
/api/operator/:path* matcher entry, so the deploy does not regress the
/client -> epic-gram.com login redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f8fc938d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

export async function resolveBoundAccount(principal: Principal): Promise<BoundResolution> {
let binding;
try {
binding = await bindingsDb.getByWorkspace(principal.workspaceId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail closed when binding DB lookup fails

In production with DATABASE_URL set, this lookup does not actually fail closed: telegramBindingsDb.getByWorkspace() catches Postgres errors and falls through to the filesystem store (apps/web/lib/telegramBindingsDb.ts:116-125), so a DB outage can authorize /api/telegram/* and the new operator gates from a stale .telegram-bindings.json row instead of denying as this guard's catch block implies. For the authz gate, use a DB-only lookup or make the DB adapter propagate errors when DATABASE_URL is configured.

Useful? React with 👍 / 👎.

Comment on lines +33 to +37
const bound = await resolveBoundAccount(principal);
if (bound.kind !== "ok") {
return NextResponse.json(
{ ok: false, ownerMatched: bound.kind !== "mismatch", executed: false, message: "К вашему профилю не привязан owner-matched Telegram-аккаунт." },
{ status: 403, headers: H }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the bound account for confirmations

When TELEGRAM_MUTATION=true, this route verifies that the caller has some ready binding but then forwards the original body unchanged; the backend confirm path reads the account from accountId || action.accountId in services/api/src/operator-agent.mjs:161-164. An authenticated user with any binding can therefore confirm a payload that names another slot in deployments where confirms are executable, bypassing the server-resolved account check. Rebuild the forwarded JSON with bound.accountId or reject mismatched IDs before proxying.

Useful? React with 👍 / 👎.

Comment on lines +182 to +184
const id = binding.tdlibAccountId;
// Never serve the shared legacy NOVIKOVA / main slot behind a login.
if (!id || isForbiddenAccountId(id)) return { kind: "mismatch" };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize account id before forbidden check

This guard compares the raw DB value to the forbidden IDs without trimming, while the backend normalizes account IDs with String(accountId || "main").trim() before selecting the TDLib slot (services/api/src/telegram-runtime.mjs:45-48). If a legacy/imported binding stores tdlibAccountId as "main " or "\t7369372055", this check returns ok and later operator schedule/confirm sends are normalized back onto the forbidden shared slot. Trim/canonicalize the id before isForbiddenAccountId() and before returning it.

Useful? React with 👍 / 👎.

Comment on lines 39 to 41
export async function GET(req: NextRequest, ctx: { params: { path: string[] } }) {
if (!(await getPrincipal())) return unauthorized();
return forward(req, ctx.params.path, "GET");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve admin status after adding session gate

When this catch-all now rejects requests without an epic_session, it also rejects the admin health check at apps/web/app/admin/page.tsx:47-52; /api/admin/login only returns {ok} and does not set that cookie (apps/web/app/api/admin/login/route.ts:36-40). An operator who successfully enters the admin password but has not separately gone through referral login will therefore always see the backend status as down. Either issue the expected session/admin cookie or use a status endpoint that matches the admin auth flow.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant