Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions controlplane/src/auth/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ export async function checkRecipеAccess(
return { hasAccess: false };
}

// Recipes without dashboard_id are accessible to any authenticated user
// Dashboard-less ("global") recipes are scoped to their creator, not to every
// authenticated user. Legacy rows with a null created_by are inaccessible
// (fail-closed).
if (!recipe.dashboard_id) {
return { hasAccess: true, recipe };
const hasAccess = Boolean(recipe.created_by) && recipe.created_by === userId;
return { hasAccess, recipe: hasAccess ? recipe : undefined };
}

const { hasAccess } = await checkDashbоardAccess(env, recipe.dashboard_id as string, userId, requiredRole);
Expand Down
16 changes: 16 additions & 0 deletions controlplane/src/dashboards/DurableObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,22 @@ export class DashboardDO implements DurableObject {
});
}

// Purge durable state + sockets on dashboard delete (DOs can't be swept later).
if (path === '/destroy' && request.method === 'POST') {
try {
for (const ws of this.state.getWebSockets()) {
try { ws.close(1001, 'dashboard deleted'); } catch { /* already closing */ }
}
} catch { /* no sockets */ }
this.dashboard = null;
this.items.clear();
this.terminalSessions.clear();
this.edges.clear();
this.presence.clear();
await this.state.storage.deleteAll();
return Response.json({ success: true });
}

if (path === '/item' && request.method === 'PUT') {
const item = await request.json() as DashboardItem;
this.items.set(item.id, item);
Expand Down
10 changes: 10 additions & 0 deletions controlplane/src/dashboards/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,16 @@ export async function deleteDashbоard(
// form_responses, terminal_integrations)
await env.DB.prepare(`DELETE FROM dashboards WHERE id = ?`).bind(dashboardId).run();

// Purge the DO after D1 is gone (D1 delete doesn't touch it, and DOs can't be
// swept later). Best-effort.
try {
const doId = env.DASHBOARD.idFromName(dashboardId);
const stub = env.DASHBOARD.get(doId);
await stub.fetch(new Request('http://do/destroy', { method: 'POST' }));
} catch (e) {
console.error(`[deleteDashboard] DashboardDO purge failed for ${dashboardId}: ${e}`);
}

return new Response(null, { status: 204 });
}

Expand Down
11 changes: 11 additions & 0 deletions controlplane/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,11 +645,13 @@ CREATE TABLE IF NOT EXISTS recipes (
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
steps TEXT NOT NULL DEFAULT '[]',
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_recipes_dashboard ON recipes(dashboard_id);
CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes(created_by);

-- Executions (workflow runs)
CREATE TABLE IF NOT EXISTS executions (
Expand Down Expand Up @@ -1193,6 +1195,15 @@ export async function initializeDatabase(db: D1Database): Promise<void> {
// Column already exists.
}

// Owner column so checkRecipeAccess can scope dashboard-less recipes to their creator.
try {
await db.prepare(`
ALTER TABLE recipes ADD COLUMN created_by TEXT
`).run();
} catch {
// Column already exists.
}

try {
await db.prepare(`
ALTER TABLE dashboards ADD COLUMN cloud_id TEXT
Expand Down
46 changes: 29 additions & 17 deletions controlplane/src/egress/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ export async function handleApproveEgress(
);
}

// If "always allow", persist to the allowlist only after sandbox accepted.
// Allowlist only if no active deny exists (deny precedence); with the atomic
// deny batch below this stops a racing allow/deny landing in both lists.
if (body.decision === 'allow_always') {
const entryId = generateId();
await env.DB.prepare(`
Expand All @@ -128,26 +129,35 @@ export async function handleApproveEgress(
SELECT 1 FROM egress_allowlist
WHERE dashboard_id = ? AND domain = ? AND revoked_at IS NULL
)
`).bind(entryId, dashboardId, normalizedDomain, userId, dashboardId, normalizedDomain).run();
AND NOT EXISTS (
SELECT 1 FROM egress_blocklist
WHERE dashboard_id = ? AND domain = ? AND revoked_at IS NULL
)
`).bind(
entryId, dashboardId, normalizedDomain, userId,
dashboardId, normalizedDomain,
dashboardId, normalizedDomain,
).run();
}

// If "deny always", persist to the blocklist and revoke any conflicting allowlist
// entry for the same domain so the deny is unambiguous (deny wins).
// If "deny always", revoke any conflicting allowlist entry and persist to the
// blocklist as one atomic batch (deny wins).
if (body.decision === 'deny_always') {
await env.DB.prepare(`
UPDATE egress_allowlist SET revoked_at = datetime('now')
WHERE dashboard_id = ? AND domain = ? AND revoked_at IS NULL
`).bind(dashboardId, normalizedDomain).run();

const entryId = generateId();
await env.DB.prepare(`
INSERT INTO egress_blocklist (id, dashboard_id, domain, created_by, created_at)
SELECT ?, ?, ?, ?, datetime('now')
WHERE NOT EXISTS (
SELECT 1 FROM egress_blocklist
await env.DB.batch([
env.DB.prepare(`
UPDATE egress_allowlist SET revoked_at = datetime('now')
WHERE dashboard_id = ? AND domain = ? AND revoked_at IS NULL
)
`).bind(entryId, dashboardId, normalizedDomain, userId, dashboardId, normalizedDomain).run();
`).bind(dashboardId, normalizedDomain),
env.DB.prepare(`
INSERT INTO egress_blocklist (id, dashboard_id, domain, created_by, created_at)
SELECT ?, ?, ?, ?, datetime('now')
WHERE NOT EXISTS (
SELECT 1 FROM egress_blocklist
WHERE dashboard_id = ? AND domain = ? AND revoked_at IS NULL
)
`).bind(entryId, dashboardId, normalizedDomain, userId, dashboardId, normalizedDomain),
]);
}

