fix: harden user linking and session pruning

Security:
- Add unique constraint on headscale_user_id to prevent hijacking
- linkHeadscaleUser now rejects already-claimed Headscale users
- Onboarding dropdown filters out claimed users
- onboarding-skip action redirects on rejected claims

Maintenance:
- Replace probabilistic session pruning with setInterval (15m)
- Move pruning out of request path into server startup

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
This commit is contained in:
Aarnav Tale
2026-03-08 00:48:32 -05:00
parent 45984ec639
commit 684a95b5e8
8 changed files with 58 additions and 11 deletions
+1 -3
View File
@@ -80,9 +80,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
})() })()
: userInfo.picture; : userInfo.picture;
const hasUsers = await context.auth.hasAnyUsers(); const userId = await context.auth.findOrCreateUser(claims.sub);
const defaultRole = hasUsers ? "member" : "owner";
const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole);
try { try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey); const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
+4 -1
View File
@@ -34,7 +34,10 @@ export async function action({ request, context }: Route.ActionArgs) {
const headscaleUserId = formData.get("headscale_user_id")?.toString(); const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (headscaleUserId) { if (headscaleUserId) {
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId); const linked = await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
if (!linked) {
return redirect("/onboarding");
}
} }
await context.db await context.db
+7 -4
View File
@@ -74,10 +74,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
firstMachine = nodes.find((n) => n.user?.id === matched.id); firstMachine = nodes.find((n) => n.user?.id === matched.id);
} else { } else {
needsUserLink = true; needsUserLink = true;
headscaleUsers = apiUsers.map((u) => ({ const claimed = await context.auth.claimedHeadscaleUserIds();
id: u.id, headscaleUsers = apiUsers
name: getUserDisplayName(u), .filter((u) => !claimed.has(u.id))
})); .map((u) => ({
id: u.id,
name: getUserDisplayName(u),
}));
} }
} }
} catch (e) { } catch (e) {
+1 -1
View File
@@ -23,7 +23,7 @@ export const users = sqliteTable("users", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
sub: text("sub").notNull().unique(), sub: text("sub").notNull().unique(),
role: text("role").notNull().default("member"), role: text("role").notNull().default("member"),
headscale_user_id: text("headscale_user_id"), headscale_user_id: text("headscale_user_id").unique(),
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false), onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()), created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()), updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
+8
View File
@@ -129,6 +129,14 @@ export default createHonoServer({
}, },
}); });
// Prune expired auth sessions every 15 minutes
setInterval(
() => {
appLoadContext.auth.pruneExpiredSessions();
},
15 * 60 * 1000,
);
process.on("SIGINT", () => { process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down..."); log.info("server", "Received SIGINT, shutting down...");
process.exit(0); process.exit(0);
+31 -2
View File
@@ -317,13 +317,42 @@ export class AuthService {
} }
/** /**
* Update the Headscale user link for a Headplane user. * Link a Headplane user to a Headscale user. Returns false if the
* Headscale user is already claimed by another Headplane user.
*/ */
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<void> { async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await this.opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.headscale_user_id, headscaleUserId))
.limit(1);
if (existing && existing.id !== userId) {
return false;
}
await this.opts.db await this.opts.db
.update(users) .update(users)
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() }) .set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
return true;
}
/**
* Returns the set of Headscale user IDs that are already claimed
* by a Headplane user. Used to filter the onboarding dropdown.
*/
async claimedHeadscaleUserIds(): Promise<Set<string>> {
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
const ids = new Set<string>();
for (const row of rows) {
if (row.hsId) {
ids.add(row.hsId);
}
}
return ids;
} }
/** /**
+1
View File
@@ -10,6 +10,7 @@ CREATE TABLE `auth_sessions` (
--> statement-breakpoint --> statement-breakpoint
ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint
CREATE UNIQUE INDEX `users_headscale_user_id_unique` ON `users` (`headscale_user_id`);--> statement-breakpoint
ALTER TABLE `users` ADD `created_at` integer;--> statement-breakpoint ALTER TABLE `users` ADD `created_at` integer;--> statement-breakpoint
ALTER TABLE `users` ADD `updated_at` integer;--> statement-breakpoint ALTER TABLE `users` ADD `updated_at` integer;--> statement-breakpoint
ALTER TABLE `users` ADD `last_login_at` integer;--> statement-breakpoint ALTER TABLE `users` ADD `last_login_at` integer;--> statement-breakpoint
+5
View File
@@ -193,6 +193,11 @@
"name": "users_sub_unique", "name": "users_sub_unique",
"columns": ["sub"], "columns": ["sub"],
"isUnique": true "isUnique": true
},
"users_headscale_user_id_unique": {
"name": "users_headscale_user_id_unique",
"columns": ["headscale_user_id"],
"isUnique": true
} }
}, },
"foreignKeys": {}, "foreignKeys": {},