fix(gdpr): stop exporting MFA credentials and gate exports on role authority (#706)

The subject-access export selected the whole users row and subtracted only
passwordHash, so profile.json carried totpSecret and recoveryCodesHash. On a
default install DATA_ENCRYPTION_KEY is empty and the TOTP seed is stored as
cleartext base32; recovery codes are 32-bit values behind an unsalted SHA-256.

Name the profile columns instead, add the canManageTargetRole gate the sibling
purge routes already apply, and scope the export status lookup to the user in
the path plus the gdpr-export tool id.
This commit is contained in:
SnapOtter
2026-08-01 14:16:36 +08:00
committed by GitHub
parent d88031ac0f
commit 059af34ace
4 changed files with 306 additions and 10 deletions
+28 -5
View File
@@ -14,11 +14,34 @@ import { readStoredFile } from "../lib/file-storage.js";
import { putObject } from "../lib/object-storage.js";
export async function gdprExportJob(userId: string, jobId: string): Promise<{ outputRef: string }> {
// 1. Fetch user profile (exclude passwordHash)
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
if (!user) throw new Error(`User ${userId} not found`);
const { passwordHash: _, ...profile } = user;
// 1. Fetch user profile.
//
// The columns are named rather than selected with `*` so that authentication
// material never leaves Postgres. passwordHash, totpSecret and recoveryCodesHash
// are credentials, not personal data a subject-access request is owed, and this
// archive is meant to be handed to the data subject or a regulator. Listing the
// allowlist here also means a future column on `users` is excluded by default
// instead of silently joining the export.
const [profile] = await db
.select({
id: schema.users.id,
username: schema.users.username,
role: schema.users.role,
team: schema.users.team,
email: schema.users.email,
authProvider: schema.users.authProvider,
externalId: schema.users.externalId,
mustChangePassword: schema.users.mustChangePassword,
legalHold: schema.users.legalHold,
storageUsed: schema.users.storageUsed,
storageQuota: schema.users.storageQuota,
totpEnabled: schema.users.totpEnabled,
createdAt: schema.users.createdAt,
updatedAt: schema.users.updatedAt,
})
.from(schema.users)
.where(eq(schema.users.id, userId));
if (!profile) throw new Error(`User ${userId} not found`);
// 2. Fetch all user's files metadata
const files = await db.select().from(schema.userFiles).where(eq(schema.userFiles.userId, userId));
+24 -3
View File
@@ -121,13 +121,23 @@ export async function registerGdprRoutes(app: FastifyInstance): Promise<void> {
// Validate the target user exists
const [targetUser] = await db
.select({ id: schema.users.id })
.select({ id: schema.users.id, role: schema.users.role })
.from(schema.users)
.where(eq(schema.users.id, targetUserId));
if (!targetUser) {
return reply.status(404).send({ error: "User not found" });
}
// Same authority gate the purge routes apply. An export archive carries the
// target's whole library and profile, so exporting an account above your own
// role is a disclosure path, not a read-only status call.
if (!(await canManageTargetRole(user, targetUser.role))) {
return reply.status(403).send({
error: "Cannot manage a user beyond your role authority",
code: "ESCALATION_DENIED",
});
}
// Create a durable job row
const jobId = randomUUID();
await db.insert(schema.jobs).values({
@@ -178,9 +188,20 @@ export async function registerGdprRoutes(app: FastifyInstance): Promise<void> {
});
}
const { jobId } = request.params;
const { id: targetUserId, jobId } = request.params;
const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
// Scoped to the user named in the path and to the export job type, so this
// cannot be used to resolve an arbitrary job id into a download URL.
const [job] = await db
.select()
.from(schema.jobs)
.where(
and(
eq(schema.jobs.id, jobId),
eq(schema.jobs.userId, targetUserId),
eq(schema.jobs.toolId, "gdpr-export"),
),
);
if (!job) {
return reply.status(404).send({ error: "Export job not found" });