From 1adef63a36ea18cf0605a1d0b08b4625614c2fc4 Mon Sep 17 00:00:00 2001 From: biosxxx Date: Sun, 16 Aug 2026 13:50:17 +0300 Subject: [PATCH] Scope camera access to QR Master instead of the whole site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Permissions-Policy header granted camera=(self) on every response. The reason was one layer down: UtilityShellPage defaulted iframeAllow to "camera 'self'", so all 28 embedded utilities asked for a camera and the header had to allow it site-wide for the one tool that uses it. QR Master is that tool — it scans through html5-qrcode with facingMode: "environment". The other two getUserMedia hits under static/utility-apps are Emscripten's SDL shim in the metal-bending worker and in the OpenCascade glue, dead code in both builds. So: default the delegation to nothing, name the camera on QR Master, and set the header per path. 26 utility pages now render allow="", one asks for the camera, one for the microphone. The header is the part that actually enforces this. A same-origin iframe inherits any feature whose default allowlist is 'self' regardless of its allow attribute, so narrowing the attribute alone would have changed nothing; it encodes intent, and starts mattering the day an app is served cross-origin. Also fixes a live gap in the whisper-transcriber rules, which matched /utilities/whisper-transcriber/ but not /ru/utilities/whisper-transcriber/ or the other four locales — so the microphone was denied on five of six languages. Both tools now match an optional locale segment. Verified with path-to-regexp 6.3.0, the version behind Vercel's routing: the pattern matches all six locale paths and still rejects /docs/utilities/qr-master/, /utilities/qr-nameplate/ and a bogus /xx/ prefix. Whisper also no longer claims camera=(self) in its own rule; it only ever needed the microphone, and it carried camera along because these rules replace the global header rather than merge with it. Verified on a six-locale build: qr-master renders allow="camera 'self'" in both / and /ru/, whisper renders allow="microphone 'self'", and pipe-cutter — which used to inherit the camera — renders allow="". Co-Authored-By: Claude Opus 5 --- src/components/Utilities/UtilityShellPage.tsx | 5 +++- src/data/utilityShellPages.tsx | 9 ++++++ vercel.json | 28 +++++++++++++++---- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/components/Utilities/UtilityShellPage.tsx b/src/components/Utilities/UtilityShellPage.tsx index 35357b0..f39f8a7 100644 --- a/src/components/Utilities/UtilityShellPage.tsx +++ b/src/components/Utilities/UtilityShellPage.tsx @@ -51,7 +51,10 @@ export default function UtilityShellPage({tool, ...config}: UtilityShellPageProp features, scriptType = 'module', appPath, - iframeAllow = "camera 'self'", + // Delegate nothing unless a tool asks for it. The default used to be + // "camera 'self'", which handed the camera to every embedded utility and + // forced the Permissions-Policy header to allow it site-wide. + iframeAllow = '', } = config; const iframeSrc = useBaseUrl(appPath ?? `/utility-apps/${slug}/app.html`); diff --git a/src/data/utilityShellPages.tsx b/src/data/utilityShellPages.tsx index 0452d97..1ea9a65 100644 --- a/src/data/utilityShellPages.tsx +++ b/src/data/utilityShellPages.tsx @@ -47,6 +47,13 @@ export type UtilityPageConfig = { * Permissions delegated to the embedded iframe. Capabilities are granted per * utility, never site-wide: only tools that actually need a device get it, * and the matching Permissions-Policy path rule lives in vercel.json. + * + * That rule comes in pairs, and both halves are required: one for the shell + * page, because a same-origin iframe can never hold a capability its parent + * document lacks, and one for /utility-apps//. The shell-page rule also + * has to carry an optional locale segment — the page exists at + * /utilities// and at /ru/utilities// and four more besides, and + * a rule written without it silently covers English only. */ iframeAllow?: string; }; @@ -197,6 +204,8 @@ export const utilityPageConfigs: Record = { 'Local history with import/export', ], scriptType: 'module', + // Live scanning: the only utility on the site that needs a camera. + iframeAllow: "camera 'self'", }, 'pdf-bom-extractor': { slug: 'pdf-bom-extractor', diff --git a/vercel.json b/vercel.json index 2bfcbde..d7f9b0f 100644 --- a/vercel.json +++ b/vercel.json @@ -22,7 +22,7 @@ }, { "key": "Permissions-Policy", - "value": "camera=(self), microphone=(), geolocation=()" + "value": "camera=(), microphone=(), geolocation=()" }, { "key": "Content-Security-Policy", @@ -31,11 +31,11 @@ ] }, { - "source": "/utilities/whisper-transcriber/:path*", + "source": "/:locale(ru|ua|de|es|et)?/utilities/whisper-transcriber/:path*", "headers": [ { "key": "Permissions-Policy", - "value": "camera=(self), microphone=(self), geolocation=()" + "value": "camera=(), microphone=(self), geolocation=()" } ] }, @@ -44,7 +44,25 @@ "headers": [ { "key": "Permissions-Policy", - "value": "camera=(self), microphone=(self), geolocation=()" + "value": "camera=(), microphone=(self), geolocation=()" + } + ] + }, + { + "source": "/:locale(ru|ua|de|es|et)?/utilities/qr-master/:path*", + "headers": [ + { + "key": "Permissions-Policy", + "value": "camera=(self), microphone=(), geolocation=()" + } + ] + }, + { + "source": "/utility-apps/qr-master/:path*", + "headers": [ + { + "key": "Permissions-Policy", + "value": "camera=(self), microphone=(), geolocation=()" } ] }, @@ -125,4 +143,4 @@ "permanent": true } ] -} \ No newline at end of file +}