aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/graphql/user/resolvers.ts3
-rw-r--r--src/lib/Utility/authorisation.test.ts20
-rw-r--r--src/lib/Utility/authorisation.ts9
-rw-r--r--src/routes/api/badges/+server.ts14
4 files changed, 40 insertions, 6 deletions
diff --git a/src/graphql/user/resolvers.ts b/src/graphql/user/resolvers.ts
index 905a2b4f..a90c6c4c 100644
--- a/src/graphql/user/resolvers.ts
+++ b/src/graphql/user/resolvers.ts
@@ -25,6 +25,7 @@ import {
type UserPreferences,
} from "$lib/Database/SB/User/preferences";
import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie";
+import { isOwnerOrPrivileged } from "$lib/Utility/authorisation";
import privilegedUser from "$lib/Utility/privilegedUser";
import type { Badge, Resolvers as RootResolvers, WithIndex } from "../$types";
@@ -110,7 +111,7 @@ const ensureOwnerOrPrivileged = (
authorised: boolean,
targetUserId: number,
) => {
- if (!authorised && identity.id !== targetUserId)
+ if (!isOwnerOrPrivileged(identity.id, targetUserId, authorised))
throw new Error("Unauthorized");
};
diff --git a/src/lib/Utility/authorisation.test.ts b/src/lib/Utility/authorisation.test.ts
new file mode 100644
index 00000000..0027782b
--- /dev/null
+++ b/src/lib/Utility/authorisation.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { isOwnerOrPrivileged } from "./authorisation";
+
+describe("isOwnerOrPrivileged", () => {
+ it("allows the owner to act on their own resources", () => {
+ expect(isOwnerOrPrivileged(7, 7, false)).toBe(true);
+ });
+
+ it("allows a privileged user to act on anyone", () => {
+ expect(isOwnerOrPrivileged(7, 999, true)).toBe(true);
+ });
+
+ it("blocks a non-privileged user acting on someone else (the IDOR case)", () => {
+ expect(isOwnerOrPrivileged(7, 999, false)).toBe(false);
+ });
+
+ it("allows a privileged owner (both conditions)", () => {
+ expect(isOwnerOrPrivileged(7, 7, true)).toBe(true);
+ });
+});
diff --git a/src/lib/Utility/authorisation.ts b/src/lib/Utility/authorisation.ts
new file mode 100644
index 00000000..c6b64414
--- /dev/null
+++ b/src/lib/Utility/authorisation.ts
@@ -0,0 +1,9 @@
+/**
+ * Whether a caller may act on resources belonging to `targetUserId`: either the
+ * caller owns them, or the caller is a privileged (allow-listed) user.
+ */
+export const isOwnerOrPrivileged = (
+ callerUserId: number,
+ targetUserId: number,
+ privileged: boolean,
+) => privileged || callerUserId === targetUserId;
diff --git a/src/routes/api/badges/+server.ts b/src/routes/api/badges/+server.ts
index 2673273c..10b63125 100644
--- a/src/routes/api/badges/+server.ts
+++ b/src/routes/api/badges/+server.ts
@@ -16,6 +16,7 @@ import {
import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie";
import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody";
import { appOrigin, appOriginHeaders } from "$lib/Utility/appOrigin";
+import { isOwnerOrPrivileged } from "$lib/Utility/authorisation";
import privilegedUser from "$lib/Utility/privilegedUser";
const unauthorised = () => new Response("Unauthorised", { status: 401 });
@@ -76,11 +77,14 @@ export const PUT = async ({ cookies, url, request }) => {
if (!identity) return unauthorised();
const authorised = privilegedUser(identity.id);
- if (url.searchParams.get("shadowHide"))
- await setShadowHidden(
- Number(url.searchParams.get("shadowHide")),
- authorised,
- );
+ if (url.searchParams.get("shadowHide")) {
+ const targetUserId = Number(url.searchParams.get("shadowHide"));
+
+ if (!isOwnerOrPrivileged(identity.id, targetUserId, authorised))
+ return unauthorised();
+
+ await setShadowHidden(targetUserId, authorised);
+ }
if (url.searchParams.get("import") || undefined) {
const importedBadges = await decodeRequestJsonOrThrow(