security: remove style-src 'unsafe-inline' from all CSP policies (#197)
Some checks failed
CI / Lint, Typecheck & Unit Tests (push) Successful in 36s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (push) Successful in 1m2s
CI / E2E (Standalone single-file) (push) Successful in 1m55s
CI / E2E (Web) (push) Has been cancelled

Closes #193

Migrate three inline-style React props to CSS classes / CSSOM:
- ErrorExpansion: cursor:copy and copy-hint color moved to BEM classes
- SegmentedControl: dynamic transform now driven by --ec-segment-offset CSS var, set via useLayoutEffect + ref.style.setProperty

Remove 'unsafe-inline' from style-src in all three enforcement layers:
- vite.config.web.ts (prod only; dev keeps it for HMR)
- nginx.conf (both CSP directives)
- public/_headers (Cloudflare Pages)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
forgejo_admin 2026-05-23 00:06:28 +04:00
parent 4efdd770dd
commit 1c642f5b37
7 changed files with 25 additions and 16 deletions

View file

@ -23,7 +23,7 @@ http {
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; base-uri 'none'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; base-uri 'none'; frame-ancestors 'none'" always;
# Cache static assets (hashed filenames) for 1 year
location /assets/ {
@ -32,7 +32,7 @@ http {
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; base-uri 'none'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; base-uri 'none'; frame-ancestors 'none'" always;
}
# Service worker no cache (must always be fresh)

View file

@ -8,7 +8,7 @@
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; base-uri 'none'; frame-ancestors 'none'
Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'; base-uri 'none'; frame-ancestors 'none'
# Hashed assets are content-addressed; cache aggressively
/assets/*

View file

@ -57,18 +57,12 @@ export function ErrorExpansion({
return (
<div className="file-table__expansion">
<pre
className="file-table__error-text"
className="file-table__error-text file-table__error-text--copyable"
onClick={handleCopy}
style={{ cursor: "copy" }}
>
{error}
</pre>
<span
className="file-table__copy-hint"
style={{ color: "var(--ec-color-text-secondary)" }}
>
Click to copy
</span>
<span className="file-table__copy-hint">Click to copy</span>
</div>
);
}

View file

@ -1,3 +1,4 @@
import { useLayoutEffect, useRef } from "react";
import "../../styles/segmented_control.css";
interface SegmentOption<T extends string> {
@ -18,6 +19,14 @@ export function SegmentedControl<const T extends string>({
label: string;
}): React.JSX.Element {
const activeIndex = options.findIndex((o) => o.value === value);
const indicatorRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
indicatorRef.current?.style.setProperty(
"--ec-segment-offset",
`${activeIndex * 100}%`,
);
}, [activeIndex]);
function handleKeyDown(e: React.KeyboardEvent, index: number): void {
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
@ -54,10 +63,7 @@ export function SegmentedControl<const T extends string>({
<span className="segmented-control__label">{option.label}</span>
</button>
))}
<div
className="segmented-control__indicator"
style={{ transform: `translateX(${activeIndex * 100}%)` }}
/>
<div ref={indicatorRef} className="segmented-control__indicator" />
</div>
);
}

View file

@ -241,6 +241,14 @@
overflow-wrap: anywhere;
}
.file-table__error-text--copyable {
cursor: copy;
}
.file-table__copy-hint {
color: var(--ec-color-text-secondary);
}
.file-table__error-link {
color: var(--ec-color-accent);
word-break: break-all;

View file

@ -57,6 +57,7 @@
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
z-index: 0;
transform: translateX(var(--ec-segment-offset, 0%));
}
@media (prefers-reduced-motion: no-preference) {

View file

@ -28,7 +28,7 @@ function webCspPlugin(): Plugin {
const scriptSrc = isDev
? "'self' 'unsafe-inline' 'wasm-unsafe-eval'"
: "'self' 'wasm-unsafe-eval'";
const styleSrc = "'self' 'unsafe-inline'";
const styleSrc = isDev ? "'self' 'unsafe-inline'" : "'self'";
const connectSrc = isDev ? "'self' ws://localhost:*" : "'self'";
return [
{