fix(auth): mark plugin-owned session fields as non-input (#9965)

This commit is contained in:
Gustavo Valverde
2026-06-09 23:59:09 +00:00
committed by GitHub
parent 2ac00fe421
commit 5e49c56a9e
7 changed files with 46 additions and 0 deletions
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Passing `activeOrganizationId`, `activeTeamId`, or `impersonatedBy` to `/update-session` now returns a 400. Change these plugin-managed session fields through their dedicated endpoints instead, such as `organization.setActive`.
@@ -17,6 +17,8 @@ import {
} from "vitest";
import { parseCookies, parseSetCookieHeader } from "../../cookies";
import { signJWT, verifyJWT } from "../../crypto";
import { admin } from "../../plugins/admin";
import { organization } from "../../plugins/organization";
import { getTestInstance } from "../../test-utils/test-instance";
import { getDate } from "../../utils/date";
import { freshSessionMiddleware, getSessionFromCtx } from "./session";
@@ -2208,3 +2210,27 @@ describe("updateSession", async () => {
});
});
});
describe("updateSession plugin authority fields", async () => {
// Plugin-owned authority fields must not be writable through the generic
// session update route. They are set only by membership/permission-checked
// setters (setActiveOrganization, impersonateUser), so /update-session must
// reject them even though they live on the session schema.
const { client, signInWithTestUser } = await getTestInstance({
plugins: [organization({ teams: { enabled: true } }), admin()],
});
it.each([
"activeOrganizationId",
"activeTeamId",
"impersonatedBy",
])("should reject forging the %s session field", async (field) => {
const { runWithUser } = await signInWithTestUser();
await runWithUser(async () => {
const res = await client.updateSession({
[field]: "forged-value",
} as any);
expect(res.error?.status).toBe(400);
});
});
});
+5
View File
@@ -40,6 +40,11 @@ function getFields(
...coreSchema,
...(additionalFields ?? {}),
};
// FIXME: Plugin-contributed fields are input-by-default, so a plugin-owned
// authority field is writable through generic input routes (e.g.
// /update-session) unless it sets `input: false`. A future breaking change
// should make plugin fields non-input by default and require an explicit
// opt-in for client-writable ones.
for (const plugin of options.plugins || []) {
if (plugin.schema && plugin.schema[modelName]) {
schema = {
@@ -31,6 +31,7 @@ export const schema = {
impersonatedBy: {
type: "string",
required: false,
input: false,
},
},
},
@@ -166,11 +166,13 @@ describe("organization", async () => {
activeOrganizationId: {
type: "string";
required: false;
input: false;
};
} & {
activeTeamId: {
type: "string";
required: false;
input: false;
};
};
@@ -1221,6 +1221,7 @@ export function organization<O extends OrganizationOptions>(options?: O) {
activeOrganizationId: {
type: "string",
required: false,
input: false,
fieldName: opts.schema?.session?.fields?.activeOrganizationId,
},
...(teamSupport
@@ -1228,6 +1229,7 @@ export function organization<O extends OrganizationOptions>(options?: O) {
activeTeamId: {
type: "string",
required: false,
input: false,
fieldName: opts.schema?.session?.fields?.activeTeamId,
},
}
@@ -1239,16 +1241,19 @@ export function organization<O extends OrganizationOptions>(options?: O) {
activeTeamId: {
type: "string";
required: false;
input: false;
};
activeOrganizationId: {
type: "string";
required: false;
input: false;
};
}
: {
activeOrganizationId: {
type: "string";
required: false;
input: false;
};
},
},
@@ -199,6 +199,7 @@ interface SessionDefaultFields {
activeOrganizationId: {
type: "string";
required: false;
input: false;
};
}
@@ -278,6 +279,7 @@ export type OrganizationSchema<O extends OrganizationOptions> =
activeTeamId: {
type: "string";
required: false;
input: false;
};
}
: {});