diff --git a/app/[lang]/_components/Notification.tsx b/app/[lang]/_components/Notification.tsx index 2592877..5a6f5d2 100644 --- a/app/[lang]/_components/Notification.tsx +++ b/app/[lang]/_components/Notification.tsx @@ -52,7 +52,7 @@ export function Notification(): ReactNode { const fetchData = async (): Promise => { try { setIsLoading(true) - const res = await fetch('/api/strapi/notification?populate=*', { + const res = await fetch('/api/strapi/notification?populate[0]=bgImage', { signal: abortController.signal, }) diff --git a/app/api/strapi/[...path]/route.ts b/app/api/strapi/[...path]/route.ts index c1ef4df..6117eb2 100644 --- a/app/api/strapi/[...path]/route.ts +++ b/app/api/strapi/[...path]/route.ts @@ -1,25 +1,11 @@ import { NextResponse } from 'next/server' -import type { NextRequest } from 'next/server' +import { publicContentQuery } from '../query' -/** - * Same-origin proxy for the Strapi Content API. - * - * Keeps the Strapi API token server-side: the browser calls /api/strapi/ with no - * credentials and this handler injects the bearer token before forwarding to Strapi. - * - * Forwarding is restricted to what the frontend actually uses: - * - only GET, and the upstream path is always prefixed with /api/ - * - only the collections in ALLOWED_COLLECTIONS; anything else 404s - * - only the query params in ALLOWED_QUERY_KEYS; anything else 400s - */ +import type { NextRequest } from 'next/server' -// Collections the browser is allowed to read through this proxy. const ALLOWED_COLLECTIONS = new Set(['notification', 'posts', 'newsrooms', 'support-articles', 'faq']) -// Top-level query params the frontend uses. -const ALLOWED_QUERY_KEYS = new Set(['fields', 'filters', 'populate', 'sort', 'pagination', 'status', 'locale']) - export async function GET( request: NextRequest, { params }: { params: Promise<{ path: string[] }> } @@ -38,22 +24,19 @@ export async function GET( return NextResponse.json({ error: 'Not found' }, { status: 404 }) } - // Reject any query param outside the known-safe set. Keys look like `filters[slug][$eq]`, so we - // compare the base key before the first bracket. - for (const key of request.nextUrl.searchParams.keys()) { - const baseKey = key.split('[')[0] - if (!ALLOWED_QUERY_KEYS.has(baseKey)) { - return NextResponse.json({ error: `Unsupported query parameter: ${baseKey}` }, { status: 400 }) - } + const target = new URL(`/api/${path[0]}`, strapiUrl) + try { + target.search = publicContentQuery(path[0], request.nextUrl.searchParams).toString() + } catch { + return NextResponse.json({ error: 'Unsupported content query' }, { status: 400 }) } - const target = new URL(`/api/${path.join('/')}`, strapiUrl) - target.search = request.nextUrl.search - try { const upstream = await fetch(target, { headers: { Authorization: `Bearer ${strapiToken}` }, - next: { revalidate: 60 }, + cache: 'no-store', + redirect: 'error', + signal: AbortSignal.timeout(10_000), }) const body = await upstream.text() diff --git a/app/api/strapi/query.test.ts b/app/api/strapi/query.test.ts new file mode 100644 index 0000000..db84c7e --- /dev/null +++ b/app/api/strapi/query.test.ts @@ -0,0 +1,89 @@ +import { strict as assert } from 'node:assert' +import { test } from 'node:test' + +import { publicContentQuery } from './query' + +test('forces published content even when status is omitted', () => { + const query = publicContentQuery('posts', new URLSearchParams()) + assert.equal(query.get('status'), 'published') + assert.equal(query.get('pagination[pageSize]'), '10') + assert.equal(query.get('fields[0]'), 'slug') +}) + +test('rejects draft, wildcards, nested operators and excessive pagination', () => { + for (const query of [ + 'status=draft', + 'status[$ne]=published', + 'status=published&status=draft', + 'populate=*', + 'populate[0]=author.privateProfile', + 'fields[0]=privateNotes', + 'filters[$or][0][publishedAt][$null]=true', + 'pagination[pageSize]=1000000', + 'pagination[pageSize]=-1', + 'pagination[start]=999999999', + 'pagination[page]=1001', + ]) + assert.throws(() => publicContentQuery('posts', new URLSearchParams(query)), query) +}) + +test('preserves the public post, support, newsroom, notification and FAQ queries', () => { + for (const [collection, query] of [ + [ + 'posts', + 'populate[0]=featuredImg&fields[0]=slug&sort[0]=isFeatured:desc&sort[1]=id:desc&filters[tags][$contains]=DeFi', + ], + ['support-articles', 'fields[0]=content&filters[slug][$eq]=recover-wallet&sort[0]=publishedAt:desc'], + ['newsrooms', 'fields[14]=author&sort[0]=publishedOn:asc&filters[category][$contains]=Press'], + ['notification', 'populate[0]=bgImage'], + ['faq', 'populate[0]=faqSection&populate[1]=faqSection.faqSectionItem&locale=en'], + ]) + assert.equal(publicContentQuery(collection, new URLSearchParams(query)).get('status'), 'published') +}) + +test('query permissions are collection-specific', () => { + assert.throws(() => publicContentQuery('notification', new URLSearchParams('populate[0]=featuredImg'))) + assert.throws(() => publicContentQuery('support-articles', new URLSearchParams('filters[type][$contains]=foo'))) +}) + +test('restricts populated media to the fields used by public pages', () => { + for (const collection of ['posts', 'newsrooms', 'support-articles', 'notification']) { + const relation = collection === 'notification' ? 'bgImage' : 'featuredImg' + const fields = collection === 'notification' ? ['url'] : ['url', 'width', 'height', 'formats'] + const query = publicContentQuery(collection, new URLSearchParams(`populate[0]=${relation}`)) + assert.deepEqual( + [...query].filter(([key]) => key.startsWith('populate')), + fields.map((field, index) => [`populate[${relation}][fields][${index}]`, field]) + ) + assert.equal(query.get('status'), 'published') + } +}) + +test('repeated media selections cannot reintroduce unrestricted population', () => { + const query = publicContentQuery('posts', new URLSearchParams('populate[0]=featuredImg&populate[99]=featuredImg')) + assert.equal([...query.keys()].filter((key) => key.startsWith('populate')).length, 4) + assert.equal(query.has('populate[0]'), false) + assert.equal(query.has('populate[99]'), false) +}) + +test('media is only populated when requested', () => { + const query = publicContentQuery('posts', new URLSearchParams()) + assert.equal( + [...query.keys()].some((key) => key.startsWith('populate')), + false + ) +}) + +test('callers cannot override media field selections or populate private relations', () => { + for (const selection of [ + 'populate[featuredImg][fields][0]=provider_metadata', + 'populate[featuredImg][fields][0]=*', + 'populate[featuredImg][populate][0]=createdBy', + 'populate[1]=featuredImg.createdBy', + ]) { + assert.throws(() => publicContentQuery('posts', new URLSearchParams(`populate[0]=featuredImg&${selection}`))) + } + assert.throws(() => + publicContentQuery('notification', new URLSearchParams('populate[0]=bgImage&populate[bgImage][fields][0]=provider')) + ) +}) diff --git a/app/api/strapi/query.ts b/app/api/strapi/query.ts new file mode 100644 index 0000000..2fbcac9 --- /dev/null +++ b/app/api/strapi/query.ts @@ -0,0 +1,96 @@ +type TContentPolicy = { + fields: string[] + populate: string[] + populateFields?: Record + filters: string[] + sort: string[] +} + +const imageFields = ['url', 'width', 'height', 'formats'] + +const policies: Record = { + posts: { + fields: ['slug', 'summary', 'title', 'type', 'tags', 'publishedAt', 'isFeatured', 'content'], + populate: ['featuredImg'], + populateFields: { featuredImg: imageFields }, + filters: ['filters[slug][$eq]', 'filters[type][$contains]', 'filters[tags][$contains]'], + sort: ['isFeatured:desc', 'id:asc', 'id:desc'], + }, + newsrooms: { + fields: [ + 'slug', + 'postSummary', + 'title', + 'category', + 'tags', + 'publishedAt', + 'publishedOn', + 'author', + 'externalURL', + 'content', + ], + populate: ['featuredImg'], + populateFields: { featuredImg: imageFields }, + filters: ['filters[slug][$eq]', 'filters[category][$contains]', 'filters[tags][$contains]'], + sort: ['publishedOn:asc', 'publishedOn:desc'], + }, + ['support-articles']: { + fields: ['slug', 'summary', 'title', 'publishedAt', 'content'], + populate: ['featuredImg'], + populateFields: { featuredImg: imageFields }, + filters: ['filters[slug][$eq]'], + sort: ['publishedAt:asc', 'publishedAt:desc'], + }, + notification: { + fields: ['title', 'description', 'enabled', 'type', 'tag', 'href'], + populate: ['bgImage'], + populateFields: { bgImage: ['url'] }, + filters: [], + sort: [], + }, + faq: { fields: ['documentId'], populate: ['faqSection', 'faqSection.faqSectionItem'], filters: [], sort: [] }, +} + +export function publicContentQuery(collection: string, input: URLSearchParams): URLSearchParams { + const policy = policies[collection] + if (!policy || input.toString().length > 4096) throw new Error('Unsupported content query') + const output = new URLSearchParams() + const seen = new Set() + for (const [key, value] of input) { + if (seen.has(key) || value.length > 200) throw new Error('Invalid query parameter') + seen.add(key) + const indexed = /^(fields|populate|sort)\[(\d{1,2})\]$/.exec(key) + if (indexed) { + const kind = indexed[1] as 'fields' | 'populate' | 'sort' + if (!policy[kind].includes(value)) throw new Error('Unsupported selection') + const relationFields = kind === 'populate' ? policy.populateFields?.[value] : undefined + if (relationFields) { + // Replace unrestricted media population with server-owned field selections. + relationFields.forEach((field, index) => output.set(`populate[${value}][fields][${index}]`, field)) + } else { + output.append(key, value) + } + } else if (policy.filters.includes(key) && value.length > 0) { + output.set(key, value) + } else if (key === 'status' && value === 'published') { + continue + } else if (key === 'locale' && /^[a-z]{2}(-[A-Z]{2})?$/.test(value)) { + output.set(key, value) + } else if (key === 'pagination[withCount]' && ['true', 'false'].includes(value)) { + output.set(key, value) + } else if (['pagination[page]', 'pagination[pageSize]'].includes(key)) { + const maximum = key === 'pagination[page]' ? 1000 : 50 + if (!/^[1-9]\d*$/.test(value) || Number(value) > maximum) throw new Error('Invalid pagination') + output.set(key, value) + } else { + throw new Error('Unsupported query parameter') + } + } + if (![...seen].some((key) => key.startsWith('fields['))) { + policy.fields.forEach((field, index) => output.set(`fields[${index}]`, field)) + } + output.set('status', 'published') + if (!output.has('pagination[pageSize]')) output.set('pagination[pageSize]', '10') + if (!output.has('pagination[page]')) output.set('pagination[page]', '1') + return output +} diff --git a/bun.lockb b/bun.lockb index d9890f2..30f173f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 4026f17..4c12399 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "highlight.js": "^11.11.1", "katex": "^0.16.21", "motion": "^12.4.7", - "next": "^15.1.9", + "next": "15.5.27", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.3",