mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-29 22:20:39 +02:00
refactor: backend-authoritative team scope and config/input hardening
This commit is contained in:
@@ -18,10 +18,22 @@ function safeEqual(a: string, b: string): boolean {
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
type TeamScope = { ownerId: string; teamId: string; teamProfileLimit: number };
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AuthGuard.name);
|
||||
private jwtPublicKey: string | null = null;
|
||||
private readonly backendInternalUrl: string | undefined;
|
||||
private readonly backendInternalKey: string | undefined;
|
||||
|
||||
// Short-lived cache of the per-user team scope so membership revocation takes
|
||||
// effect quickly (within TTL) without a backend round-trip on every request.
|
||||
private readonly teamScopeCache = new Map<
|
||||
string,
|
||||
{ value: TeamScope | null; expires: number }
|
||||
>();
|
||||
private static readonly TEAM_SCOPE_TTL_MS = 30_000;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const publicKey = this.configService.get<string>("SYNC_JWT_PUBLIC_KEY");
|
||||
@@ -29,9 +41,52 @@ export class AuthGuard implements CanActivate {
|
||||
this.jwtPublicKey = publicKey.replace(/\\n/g, "\n");
|
||||
this.logger.log("JWT public key configured — cloud auth enabled");
|
||||
}
|
||||
this.backendInternalUrl = this.configService.get<string>(
|
||||
"BACKEND_INTERNAL_URL",
|
||||
);
|
||||
this.backendInternalKey = this.configService.get<string>(
|
||||
"BACKEND_INTERNAL_KEY",
|
||||
);
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
/**
|
||||
* Resolve a cloud user's team scope via the backend (the ONLY authority for
|
||||
* team membership). Cached briefly. Throws on backend error so the caller can
|
||||
* fail closed (fall back to the user's own namespace, never a team one).
|
||||
*/
|
||||
private async resolveTeamScope(sub: string): Promise<TeamScope | null> {
|
||||
if (!this.backendInternalUrl || !this.backendInternalKey) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const cached = this.teamScopeCache.get(sub);
|
||||
if (cached && cached.expires > now) return cached.value;
|
||||
|
||||
const resp = await fetch(
|
||||
`${this.backendInternalUrl}/api/auth/internal/team-scope`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-internal-key": this.backendInternalKey,
|
||||
},
|
||||
body: JSON.stringify({ userId: sub }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`team-scope resolver returned ${resp.status}`);
|
||||
}
|
||||
const value = (await resp.json()) as TeamScope | null;
|
||||
|
||||
// Bound the cache; a coarse clear is fine since entries are cheap to rebuild.
|
||||
if (this.teamScopeCache.size > 10_000) this.teamScopeCache.clear();
|
||||
this.teamScopeCache.set(sub, {
|
||||
value: value ?? null,
|
||||
expires: now + AuthGuard.TEAM_SCOPE_TTL_MS,
|
||||
});
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
@@ -49,9 +104,7 @@ export class AuthGuard implements CanActivate {
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "self-hosted",
|
||||
prefix: "",
|
||||
teamPrefix: null,
|
||||
profileLimit: 0,
|
||||
teamProfileLimit: 0,
|
||||
} satisfies UserContext;
|
||||
return true;
|
||||
}
|
||||
@@ -63,31 +116,46 @@ export class AuthGuard implements CanActivate {
|
||||
algorithms: ["RS256"],
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
// Validate the scope claims' SHAPE before trusting them as S3 key
|
||||
// prefixes. An empty/over-broad prefix would make validateKeyAccess
|
||||
// (`key.startsWith(prefix)`) authorize the entire bucket, so a signer
|
||||
// bug or permissive claim must not silently widen scope.
|
||||
const prefix = decoded.prefix || `users/${decoded.sub}/`;
|
||||
if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) {
|
||||
const sub = typeof decoded.sub === "string" ? decoded.sub : "";
|
||||
// Validate the prefix claim SHAPE before trusting it as an S3 key
|
||||
// prefix. An empty/over-broad prefix would make validateKeyAccess
|
||||
// (`key.startsWith(prefix)`) authorize the entire bucket.
|
||||
const ownPrefix = decoded.prefix || `users/${sub}/`;
|
||||
if (
|
||||
typeof ownPrefix !== "string" ||
|
||||
!/^users\/[^/]+\/$/.test(ownPrefix)
|
||||
) {
|
||||
throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`);
|
||||
}
|
||||
const teamPrefix =
|
||||
decoded.teamPrefix === undefined || decoded.teamPrefix === null
|
||||
? null
|
||||
: decoded.teamPrefix;
|
||||
if (
|
||||
teamPrefix !== null &&
|
||||
!/^teams\/[^/]+\/$/.test(String(teamPrefix))
|
||||
) {
|
||||
throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`);
|
||||
|
||||
// Resolve the EFFECTIVE namespace: a team member's requests are scoped
|
||||
// to the shared team owner namespace. The JWT carries no team data — the
|
||||
// backend is the sole authority. On any resolver error we fail CLOSED:
|
||||
// fall back to the user's own namespace, never widening to a team one.
|
||||
let effectivePrefix = ownPrefix;
|
||||
let effectiveProfileLimit =
|
||||
typeof decoded.profileLimit === "number" ? decoded.profileLimit : 0;
|
||||
try {
|
||||
const scope = sub ? await this.resolveTeamScope(sub) : null;
|
||||
if (scope && /^[^/]+$/.test(scope.ownerId)) {
|
||||
effectivePrefix = `users/${scope.ownerId}/`;
|
||||
if (scope.teamProfileLimit > 0) {
|
||||
effectiveProfileLimit = scope.teamProfileLimit;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Team scope resolution failed for ${sub}; using own namespace: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "cloud",
|
||||
prefix,
|
||||
teamPrefix,
|
||||
profileLimit: decoded.profileLimit || 0,
|
||||
teamProfileLimit: decoded.teamProfileLimit || 0,
|
||||
prefix: effectivePrefix,
|
||||
profileLimit: effectiveProfileLimit,
|
||||
sub,
|
||||
} satisfies UserContext;
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export interface UserContext {
|
||||
mode: "self-hosted" | "cloud";
|
||||
prefix: string; // '' for self-hosted, 'users/{id}/' for cloud
|
||||
teamPrefix: string | null; // 'teams/{id}/' or null
|
||||
profileLimit: number; // 0 for unlimited (self-hosted)
|
||||
teamProfileLimit: number; // 0 for unlimited or non-team users
|
||||
// The EFFECTIVE namespace for this request: '' for self-hosted, and for cloud
|
||||
// either the user's own 'users/{sub}/' or, for a team member, the shared team
|
||||
// owner's 'users/{ownerId}/' — resolved server-side by the AuthGuard from the
|
||||
// backend (never carried in the JWT). All key scoping uses this directly.
|
||||
prefix: string;
|
||||
profileLimit: number; // 0 for unlimited (self-hosted); effective (team) limit for team members
|
||||
sub?: string; // the authenticated user id (cloud only)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user