diff --git a/packages/pds/src/actor-store/preference/reader.ts b/packages/pds/src/actor-store/preference/reader.ts index 2325350ff82..5c0359ff72e 100644 --- a/packages/pds/src/actor-store/preference/reader.ts +++ b/packages/pds/src/actor-store/preference/reader.ts @@ -1,9 +1,14 @@ +import { AuthScope } from '../../auth-verifier' import { ActorDb } from '../db' +import { prefInScope } from './util' export class PreferenceReader { constructor(public db: ActorDb) {} - async getPreferences(namespace?: string): Promise { + async getPreferences( + namespace: string, + scope: AuthScope, + ): Promise { const prefsRes = await this.db.db .selectFrom('account_pref') .orderBy('id') @@ -11,6 +16,7 @@ export class PreferenceReader { .execute() return prefsRes .filter((pref) => !namespace || prefMatchNamespace(namespace, pref.name)) + .filter((pref) => prefInScope(scope, pref.name)) .map((pref) => JSON.parse(pref.valueJson)) } } diff --git a/packages/pds/src/actor-store/preference/transactor.ts b/packages/pds/src/actor-store/preference/transactor.ts index cfb8bd383ea..152082dff2f 100644 --- a/packages/pds/src/actor-store/preference/transactor.ts +++ b/packages/pds/src/actor-store/preference/transactor.ts @@ -4,11 +4,14 @@ import { AccountPreference, prefMatchNamespace, } from './reader' +import { AuthScope } from '../../auth-verifier' +import { prefInScope } from './util' export class PreferenceTransactor extends PreferenceReader { async putPreferences( values: AccountPreference[], namespace: string, + scope: AuthScope, ): Promise { this.db.assertTransaction() if (!values.every((value) => prefMatchNamespace(namespace, value.$type))) { @@ -16,6 +19,12 @@ export class PreferenceTransactor extends PreferenceReader { `Some preferences are not in the ${namespace} namespace`, ) } + const notInScope = values.filter((val) => !prefInScope(scope, val.$type)) + if (notInScope.length > 0) { + throw new InvalidRequestError( + `Do not have authorization to set preferences: ${notInScope.join(', ')}`, + ) + } // get all current prefs for user and prep new pref rows const allPrefs = await this.db.db .selectFrom('account_pref') @@ -29,6 +38,7 @@ export class PreferenceTransactor extends PreferenceReader { }) const allPrefIdsInNamespace = allPrefs .filter((pref) => prefMatchNamespace(namespace, pref.name)) + .filter((pref) => prefInScope(scope, pref.name)) .map((pref) => pref.id) // replace all prefs in given namespace if (allPrefIdsInNamespace.length) { diff --git a/packages/pds/src/actor-store/preference/util.ts b/packages/pds/src/actor-store/preference/util.ts new file mode 100644 index 00000000000..55f636d07c4 --- /dev/null +++ b/packages/pds/src/actor-store/preference/util.ts @@ -0,0 +1,8 @@ +import { AuthScope } from '../../auth-verifier' + +const FULL_ACCESS_ONLY_PREFS = ['app.bsky.actor.defs#personalDetailsPref'] + +export const prefInScope = (scope: AuthScope, prefType: string) => { + if (scope === AuthScope.Access) return true + return !FULL_ACCESS_ONLY_PREFS.includes(prefType) +} diff --git a/packages/pds/src/api/app/bsky/actor/getPreferences.ts b/packages/pds/src/api/app/bsky/actor/getPreferences.ts index f4c42729640..310495e3282 100644 --- a/packages/pds/src/api/app/bsky/actor/getPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/getPreferences.ts @@ -1,6 +1,5 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { AuthScope } from '../../../../auth-verifier' export default function (server: Server, ctx: AppContext) { if (!ctx.cfg.bskyAppView) return @@ -8,15 +7,9 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.authVerifier.accessStandard(), handler: async ({ auth }) => { const requester = auth.credentials.did - let preferences = await ctx.actorStore.read(requester, (store) => - store.pref.getPreferences('app.bsky'), + const preferences = await ctx.actorStore.read(requester, (store) => + store.pref.getPreferences('app.bsky', auth.credentials.scope), ) - if (auth.credentials.scope !== AuthScope.Access) { - // filter out personal details for app passwords - preferences = preferences.filter( - (pref) => pref.$type !== 'app.bsky.actor.defs#personalDetailsPref', - ) - } return { encoding: 'application/json', body: { preferences }, diff --git a/packages/pds/src/api/app/bsky/actor/putPreferences.ts b/packages/pds/src/api/app/bsky/actor/putPreferences.ts index 537c32feaa1..5006c2150a5 100644 --- a/packages/pds/src/api/app/bsky/actor/putPreferences.ts +++ b/packages/pds/src/api/app/bsky/actor/putPreferences.ts @@ -19,7 +19,11 @@ export default function (server: Server, ctx: AppContext) { } } await ctx.actorStore.transact(requester, async (actorTxn) => { - await actorTxn.pref.putPreferences(checkedPreferences, 'app.bsky') + await actorTxn.pref.putPreferences( + checkedPreferences, + 'app.bsky', + auth.credentials.scope, + ) }) }, }) diff --git a/packages/pds/tests/preferences.test.ts b/packages/pds/tests/preferences.test.ts index 3af5cba0384..d5b51c0ba69 100644 --- a/packages/pds/tests/preferences.test.ts +++ b/packages/pds/tests/preferences.test.ts @@ -1,11 +1,13 @@ import { TestNetworkNoAppView, SeedClient } from '@atproto/dev-env' import AtpAgent from '@atproto/api' import usersSeed from './seeds/users' +import { AuthScope } from '../dist/auth-verifier' describe('user preferences', () => { let network: TestNetworkNoAppView let agent: AtpAgent let sc: SeedClient + let appPassHeaders: { authorization: string } beforeAll(async () => { network = await TestNetworkNoAppView.create({ @@ -14,6 +16,16 @@ describe('user preferences', () => { agent = network.pds.getClient() sc = network.getSeedClient() await usersSeed(sc) + const appPass = await network.pds.ctx.accountManager.createAppPassword( + sc.dids.alice, + 'test app pass', + false, + ) + const res = await agent.com.atproto.server.createSession({ + identifier: sc.dids.alice, + password: appPass.password, + }) + appPassHeaders = { authorization: `Bearer ${res.data.accessJwt}` } }) afterAll(async () => { @@ -46,6 +58,7 @@ describe('user preferences', () => { store.pref.putPreferences( [{ $type: 'com.atproto.server.defs#unknown' }], 'com.atproto', + AuthScope.Access, ), ) const { data } = await agent.api.app.bsky.actor.getPreferences( @@ -96,7 +109,7 @@ describe('user preferences', () => { // Ensure other prefs were not clobbered const otherPrefs = await network.pds.ctx.actorStore.read( sc.dids.alice, - (store) => store.pref.getPreferences('com.atproto'), + (store) => store.pref.getPreferences('com.atproto', AuthScope.Access), ) expect(otherPrefs).toEqual([{ $type: 'com.atproto.server.defs#unknown' }]) }) @@ -178,4 +191,57 @@ describe('user preferences', () => { 'Input/preferences/1 must be an object which includes the "$type" property', ) }) + + it('does not read permissioned preferences with an app password', async () => { + await agent.api.app.bsky.actor.putPreferences( + { + preferences: [ + { + $type: 'app.bsky.actor.defs#personalDetailsPref', + birthDate: new Date().toISOString(), + }, + ], + }, + { headers: sc.getHeaders(sc.dids.alice), encoding: 'application/json' }, + ) + const res = await agent.api.app.bsky.actor.getPreferences( + {}, + { headers: appPassHeaders }, + ) + expect(res.data.preferences).toEqual([]) + }) + + it('does not write permissioned preferences with an app password', async () => { + const tryPut = agent.api.app.bsky.actor.putPreferences( + { + preferences: [ + { + $type: 'app.bsky.actor.defs#personalDetailsPref', + birthDate: new Date().toISOString(), + }, + ], + }, + { headers: appPassHeaders, encoding: 'application/json' }, + ) + await expect(tryPut).rejects.toThrow( + /Do not have authorization to set preferences/, + ) + }) + + it('does not remove permissioned preferences with an app password', async () => { + await agent.api.app.bsky.actor.putPreferences( + { + preferences: [], + }, + { headers: appPassHeaders, encoding: 'application/json' }, + ) + const res = await agent.api.app.bsky.actor.getPreferences( + {}, + { headers: sc.getHeaders(sc.dids.alice) }, + ) + const scopedPref = res.data.preferences.find( + (pref) => pref.$type === 'app.bsky.actor.defs#personalDetailsPref', + ) + expect(scopedPref).toBeDefined() + }) })