// Log the decision after successful sandbox forward.
Expand Down Expand Up @@ -582,7 +592,9 @@ export async function handleListEgressAudit(
dashboardId: string,
): Promise<Response> {
const url = new URL(request.url);
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
// Guard against a NaN limit (parseInt('abc')) binding non-finite into D1.
const parsedLimit = parseInt(url.searchParams.get('limit') || '50', 10);
const limit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 200) : 50;

const entries = await env.DB.prepare(`
SELECT id, domain, port, decision, decided_by, created_at
Expand Down
19 changes: 17 additions & 2 deletions controlplane/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2026 Rob Macrae. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Proprietary
// REVISION: controlplane-v17-pty-ws-edge-debug
console.log(`[controlplane] REVISION: controlplane-v17-pty-ws-edge-debug loaded at ${new Date().toISOString()}`);
// REVISION: controlplane-v18-idor-input-validation
console.log(`[controlplane] REVISION: controlplane-v18-idor-input-validation loaded at ${new Date().toISOString()}`);

/**
* OrcaBot Control Plane - Cloudflare Worker Entry Point
Expand Down Expand Up @@ -531,6 +531,15 @@ export default {
{ status: 501 }
), origin, allowedOrigins);
}
// Malformed request body (SyntaxError from request.json()) is client error →
// 400, not 500. Logged so a stray internal parse error isn't silent.
if (error instanceof SyntaxError) {
console.warn('Request body parse error (400):', error.message);
return cоrsRespоnse(Response.json(
{ error: 'E40001: Invalid JSON body' },
{ status: 400 }
), origin, allowedOrigins);
}
console.error('Request error:', error);
return cоrsRespоnse(Response.json(
{ error: error instanceof Error ? error.message : 'Internal server error' },
Expand Down Expand Up @@ -1517,6 +1526,9 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick<E
const authError = requireAuth(auth);
if (authError) return authError;
const data = await request.json() as { sourceItemId: string; targetItemId: string; sourceHandle?: string; targetHandle?: string };
if (typeof data?.sourceItemId !== 'string' || typeof data?.targetItemId !== 'string' || !data.sourceItemId || !data.targetItemId) {
return Response.json({ error: 'E40002: sourceItemId and targetItemId are required' }, { status: 400 });
}
return dashboards.createEdge(env, segments[1], auth.user!.id, data);
}

Expand Down Expand Up @@ -1762,6 +1774,9 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick<E
const authError = requireAuth(auth);
if (authError) return authError;
const data = await request.json() as { email: string; role: 'editor' | 'viewer' };
if (typeof data?.email !== 'string' || !data.email.trim()) {
return Response.json({ error: 'E40003: email is required' }, { status: 400 });
}
return members.addMember(env, segments[1], auth.user!.id, data);
}

Expand Down
12 changes: 10 additions & 2 deletions controlplane/src/integration-policies/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2203,13 +2203,17 @@ export async function getAuditLog(
return Response.json({ error: 'E79735: Terminal not found or does not belong to this dashboard' }, { status: 404 });
}

// Clamp limit/offset to a finite, bounded page (a NaN would 500 on the D1 bind).
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 0), 1000) : 100;
const safeOffset = Number.isFinite(offset) ? Math.max(Math.trunc(offset), 0) : 0;

const rows = await env.DB.prepare(`
SELECT * FROM integration_audit_log
WHERE dashboard_id = ? AND terminal_id = ? AND provider = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`)
.bind(dashboardId, terminalId, provider, limit, offset)
.bind(dashboardId, terminalId, provider, safeLimit, safeOffset)
.all();

return Response.json({
Expand Down Expand Up @@ -2242,13 +2246,17 @@ export async function getDashboardAuditLog(
return Response.json({ error: 'E79734: Not found or no access' }, { status: 404 });
}

// Clamp limit/offset to a finite, bounded page (a NaN would 500 on the D1 bind).
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 0), 1000) : 100;
const safeOffset = Number.isFinite(offset) ? Math.max(Math.trunc(offset), 0) : 0;

const rows = await env.DB.prepare(`
SELECT * FROM integration_audit_log
WHERE dashboard_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`)
.bind(dashboardId, limit, offset)
.bind(dashboardId, safeLimit, safeOffset)
.all();

return Response.json({
Expand Down
18 changes: 15 additions & 3 deletions controlplane/src/members/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,15 @@ export async function addMember(
const now = new Date();
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days

await env.DB.prepare(`
// Atomic insert (the pending check above is check-then-act and races). Only one
// of a concurrent pair inserts; the other's WHERE NOT EXISTS makes changes 0.
const inserted = await env.DB.prepare(`
INSERT INTO dashboard_invitations (id, dashboard_id, email, role, invited_by, token, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
SELECT ?, ?, ?, ?, ?, ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM dashboard_invitations
WHERE dashboard_id = ? AND LOWER(email) = LOWER(?) AND accepted_at IS NULL
)
`).bind(
invitationId,
dashboardId,
Expand All @@ -256,9 +262,15 @@ export async function addMember(
userId,
token,
now.toISOString(),
expiresAt.toISOString()
expiresAt.toISOString(),
dashboardId,
email
).run();

if (!inserted.meta.changes) {
return Response.json({ error: 'E79066: An invitation has already been sent to this email' }, { status: 400 });
}

// Send invitation email
const acceptUrl = `${getFrontendUrl(env)}/login?invite=${token}`;
try {
Expand Down
24 changes: 21 additions & 3 deletions controlplane/src/messaging/webhook-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1254,10 +1254,13 @@ async function processSubscriptionMessage(
);
}

// Clear the timeout when the resolves win, so it doesn't linger holding its closure.
let resolveTimer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
Promise.allSettled(resolvePromises),
new Promise<void>(resolve => setTimeout(resolve, RESOLVE_TIMEOUT_MS)),
new Promise<void>(resolve => { resolveTimer = setTimeout(resolve, RESOLVE_TIMEOUT_MS); }),
]);
if (resolveTimer !== undefined) clearTimeout(resolveTimer);
}

// Enforce subscription channel scoping (Telegram uses chatId, Slack/Discord use channelId).
Expand Down Expand Up @@ -1891,12 +1894,19 @@ export async function createSubscription(
}
}

await env.DB.prepare(`
// Atomic guard for the Telegram "one active sub per user" invariant (the check
// above races). The `? = 'telegram'` term makes it a no-op for other providers.
const subInsert = await env.DB.prepare(`
INSERT INTO messaging_subscriptions (
id, dashboard_id, item_id, user_id, provider,
channel_id, channel_name, chat_id, team_id,
webhook_id, webhook_secret, status, user_integration_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)
)
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?
WHERE NOT EXISTS (
SELECT 1 FROM messaging_subscriptions
WHERE user_id = ? AND provider = 'telegram' AND status IN ('pending', 'active') AND ? = 'telegram'
)
`).bind(
id, dashboardId, itemId, userId, provider,
data.channelId || null,
Expand All @@ -1906,8 +1916,16 @@ export async function createSubscription(
webhookId,
webhookSecret,
resolvedIntegrationId,
userId, provider,
).run();

if (!subInsert.meta.changes && provider === 'telegram') {
throw new Error(
`Only one active Telegram subscription per bot is allowed. ` +
`Delete the existing one first to create a new one.`
);
}

// For Discord: register /orcabot slash command as a guild command (instant availability).
// Resolve guild_id from the integration metadata or by looking up the channel.
if (provider === 'discord' && resolvedIntegrationId) {
Expand Down
18 changes: 11 additions & 7 deletions controlplane/src/recipes/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,19 @@ describe('Recipe Handlers', () => {
});

describe('Access control', () => {
it('should allow access to recipes without dashboard_id', async () => {
// Recipe without dashboard is accessible to any authenticated user
const recipe = await seedRecipe(ctx.db, { name: 'Global Recipe' });
it('should scope dashboard-less recipes to their owner', async () => {
// A global (no dashboard) recipe is accessible to its CREATOR...
const recipe = await seedRecipe(ctx.db, { name: 'Global Recipe', createdBy: testUser.id });

const response = await getRecipе(ctx.env, recipe.id, testUser.id);

expect(response.status).toBe(200);
const data = await response.json() as Record<string, any>;
const ownerResponse = await getRecipе(ctx.env, recipe.id, testUser.id);
expect(ownerResponse.status).toBe(200);
const data = await ownerResponse.json() as Record<string, any>;
expect(data.recipe.name).toBe('Global Recipe');

// ...but NOT to a different user. Previously this returned the recipe to
// anyone (cross-tenant IDOR); it must now 404.
const otherResponse = await getRecipе(ctx.env, recipe.id, 'user-2');
expect(otherResponse.status).toBe(404);
});

it('should return 404 for non-existent recipes', async () => {
Expand Down
12 changes: 7 additions & 5 deletions controlplane/src/recipes/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,14 @@ export async function listRecipеs(
SELECT * FROM recipes WHERE dashboard_id = ? ORDER BY updated_at DESC
`).bind(dashboardId).all();
} else {
// Get recipes from dashboards user is a member of, plus global recipes (no dashboard_id)
// Dashboards the user is a member of, plus the user's own global recipes
// (global recipes are owner-scoped via created_by).
result = await env.DB.prepare(`
SELECT r.* FROM recipes r
LEFT JOIN dashboard_members dm ON r.dashboard_id = dm.dashboard_id
WHERE r.dashboard_id IS NULL OR dm.user_id = ?
WHERE (r.dashboard_id IS NULL AND r.created_by = ?) OR dm.user_id = ?
ORDER BY r.updated_at DESC
`).bind(userId).all();
`).bind(userId, userId).all();
}

const recipes = result.results.map(r => ({
Expand Down Expand Up @@ -125,14 +126,15 @@ export async function createRecipе(
const now = new Date().toISOString();

await env.DB.prepare(`
INSERT INTO recipes (id, dashboard_id, name, description, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO recipes (id, dashboard_id, name, description, steps, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).bind(
id,
data.dashboardId || null,
data.name,
data.description || '',
JSON.stringify(data.steps || []),
userId,
now,
now
).run();
Expand Down
Loading
Loading