mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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));
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user