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
2 changes: 1 addition & 1 deletion packages/cli/src/commands/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function registerAppCommands(program: Command): void {
a.platform,
a.archived ? "yes" : "no",
a.default_channel_slug ?? "—",
a.id.slice(0, 8),
a.id,
].join("\t"),
);
}
Expand Down
36 changes: 36 additions & 0 deletions worker/src/lib/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,38 @@ function devTokenBypass(c: AdminContext) {
return c.get("admin_actor") === "dev-token";
}

// App ids are UUIDs. A caller that passes a truncated/partial id (e.g. the
// 8-char short id `hands apps list` used to print) must fail as a client error
// AT THE RESOLUTION BOUNDARY — before any DB lookup or role check — with an
// explicit "use the full app id", instead of falling through to the role check
// and returning a misleading INSUFFICIENT_APP_ROLE.
//
// This is a SHAPE check only and adds no existence signal of its own: it rejects
// an id built solely from UUID characters (hex + dashes) that is not a complete
// UUID — i.e. a truncated UUID. A well-formed UUID that simply does not exist (or
// that the caller may not access) is NOT rejected here; it continues to the
// normal role path exactly as before. Anything containing a non-UUID character
// (the synthetic slug-like ids used in tests, future schemes) is left untouched.
// (Note: whether the role path itself distinguishes existing vs absent apps — via
// the org_id echoed in the forbidden response — is a separate pre-existing concern
// this gate neither introduces nor fixes.)
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function appIdShapeError(c: AdminContext, appId: string) {
if (UUID_RE.test(appId)) return null;
if (!/^[0-9a-f-]+$/i.test(appId)) return null;
return c.json(
{
error: "invalid_app_id",
code: "EXACT_APP_ID_REQUIRED",
next_action:
`'${appId}' is not a complete app id. Pass the full app UUID ` +
`(the ID column of \`hands apps list\`), not a shortened or partial id.`,
app_id: appId,
},
400,
);
}

export function currentAccount(c: AdminContext): AdminAccount | null {
return c.get("admin_account") ?? null;
}
Expand Down Expand Up @@ -292,6 +324,8 @@ export async function ensureAppRole(
opts?: { orgMinimum?: OrgRole },
) {
if (devTokenBypass(c)) return { ok: true as const, app_role: "admin" as AppRole, org_role: "owner" as OrgRole };
const shapeError = appIdShapeError(c, appId);
if (shapeError) return { ok: false as const, response: shapeError };
const deployToken = currentDeployToken(c);
if (deployToken) {
if (
Expand Down Expand Up @@ -370,6 +404,8 @@ export async function ensureAppPermission(
opts?: { orgMinimum?: OrgRole },
) {
if (devTokenBypass(c)) return { ok: true as const, app_permission: permission };
const shapeError = appIdShapeError(c, appId);
if (shapeError) return { ok: false as const, response: shapeError };
const deployToken = currentDeployToken(c);
if (deployToken) {
if (deployToken.app_id !== appId || !hasDeployTokenPermission(deployToken, permission)) {
Expand Down
71 changes: 71 additions & 0 deletions worker/test/app_id_shape_gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { Hono } from "hono";
import { requireAppRole } from "../src/lib/permissions";

// A short/partial app id (the 8-char form `hands apps list` used to print) must
// fail at the resolution boundary with an explicit "use the full app id", NOT
// fall through to the role check and return a misleading INSUFFICIENT_APP_ROLE.
// The gate is SHAPE-only: it adds no existence signal of its own — a well-formed
// UUID is left to the normal role path exactly as before, and synthetic slug-like
// ids are untouched. (Whether that role path itself distinguishes existing vs
// absent apps is a SEPARATE pre-existing concern — the org_id echo in the
// forbidden response — tracked outside this gate; not asserted here.)

const account = {
id: "account-1", provider: "raft", provider_subject: "subject-1",
server_id: "s", server_slug: "s", principal_type: "human",
server_role: "member", username: "u", display_name: "U", avatar_url: null,
raw_profile: "{}", created_at: 1, updated_at: 1, last_login_at: 1,
org_id: null, org_role: null,
};

// DB that resolves no org and no role for anyone → any request that clears the
// shape gate lands on the normal 403 role error.
function emptyDb() {
return {
prepare: () => ({ bind: () => ({ first: async () => null, all: async () => ({ results: [] }) }) }),
} as unknown as D1Database;
}

async function hit(appId: string) {
const app = new Hono<any>();
app.use("*", async (c, next) => { c.set("admin_account", account); await next(); });
app.use("/api/apps/:appId/*", requireAppRole("viewer"));
app.get("/api/apps/:appId/thing", (c) => c.json({ ok: true }));
const env = { DB: emptyDb(), DASHBOARD_ORIGIN: "https://dashboard.example" } as unknown as Env;
return app.request(`https://app.hands.build/api/apps/${appId}/thing`, {}, env);
}

describe("app id shape gate (requireAppRole / ensureAppRole)", () => {
it("rejects an 8-char truncated UUID with 400 EXACT_APP_ID_REQUIRED before the role check", async () => {
const res = await hit("76304f16");
expect(res.status).toBe(400);
const body = await res.json<{ code: string; app_id: string }>();
expect(body.code).toBe("EXACT_APP_ID_REQUIRED");
expect(body.app_id).toBe("76304f16");
});

it("rejects a dashed partial UUID (hex+dash only, not a full UUID) with 400", async () => {
const res = await hit("76304f16-fbf7");
expect(res.status).toBe(400);
expect((await res.json<{ code: string }>()).code).toBe("EXACT_APP_ID_REQUIRED");
});

it("does NOT shape-gate a full UUID — it passes through to the normal role check (here 403 INSUFFICIENT_APP_ROLE)", async () => {
// emptyDb() resolves no role, so this only proves the gate lets a well-formed
// UUID through to the role path. It intentionally does NOT assert existence
// indistinguishability of that role path (the org_id echo makes exists-vs-absent
// distinguishable today — a separate follow-up, not this gate's concern).
const res = await hit("76304f16-fbf7-488f-8445-e16ffdd6cef8");
expect(res.status).toBe(403);
expect((await res.json<{ code: string }>()).code).toBe("INSUFFICIENT_APP_ROLE");
});

it("does NOT shape-reject synthetic slug-like ids (contain non-hex chars) — they reach the normal role check", async () => {
for (const id of ["app-1", "guard-app", "legacy-app", "other"]) {
const res = await hit(id);
expect(res.status, `id ${id}`).toBe(403);
expect((await res.json<{ code: string }>()).code, `id ${id}`).toBe("INSUFFICIENT_APP_ROLE");
}
});
});
Loading