diff --git a/app/awtis/[slug]/page.js b/app/awtis/[slug]/page.js
index dc9a08d..da54871 100644
--- a/app/awtis/[slug]/page.js
+++ b/app/awtis/[slug]/page.js
@@ -1,3 +1,4 @@
+import {headers} from 'next/headers'
import {notFound} from 'next/navigation'
import Box from '@mui/material/Box'
@@ -67,6 +68,8 @@ export default async function AwtisPajSlug(props) {
const params = await props.params
const {slug} = params
const anAwtis = await jwennAwtis(slug)
+ const requestHeaders = await headers()
+ const nonce = requestHeaders.get('x-nonce') || undefined
const {photo} = anAwtis
const kuvetiFormat = formatKuveti(photo)
@@ -91,6 +94,7 @@ export default async function AwtisPajSlug(props) {
diff --git a/app/emotion-cache-provider.js b/app/emotion-cache-provider.js
index e2229ed..2433646 100644
--- a/app/emotion-cache-provider.js
+++ b/app/emotion-cache-provider.js
@@ -7,10 +7,10 @@ import {useServerInsertedHTML} from 'next/navigation'
import {CacheProvider as DefaultCacheProvider} from '@emotion/react'
export default function NextAppDirEmotionCacheProvider(props) {
- const {options, CacheProvider = DefaultCacheProvider, children} = props
+ const {options, CacheProvider = DefaultCacheProvider, children, nonce} = props
const [registry] = React.useState(() => {
- const cache = createCache(options)
+ const cache = createCache({...options, nonce})
cache.compat = true
const prevInsert = cache.insert
let inserted = []
@@ -65,12 +65,14 @@ export default function NextAppDirEmotionCacheProvider(props) {
))}
{styles !== '' && (
)}
@@ -84,5 +86,6 @@ export default function NextAppDirEmotionCacheProvider(props) {
NextAppDirEmotionCacheProvider.propTypes = {
options: PropTypes.object.isRequired,
CacheProvider: PropTypes.func,
- children: PropTypes.node.isRequired
+ children: PropTypes.node.isRequired,
+ nonce: PropTypes.string
}
diff --git a/app/layout.js b/app/layout.js
index 8dcd484..4061293 100644
--- a/app/layout.js
+++ b/app/layout.js
@@ -1,3 +1,4 @@
+import {headers} from 'next/headers'
import PlausibleProvider from 'next-plausible'
import TopLoader from '../components/top-loader'
import Navigasyon from '../components/navigasyon'
@@ -61,16 +62,20 @@ const jsonLd = {
}
export default async function RootLayout({children}) {
+ const requestHeaders = await headers()
+ const nonce = requestHeaders.get('x-nonce') || undefined
+
const inner = (
<>
-
-
+
+
{children}
@@ -81,7 +86,7 @@ export default async function RootLayout({children}) {
{plausibleUrl
- ? {inner}
+ ? {inner}
: inner}
diff --git a/app/paroles/[slug]/page.js b/app/paroles/[slug]/page.js
index 2c58daa..28a6748 100644
--- a/app/paroles/[slug]/page.js
+++ b/app/paroles/[slug]/page.js
@@ -1,3 +1,4 @@
+import {headers} from 'next/headers'
import {notFound} from 'next/navigation'
import Box from '@mui/material/Box'
@@ -71,6 +72,8 @@ export default async function AnPawolPaj(props) {
const {slug} = params
const anTeks = await jwennAnTeks(slug)
+ const requestHeaders = await headers()
+ const nonce = requestHeaders.get('x-nonce') || undefined
const {couverture} = anTeks
const teksKuvetiFormat = formatKuveti(couverture)
@@ -126,6 +129,7 @@ export default async function AnPawolPaj(props) {
diff --git a/app/theme-registy.js b/app/theme-registy.js
index 323a247..ba7af20 100644
--- a/app/theme-registy.js
+++ b/app/theme-registy.js
@@ -64,12 +64,12 @@ const theme = createTheme({
})
export default function ThemeRegistry(props) {
- const {children} = props
+ const {children, nonce} = props
return (
<>
-
-
+
+
@@ -81,5 +81,6 @@ export default function ThemeRegistry(props) {
}
ThemeRegistry.propTypes = {
- children: PropTypes.node.isRequired
+ children: PropTypes.node.isRequired,
+ nonce: PropTypes.string
}
diff --git a/components/top-loader.js b/components/top-loader.js
index 51fd0e4..60cf216 100644
--- a/components/top-loader.js
+++ b/components/top-loader.js
@@ -3,8 +3,13 @@
import PropTypes from 'prop-types'
import NextTopLoader from 'nextjs-toploader'
-export default function TopLoader({color}) {
- return
+export default function TopLoader({color, nonce}) {
+ return
+}
+
+TopLoader.propTypes = {
+ color: PropTypes.string.isRequired,
+ nonce: PropTypes.string
}
TopLoader.propTypes = {
diff --git a/middleware.js b/middleware.js
new file mode 100644
index 0000000..bbc610d
--- /dev/null
+++ b/middleware.js
@@ -0,0 +1,121 @@
+/* eslint-disable @next/next/no-server-import-in-page */
+import {NextResponse} from 'next/server'
+
+const nonceCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
+
+function generateNonce() {
+ const array = new Uint8Array(16)
+ crypto.getRandomValues(array)
+ let nonce = ''
+ for (const byte of array) {
+ nonce += nonceCharset[byte % 64]
+ }
+
+ return nonce
+}
+
+function extractOrigin(url) {
+ if (!url) {
+ return ''
+ }
+
+ try {
+ return new URL(url).origin
+ } catch {
+ return ''
+ }
+}
+
+function extractHostSource(raw) {
+ if (!raw) {
+ return ''
+ }
+
+ const [hostname, port] = raw.split(':')
+ if (!hostname) {
+ return ''
+ }
+
+ if (hostname === 'localhost' || hostname === '127.0.0.1') {
+ return `http://${hostname}${port ? `:${port}` : ''}`
+ }
+
+ return `https://${hostname}${port ? `:${port}` : ''}`
+}
+
+function buildCsp(nonce) {
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL || process.env.NEXT_PUBLIC_API_URL_ROOT || ''
+ const plausibleUrl = process.env.NEXT_PUBLIC_PLAUSIBLE_URL || ''
+ const mizikUrl = process.env.NEXT_PUBLIC_OKI_MIZIK_URL || ''
+ const imageDomainsRaw = process.env.NEXT_PUBLIC_DOMAINS_IMAGE || ''
+
+ const apiOrigin = extractOrigin(apiUrl)
+ const plausibleOrigin = extractOrigin(plausibleUrl)
+ const mizikOrigin = extractOrigin(mizikUrl)
+ const imageOrigins = imageDomainsRaw
+ .split(' ')
+ .filter(Boolean)
+ .map(entry => extractHostSource(entry))
+ .filter(Boolean)
+
+ const imageOriginsSet = new Set([apiOrigin, ...imageOrigins])
+ const imageOriginsUnique = [...imageOriginsSet].filter(Boolean)
+
+ const connectSrc = ['\'self\'', apiOrigin, plausibleOrigin, mizikOrigin].filter(Boolean)
+ const imgSrc = ['\'self\'', 'data:', 'blob:', ...imageOriginsUnique].filter(Boolean)
+ const mediaSrc = ['\'self\'', apiOrigin, mizikOrigin].filter(Boolean)
+
+ // Frame-src is intentionally broad: the application embeds user-supplied
+ // PeerTube instances in addition to known platforms (Tidal, Deezer, Spotify,
+ // Soundcloud, Apple Music). Restricting this to a fixed list would break
+ // user-generated content.
+ const frameSrc = ['\'self\'', 'https:']
+
+ const directives = [
+ 'default-src \'self\'',
+ `script-src 'nonce-${nonce}' 'strict-dynamic'`,
+ `style-src-elem 'self' 'nonce-${nonce}'`,
+ 'style-src-attr \'unsafe-inline\'',
+ `img-src ${imgSrc.join(' ')}`,
+ 'font-src \'self\'',
+ `connect-src ${connectSrc.join(' ')}`,
+ `frame-src ${frameSrc.join(' ')}`,
+ `media-src ${mediaSrc.join(' ')}`,
+ 'manifest-src \'self\'',
+ 'object-src \'none\'',
+ 'base-uri \'self\'',
+ 'form-action \'self\'',
+ 'frame-ancestors \'self\'',
+ 'upgrade-insecure-requests'
+ ]
+
+ return directives.join('; ')
+}
+
+export function middleware(request) {
+ const nonce = generateNonce()
+ const requestHeaders = new Headers(request.headers)
+ requestHeaders.set('x-nonce', nonce)
+
+ const response = NextResponse.next({
+ request: {
+ headers: requestHeaders
+ }
+ })
+
+ response.headers.set('Content-Security-Policy', buildCsp(nonce))
+
+ return response
+}
+
+export const config = {
+ matcher: [
+ {
+ source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
+ missing: [
+ {type: 'header', key: 'next-router-prefetch'},
+ {type: 'header', key: 'purpose', value: 'prefetch'}
+ ]
+ }
+ ]
+}
diff --git a/next.config.js b/next.config.js
index 2f5ac83..7e1c551 100644
--- a/next.config.js
+++ b/next.config.js
@@ -28,9 +28,49 @@ function buildRemotePatterns() {
return patterns
}
+async function headers() {
+ return [
+ {
+ source: '/:path*',
+ headers: [
+ {
+ key: 'Strict-Transport-Security',
+ value: 'max-age=63072000; includeSubDomains; preload'
+ },
+ {
+ key: 'X-Content-Type-Options',
+ value: 'nosniff'
+ },
+ {
+ key: 'X-Frame-Options',
+ value: 'SAMEORIGIN'
+ },
+ {
+ key: 'Referrer-Policy',
+ value: 'strict-origin-when-cross-origin'
+ },
+ {
+ key: 'Cross-Origin-Resource-Policy',
+ value: 'same-site'
+ },
+ {
+ key: 'Cross-Origin-Opener-Policy',
+ value: 'same-origin'
+ },
+ {
+ key: 'Permissions-Policy',
+ value: 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()'
+ }
+ ]
+ }
+ ]
+}
+
module.exports = (withPWA({
turbopack: {},
+ poweredByHeader: false,
images: {
remotePatterns: buildRemotePatterns()
- }
+ },
+ headers
}))