diff --git a/docker/default.conf b/docker/default.conf index 4549f939..131d29c2 100644 --- a/docker/default.conf +++ b/docker/default.conf @@ -14,14 +14,24 @@ server { location / { try_files $uri $uri.html $uri/ =404; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always; + add_header Last-Modified ""; expires off; } error_page 404 /404.html; location = /404.html { internal; - add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always; + add_header Last-Modified ""; expires off; } } \ No newline at end of file diff --git a/docker/init_react_envs.sh b/docker/init_react_envs.sh index d1c5a182..43424a39 100644 --- a/docker/init_react_envs.sh +++ b/docker/init_react_envs.sh @@ -62,9 +62,93 @@ export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID} export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken} export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false} export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH} +export NETBIRD_CSP=${NETBIRD_CSP} +export NETBIRD_CSP_CONNECT_SRC=${NETBIRD_CSP_CONNECT_SRC} +export NETBIRD_DISABLE_CSP=${NETBIRD_DISABLE_CSP} echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}" +# Build CSP +if [[ "${NETBIRD_DISABLE_CSP}" == "true" ]]; then + echo "CSP disabled via NETBIRD_DISABLE_CSP" + sed -i '/add_header Content-Security-Policy/d' /etc/nginx/http.d/default.conf +else + FIRST_PARTY_CSP="https://pkgs.netbird.io" + FIRST_PARTY_CSP_CONNECT_SRC="$NETBIRD_CSP_CONNECT_SRC" + THIRD_PARTY_CSP="" + THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/" + THIRD_PARTY_CSP_SCRIPT_SRC="" + + CSP_DOMAINS="" + CSP_DOMAINS_CONNECT_SRC="" + + if [[ -n "${NETBIRD_CSP}" ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_CSP" + fi + + # Add AUTH_AUTHORITY to CSP + if [[ -n "${AUTH_AUTHORITY}" ]]; then + CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY" + fi + + # Add AUTH_AUDIENCE to CSP + if [[ -n "${AUTH_AUDIENCE}" && "${AUTH_AUDIENCE}" != "none" && "${AUTH_AUDIENCE}" == *.* ]]; then + if [[ "${AUTH_AUDIENCE}" == *"http://"* || "${AUTH_AUDIENCE}" == *"https://"* ]]; then + CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUDIENCE" + else + CSP_DOMAINS="$CSP_DOMAINS https://$AUTH_AUDIENCE" + fi + fi + + # Add NETBIRD_MGMT_API_ENDPOINT to CSP + if [[ -n "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then + MGMT_HOST=$(echo "$NETBIRD_MGMT_API_ENDPOINT" | sed -E 's|https?://||' | cut -d'/' -f1) + if [[ -n "$MGMT_HOST" ]]; then + if [[ "$NETBIRD_MGMT_API_ENDPOINT" == https://* ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$MGMT_HOST" + elif [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* ]]; then + CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$MGMT_HOST" + fi + fi + fi + + # Add LETSENCRYPT_DOMAIN to CSP + if [[ -n "${LETSENCRYPT_DOMAIN}" && "${LETSENCRYPT_DOMAIN}" != "none" ]]; then + if [[ "$LETSENCRYPT_DOMAIN" == *"localhost"* ]]; then + CSP_DOMAINS="$CSP_DOMAINS http://$LETSENCRYPT_DOMAIN" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$LETSENCRYPT_DOMAIN ws://*.$LETSENCRYPT_DOMAIN" + else + CSP_DOMAINS="$CSP_DOMAINS https://$LETSENCRYPT_DOMAIN" + CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$LETSENCRYPT_DOMAIN wss://*.$LETSENCRYPT_DOMAIN" + fi + fi + + CSP_CONNECT_SRC="$CSP_DOMAINS $CSP_DOMAINS_CONNECT_SRC $FIRST_PARTY_CSP $FIRST_PARTY_CSP_CONNECT_SRC $THIRD_PARTY_CSP $THIRD_PARTY_CSP_CONNECT_SRC" + CSP_FRAME_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP" + CSP_SCRIPT_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP $THIRD_PARTY_CSP_SCRIPT_SRC" + + # Remove duplicates + CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' | sed 's/ $//') + CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' | sed 's/ $//') + CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' | sed 's/ $//') + + # Update CSP in nginx config + CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" + CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;" + + # Escape sed special characters in replacement string (& | \) + CSP_HEADER=$(printf '%s' "$CSP_HEADER" | sed -e 's/[\\&|]/\\&/g') + + echo "CSP header: $CSP_HEADER" + + # Replace CSP header in nginx config + sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /etc/nginx/http.d/default.conf || { + echo "Failed to replace CSP header" + } +fi + # replace ENVs in the config ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH" @@ -74,4 +158,7 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do cp "$f" "$f".copy envsubst "$ENV_STR" < "$f".copy > "$f" rm "$f".copy -done \ No newline at end of file +done + +# Reload nginx to pick up the updated CSP and config +nginx -s reload \ No newline at end of file diff --git a/package.json b/package.json index 8c4817f9..828e9ec8 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev": "next dev -p 3000", "turbo": "next dev -p 3000 --turbo", "build": "next build", + "postbuild": "node postbuild.js", "start": "next start", "lint": "next lint", "cypress:open": "cypress open" diff --git a/postbuild.js b/postbuild.js new file mode 100644 index 00000000..036348ab --- /dev/null +++ b/postbuild.js @@ -0,0 +1,96 @@ +const { resolve, join } = require("path"); +const { createHash } = require("crypto"); +const { + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, +} = require("fs"); + +process.env.NODE_ENV = "production"; +const PLACEHOLDER = "NB_INLINE_SCRIPT_PLACEHOLDER"; +console.log("Starting post-build script to extract inline scripts..."); + +// Function to find HTML files recursively +function findHtmlFiles(dir) { + const files = []; + const entries = readdirSync(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + files.push(...findHtmlFiles(fullPath)); + } else if (entry.endsWith(".html")) { + files.push(fullPath); + } + } + + return files; +} + +// For Next.js export output, the files are in the 'out' directory +const baseDir = resolve("out"); +const htmlFiles = findHtmlFiles(baseDir); + +console.log(`Found ${htmlFiles.length} .html files to process`); + +// Ensure assets directory exists +const assetsDir = `${baseDir}/assets`; +mkdirSync(assetsDir, { recursive: true }); + +htmlFiles.forEach((file) => { + // Read file contents + const contents = readFileSync(file, "utf8"); + const scripts = []; + + // Extract inline scripts + const newFile = contents.replace( + /]*src)([^>]*)>(.+?)<\/script>/gs, + (match, attributes, scriptContent) => { + // Skip if script has src attribute (external script) + if (attributes.includes("src=")) { + return match; + } + + const addPlaceholderString = scripts.length === 0; + const cleanedScript = scriptContent.trim(); + + if (cleanedScript) { + scripts.push( + `${cleanedScript}${cleanedScript.endsWith(";") ? "" : ";"}`, + ); + } + + return addPlaceholderString ? PLACEHOLDER : ""; + }, + ); + + // Early exit if no inline scripts found + if (!scripts.length) { + console.log(`No inline scripts found`); + return; + } + + // Combine scripts and create hash + const chunk = scripts.join("\n"); + const hash = createHash("md5").update(chunk).digest("hex").slice(0, 8); + const chunkFileName = `chunk.${hash}.js`; + const chunkPath = `${assetsDir}/${chunkFileName}`; + + // Write the chunk file + writeFileSync(chunkPath, chunk, "utf8"); + + // Replace placeholder string with script tag + const updatedFile = newFile.replace( + PLACEHOLDER, + ``, + ); + + // Write updated HTML file + writeFileSync(file, updatedFile, "utf8"); +}); + +console.log("Post-build script completed successfully!");