Skip to content

Commit dd8643c

Browse files
committed
Harden Auth.ts: runtime validation, cycle detection, and entityType typing
- Replace unsafe `as` casts with runtime validation in verifyToken and verifyPassword - Add cycle detection to resolveOwner and collectGroupEntries recursive tree walks - Replace jwtSecret static block mutation with IIFE initializer - Change entityType parameter from `string` to `EntityType` enum in all Auth methods - Add isValidEntityType type guard; validate at HTTP boundaries in AdminRoutes - Update ScoreRoutes and AuthRoutes callers to use EntityType enum members - Add e2e test for permission endpoints rejecting invalid entityType values Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent e2b98cd commit dd8643c

5 files changed

Lines changed: 412 additions & 62 deletions

File tree

src/server/AdminRoutes.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { type IncomingMessage, type ServerResponse } from "node:http";
77

8-
import { Auth } from "./Auth.js";
8+
import { Auth, EntityType, isValidEntityType } from "./Auth.js";
99
import { type RequestContext } from "./RequestContext.js";
1010

1111
export class AdminRoutes {
@@ -616,6 +616,12 @@ export class AdminRoutes {
616616
return;
617617
}
618618

619+
if (!isValidEntityType(entityType)) {
620+
this.ctx.sendError(res, `Invalid entityType: ${entityType}`);
621+
622+
return;
623+
}
624+
619625
const resolvedOwner = await this.ctx.auth.getExplicitOwner(entityType, entityId);
620626

621627
if (!user || (!(await this.ctx.auth.isUserInAdminGroup(user.userId))
@@ -660,7 +666,7 @@ export class AdminRoutes {
660666
}
661667

662668
const isAdmin = await this.ctx.auth.isUserInAdminGroup(user.userId);
663-
const resolvedOwner = await this.ctx.auth.getExplicitOwner(entityType, entityId);
669+
const resolvedOwner = await this.ctx.auth.getExplicitOwner(entityType as EntityType, entityId);
664670

665671
if (!isAdmin && resolvedOwner !== user.userId) {
666672
this.ctx.sendError(res, "Forbidden", 403);
@@ -671,18 +677,18 @@ export class AdminRoutes {
671677
if (body.ownerId !== undefined) {
672678
const newOwnerId = body.ownerId !== null ? Number(body.ownerId) : null;
673679

674-
await this.ctx.auth.setOwner(entityType, entityId, newOwnerId);
680+
await this.ctx.auth.setOwner(entityType as EntityType, entityId, newOwnerId);
675681
}
676682

677683
if (Array.isArray(body.addGroups)) {
678684
for (const g of body.addGroups as Array<{ groupId: number; writable: boolean; }>) {
679-
await this.ctx.auth.addEntityGroup(entityType, entityId, g.groupId, g.writable);
685+
await this.ctx.auth.addEntityGroup(entityType as EntityType, entityId, g.groupId, g.writable);
680686
}
681687
}
682688

683689
if (Array.isArray(body.removeGroups)) {
684690
for (const g of body.removeGroups as Array<{ groupId: number; }>) {
685-
await this.ctx.auth.removeEntityGroup(entityType, entityId, g.groupId);
691+
await this.ctx.auth.removeEntityGroup(entityType as EntityType, entityId, g.groupId);
686692
}
687693
}
688694

src/server/Auth.ts

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,24 @@ export const enum AccessLevel {
8585
}
8686

8787
/** Entity types used in the permissions table. */
88-
export const enum EntityType {
88+
export enum EntityType {
8989
Score = "score",
9090
Folder = "folder",
9191
Feature = "feature",
9292
}
9393

94+
const entityTypeValues = Object.values(EntityType) as string[];
95+
96+
/**
97+
* Type guard: checks whether a string is a valid {@link EntityType} value.
98+
*
99+
* @param value The string to check.
100+
* @returns True if the value is a valid EntityType.
101+
*/
102+
export const isValidEntityType = (value: string): value is EntityType => {
103+
return entityTypeValues.includes(value);
104+
};
105+
94106
export enum LoginAuditEvent {
95107
Login = "login",
96108
GroupLogin = "group_login",
@@ -123,7 +135,15 @@ export class Auth {
123135
private static scryptKeyLen = 64;
124136
private static scryptOptions = { N: 16384, r: 8, p: 1 };
125137

126-
private static readonly jwtSecret: string;
138+
private static jwtSecret: string = (() => {
139+
// eslint-disable-next-line no-restricted-syntax
140+
const secret = process.env.JWT_SECRET;
141+
if (!secret) {
142+
throw new Error("JWT_SECRET environment variable is required.");
143+
}
144+
145+
return secret;
146+
})();
127147

128148
private currentAdapter: IDatabaseAdapter;
129149

@@ -292,15 +312,15 @@ export class Auth {
292312
*
293313
* @returns True if the user has the required access.
294314
*/
295-
public async checkPermission(user: ITokenPayload | undefined, entityType: string,
315+
public async checkPermission(user: ITokenPayload | undefined, entityType: EntityType,
296316
entityId: number | null, requiredLevel: AccessLevel,): Promise<boolean> {
297317
// Admin users always have full access.
298318
if (user && await this.isUserInAdminGroup(user.userId)) {
299319
return true;
300320
}
301321

302322
// Features: currently only admins get access (features will be reworked separately).
303-
if ((entityType as EntityType) === EntityType.Feature) {
323+
if (entityType === EntityType.Feature) {
304324
return false;
305325
}
306326

@@ -369,7 +389,7 @@ export class Auth {
369389
*
370390
* @returns A summary of the user's access.
371391
*/
372-
public async getPermissionSummary(user: ITokenPayload | undefined, entityType: string,
392+
public async getPermissionSummary(user: ITokenPayload | undefined, entityType: EntityType,
373393
entityId: number): Promise<IPermissionSummary> {
374394
const isAdmin = user ? await this.isUserInAdminGroup(user.userId) : false;
375395

@@ -439,7 +459,7 @@ export class Auth {
439459
* @param entityId The entity id.
440460
* @param ownerId The new owner id, or null to remove the explicit owner (inherit).
441461
*/
442-
public async setOwner(entityType: string, entityId: number,
462+
public async setOwner(entityType: EntityType, entityId: number,
443463
ownerId: number | null): Promise<void> {
444464
if (ownerId === null) {
445465
// Remove the explicit row — inheritance takes over.
@@ -464,7 +484,7 @@ export class Auth {
464484
* @param groupId The group id.
465485
* @param writable Whether the group has write access.
466486
*/
467-
public async addEntityGroup(entityType: string, entityId: number,
487+
public async addEntityGroup(entityType: EntityType, entityId: number,
468488
groupId: number, writable: boolean): Promise<void> {
469489
await this.adapter.execute(
470490
`INSERT INTO entity_groups (entity_type, entity_id, group_id, writable) ` +
@@ -480,7 +500,7 @@ export class Auth {
480500
* @param entityId The entity id.
481501
* @param groupId The group id.
482502
*/
483-
public async removeEntityGroup(entityType: string, entityId: number,
503+
public async removeEntityGroup(entityType: EntityType, entityId: number,
484504
groupId: number): Promise<void> {
485505
await this.adapter.execute(
486506
"DELETE FROM entity_groups WHERE entity_type = ? AND entity_id = ? AND group_id = ?",
@@ -494,7 +514,7 @@ export class Auth {
494514
* @param entityType The entity type.
495515
* @param entityId The entity id.
496516
*/
497-
public async getExplicitEntityGroups(entityType: string,
517+
public async getExplicitEntityGroups(entityType: EntityType,
498518
entityId: number): Promise<IEntityGroupEntry[]> {
499519
const rows = await this.adapter.query<{ group_id: number; writable: number; }>(
500520
"SELECT group_id, writable FROM entity_groups WHERE entity_type = ? AND entity_id = ?",
@@ -514,7 +534,7 @@ export class Auth {
514534
*
515535
* @returns The owner id, or null.
516536
*/
517-
public async getExplicitOwner(entityType: string,
537+
public async getExplicitOwner(entityType: EntityType,
518538
entityId: number): Promise<number | null> {
519539
const rows = await this.adapter.query<{ owner_id: number | null; }>(
520540
"SELECT owner_id FROM permissions WHERE entity_type = ? AND entity_id = ?",
@@ -614,9 +634,18 @@ export class Auth {
614634
*
615635
* @param entityType The type of entity.
616636
* @param entityId The entity id.
637+
* @param visited Set of already-visited entity keys to detect cycles.
617638
* @returns The resolved owner id, or null.
618639
*/
619-
private async resolveOwner(entityType: string, entityId: number): Promise<number | null> {
640+
private async resolveOwner(entityType: EntityType, entityId: number,
641+
visited = new Set<string>(),): Promise<number | null> {
642+
const key = `${entityType}:${entityId}`;
643+
if (visited.has(key)) {
644+
return null;
645+
}
646+
647+
visited.add(key);
648+
620649
// Check for an explicit owner on this entity.
621650
const rows = await this.adapter.query<{ owner_id: number | null; }>(
622651
"SELECT owner_id FROM permissions WHERE entity_type = ? AND entity_id = ?",
@@ -628,26 +657,26 @@ export class Auth {
628657
}
629658

630659
// For scores, walk up to the parent folder.
631-
if ((entityType as EntityType) === EntityType.Score) {
660+
if (entityType === EntityType.Score) {
632661
const scoreRows = await this.adapter.query<{ folderid: number | null; }>(
633662
"SELECT folderid FROM scores WHERE id = ?",
634663
[entityId],
635664
);
636665

637666
if (scoreRows[0]?.folderid !== null) {
638-
return this.resolveOwner(EntityType.Folder, scoreRows[0].folderid);
667+
return this.resolveOwner(EntityType.Folder, scoreRows[0].folderid, visited);
639668
}
640669
}
641670

642671
// For folders, walk up to the parent folder.
643-
if ((entityType as EntityType) === EntityType.Folder) {
672+
if (entityType === EntityType.Folder) {
644673
const folderRows = await this.adapter.query<{ parentid: number | null; }>(
645674
"SELECT parentid FROM folders WHERE id = ?",
646675
[entityId],
647676
);
648677

649678
if (folderRows[0]?.parentid !== null) {
650-
return this.resolveOwner(EntityType.Folder, folderRows[0].parentid);
679+
return this.resolveOwner(EntityType.Folder, folderRows[0].parentid, visited);
651680
}
652681
}
653682

@@ -662,10 +691,18 @@ export class Auth {
662691
* @param entityType The type of entity.
663692
* @param entityId The entity id.
664693
* @param collected Accumulator map (groupId → writable). Pass a new Map() on first call.
694+
* @param visited Set of already-visited entity keys to detect cycles.
665695
* @returns A map of groupId → writable.
666696
*/
667-
private async collectGroupEntries(entityType: string, entityId: number,
668-
collected: Map<number, boolean>,): Promise<Map<number, boolean>> {
697+
private async collectGroupEntries(entityType: EntityType, entityId: number,
698+
collected: Map<number, boolean>, visited = new Set<string>(),): Promise<Map<number, boolean>> {
699+
const key = `${entityType}:${entityId}`;
700+
if (visited.has(key)) {
701+
return collected;
702+
}
703+
704+
visited.add(key);
705+
669706
// Collect explicit group assignments for this entity.
670707
const rows = await this.adapter.query<{ group_id: number; writable: number; }>(
671708
"SELECT group_id, writable FROM entity_groups WHERE entity_type = ? AND entity_id = ?",
@@ -682,25 +719,25 @@ export class Auth {
682719
}
683720

684721
// Walk up to the parent.
685-
if ((entityType as EntityType) === EntityType.Score) {
722+
if (entityType === EntityType.Score) {
686723
const scoreRows = await this.adapter.query<{ folderid: number | null; }>(
687724
"SELECT folderid FROM scores WHERE id = ?",
688725
[entityId],
689726
);
690727

691728
if (scoreRows[0]?.folderid !== null) {
692-
return this.collectGroupEntries(EntityType.Folder, scoreRows[0].folderid, collected);
729+
return this.collectGroupEntries(EntityType.Folder, scoreRows[0].folderid, collected, visited);
693730
}
694731
}
695732

696-
if ((entityType as EntityType) === EntityType.Folder) {
733+
if (entityType === EntityType.Folder) {
697734
const folderRows = await this.adapter.query<{ parentid: number | null; }>(
698735
"SELECT parentid FROM folders WHERE id = ?",
699736
[entityId],
700737
);
701738

702739
if (folderRows[0]?.parentid !== null) {
703-
return this.collectGroupEntries(EntityType.Folder, folderRows[0].parentid, collected);
740+
return this.collectGroupEntries(EntityType.Folder, folderRows[0].parentid, collected, visited);
704741
}
705742
}
706743

@@ -715,7 +752,7 @@ export class Auth {
715752
* @param entityId The entity id.
716753
* @returns The resolved permission state.
717754
*/
718-
private async resolvePermission(entityType: string, entityId: number): Promise<IResolvedPermission> {
755+
private async resolvePermission(entityType: EntityType, entityId: number): Promise<IResolvedPermission> {
719756
const [ownerId, groupEntries] = await Promise.all([
720757
this.resolveOwner(entityType, entityId),
721758
this.collectGroupEntries(entityType, entityId, new Map()),
@@ -743,12 +780,4 @@ export class Auth {
743780
return rows[0]?.id;
744781
};
745782

746-
static {
747-
// @ts-expect-error, the field is readonly.
748-
// eslint-disable-next-line no-restricted-syntax
749-
this.jwtSecret = process.env.JWT_SECRET;
750-
if (!this.jwtSecret) {
751-
throw new Error("JWT_SECRET environment variable is required.");
752-
}
753-
}
754783
}

src/server/AuthRoutes.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { type IncomingMessage, type ServerResponse } from "node:http";
77

8-
import { Auth, type ITokenPayload, LoginAuditEvent } from "./Auth.js";
8+
import { Auth, type ITokenPayload, LoginAuditEvent, EntityType } from "./Auth.js";
99
import { type RequestContext } from "./RequestContext.js";
1010

1111
export class AuthRoutes {
@@ -409,14 +409,14 @@ export class AuthRoutes {
409409
);
410410

411411
for (const f of orphanFolders) {
412-
await this.ctx.auth.setOwner("folder", f.id, result.insertId);
412+
await this.ctx.auth.setOwner(EntityType.Folder, f.id, result.insertId);
413413

414414
if (worldId !== undefined) {
415-
await this.ctx.auth.addEntityGroup("folder", f.id, worldId, false);
415+
await this.ctx.auth.addEntityGroup(EntityType.Folder, f.id, worldId, false);
416416
}
417417

418418
if (defaultGroupId) {
419-
await this.ctx.auth.addEntityGroup("folder", f.id, defaultGroupId, false);
419+
await this.ctx.auth.addEntityGroup(EntityType.Folder, f.id, defaultGroupId, false);
420420
}
421421
}
422422

@@ -429,14 +429,14 @@ export class AuthRoutes {
429429
);
430430

431431
for (const s of orphanScores) {
432-
await this.ctx.auth.setOwner("score", s.id, result.insertId);
432+
await this.ctx.auth.setOwner(EntityType.Score, s.id, result.insertId);
433433

434434
if (worldId !== undefined) {
435-
await this.ctx.auth.addEntityGroup("score", s.id, worldId, false);
435+
await this.ctx.auth.addEntityGroup(EntityType.Score, s.id, worldId, false);
436436
}
437437

438438
if (defaultGroupId) {
439-
await this.ctx.auth.addEntityGroup("score", s.id, defaultGroupId, false);
439+
await this.ctx.auth.addEntityGroup(EntityType.Score, s.id, defaultGroupId, false);
440440
}
441441
}
442442

0 commit comments

Comments
 (0)