diff --git a/skill/scripts/detect-csp.mjs b/skill/scripts/detect-csp.mjs index 88ae27cb3..1a5664b4d 100644 --- a/skill/scripts/detect-csp.mjs +++ b/skill/scripts/detect-csp.mjs @@ -88,17 +88,45 @@ const NEXT_PROXY_FILES = new Set([ 'proxy.js', 'proxy.mjs', ]); +const NEXT_CONFIG_FILES = [ + 'next.config.js', + 'next.config.mjs', + 'next.config.cjs', + 'next.config.ts', + 'next.config.mts', + 'next.config.cts', +]; const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i; const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i; -function isNextRequestHookFile(relPath, base) { +function hasNextProjectMarker(projectRoot) { + if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true; + if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + return ['dependencies', 'devDependencies', 'peerDependencies'] + .some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next')); + } catch { + return false; + } +} + +function isNextRequestHookFile(root, absPath, relPath, base) { if (NEXT_MIDDLEWARE_FILES.has(base)) return true; if (!NEXT_PROXY_FILES.has(base)) return false; const normalized = relPath.split(path.sep).join('/').toLowerCase(); // Next.js 16 recognizes proxy at the project root or in the optional src/ - // directory, alongside app/ or pages/. A same-named helper deeper in the - // tree is not the framework request hook. - return normalized === base || normalized === `src/${base}`; + // directory, alongside app/ or pages/. The scan root is commonly a + // monorepo, so also accept that placement relative to a nested directory + // that carries a concrete Next.js project marker. A same-named helper + // elsewhere in the tree is not the framework request hook. + if (normalized === base || normalized === `src/${base}`) return true; + const hookDir = path.dirname(absPath); + const projectRoot = path.basename(hookDir).toLowerCase() === 'src' + ? path.dirname(hookDir) + : hookDir; + if (path.resolve(projectRoot) === path.resolve(root)) return true; + return hasNextProjectMarker(projectRoot); } /** @@ -154,7 +182,7 @@ export function detectCsp(cwd = process.cwd()) { // === detect-only shapes === - if (isNextRequestHookFile(relPath, base) && MIDDLEWARE_HINT.test(body)) { + if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) { hits.middleware.push(relPath); } diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs index 5d4e8825d..ab55a2348 100644 --- a/tests/framework-fixtures.test.mjs +++ b/tests/framework-fixtures.test.mjs @@ -286,20 +286,32 @@ for (const name of listFixtures()) { } describe('detectCsp — Next.js proxy placement', () => { - it('accepts root and src proxy files but ignores same-named nested helpers', () => { + it('accepts proxy files at app roots and src roots but ignores same-named helpers', () => { const source = `export function proxy() { const response = new Response(); response.headers.set('Content-Security-Policy', "script-src 'self'"); return response; }\n`; - for (const [relPath, expectedShape] of [ + for (const [relPath, expectedShape, markers = []] of [ ['proxy.ts', 'middleware'], ['src/proxy.ts', 'middleware'], + ['apps/web/proxy.ts', 'middleware', ['apps/web/app']], + ['apps/docs/src/proxy.ts', 'middleware', ['apps/docs/src/pages']], + ['apps/store/proxy.ts', 'middleware', ['apps/store/package.json']], ['lib/network/proxy.ts', null], + ['apps/web/lib/proxy.ts', null, ['apps/web/app']], ]) { const tmp = mkdtempSync(join(tmpdir(), 'impeccable-proxy-placement-')); try { mkdirSync(dirname(join(tmp, relPath)), { recursive: true }); + for (const marker of markers) { + if (marker.endsWith('package.json')) { + mkdirSync(dirname(join(tmp, marker)), { recursive: true }); + writeFileSync(join(tmp, marker), JSON.stringify({ dependencies: { next: '^16.0.0' } })); + } else { + mkdirSync(join(tmp, marker), { recursive: true }); + } + } writeFileSync(join(tmp, relPath), source); assert.equal(detectCsp(tmp).shape, expectedShape, relPath); } finally {