Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/[lang]/_components/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function Notification(): ReactNode {
const fetchData = async (): Promise<void> => {
try {
setIsLoading(true)
const res = await fetch('/api/strapi/notification?populate=*', {
const res = await fetch('/api/strapi/notification?populate[0]=bgImage', {
signal: abortController.signal,
})

Expand Down
37 changes: 10 additions & 27 deletions app/api/strapi/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -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/<collection> 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[] }> }
Expand All @@ -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()
Expand Down
89 changes: 89 additions & 0 deletions app/api/strapi/query.test.ts
Original file line number Diff line number Diff line change
@@ -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'))
)
})
96 changes: 96 additions & 0 deletions app/api/strapi/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
type TContentPolicy = {
fields: string[]
populate: string[]
populateFields?: Record<string, string[]>
filters: string[]
sort: string[]
}

const imageFields = ['url', 'width', 'height', 'formats']

const policies: Record<string, TContentPolicy> = {
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<string>()
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
}
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading