diff --git a/.dockerignore b/.dockerignore index 9f3a11e..b65605c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ -# The Docker image only needs nginx.conf + the prebuilt dist/ (see Dockerfile). +# The Docker image only needs nginx.conf, nginx.headers.conf and the prebuilt +# dist/ (see Dockerfile). # Keep the build context small and avoid leaking source/secrets into the image. node_modules .git diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 463a3b8..916a98a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ # typecheck — astro check (TypeScript / Astro type errors) # build — headless build from the vendored fixture; uploads dist/ # e2e — Playwright: embedded web-fragment harness + standalone layer +# image — docker build of the runtime image + Trivy scan # audit — npm dependency vulnerability gate name: CI @@ -160,7 +161,37 @@ jobs: - run: npm run selftest working-directory: actions/publish-single-page-docs - # ── 5. Dependency audit ──────────────────────────────────────────────────── + # ── 5. Container image ───────────────────────────────────────────────────── + # + # Builds the runtime image from the dist/ the build job produced, then scans + # it. `npm audit` below covers JS dependencies only — nothing else in CI looks + # at the nginx base image, which is what the digest pin in the Dockerfile + # exists to control. CRITICAL-only so a routine base-image CVE does not block + # unrelated PRs; the fix is to bump the pinned digest. + image: + name: Image build + scan + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Download dist artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist + # Also proves the Dockerfile's dist/ sanity checks pass on a real build. + - name: Build image + run: docker build -t knowledge-base:ci . + - name: Scan image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: knowledge-base:ci + format: table + exit-code: '1' + ignore-unfixed: true + severity: CRITICAL + + # ── 6. Dependency audit ──────────────────────────────────────────────────── audit: name: npm audit runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index d1af4af..245d090 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,33 @@ -FROM nginx:1.27-alpine AS runtime +# Runtime image: nginx serving the prebuilt static site. +# +# nginx-unprivileged rather than the stock nginx image: this container serves +# static files on port 8080 and needs no privileged port, so there is no reason +# for the master process to run as root. This variant already listens on 8080 +# and runs as UID 101. +# +# Pinned by digest, matching how every GitHub Action in .github/workflows is +# pinned. Dependabot bumps the tag; the digest keeps the deployment from moving +# underneath it in the meantime. +FROM nginxinc/nginx-unprivileged:1.29-alpine@sha256:0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6 AS runtime -# Remove default nginx config -RUN rm /etc/nginx/conf.d/default.conf +# Overwrite the stock config rather than `RUN rm`-ing it: this image drops to a +# non-root user, which cannot delete files under /etc/nginx. +COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY nginx.conf /etc/nginx/conf.d/marketplace.conf +# The shared CORS + security header set, included by nginx.conf. Lives outside +# conf.d/ because nginx loads conf.d/*.conf as top-level server configuration +# and this is a fragment, not a server block. +COPY nginx.headers.conf /etc/nginx/kb-headers.conf + +# `dist/` is built outside the image (npm run build / build:headless) and is not +# reproducible from this Dockerfile alone — see README. Fail loudly here rather +# than shipping an image that 404s, which is what a missing or half-built dist +# would otherwise produce at runtime. COPY dist /usr/share/nginx/html +RUN test -f /usr/share/nginx/html/index.html \ + || (echo "dist/ has no index.html — run 'npm run build:headless' before docker build" >&2; exit 1) +RUN test -f /usr/share/nginx/html/style.css \ + || (echo "dist/ has no style.css — sub-app pages reference it via /__wf/knowledge-base/style.css" >&2; exit 1) EXPOSE 8080 diff --git a/README.md b/README.md index 9688f56..941d347 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,8 @@ knowledge-base/ │ ├── web-fragment.spec.js ← Embedded harness suite │ ├── standalone.spec.js ← Standalone fragment-server suite │ ├── build-integrity.spec.js +│ ├── artifact-safety.spec.js ← Tarball extraction guards +│ ├── nginx-config.spec.js ← nginx header-inheritance guard │ ├── host/server.mjs ← Reference web-fragments host (gateway) │ ├── fragment-server.mjs ← nginx-mirroring static server │ ├── support/fragment.js ← Shadow-DOM test helpers @@ -377,7 +379,9 @@ knowledge-base/ ├── contract/ ← marketplace.json schema + rules + style guide ├── .github/workflows/ ← ci.yml, validate-doc-app.yml ├── Dockerfile -└── nginx.conf +├── nginx.conf ← server block (rewrites, caching, routing) +└── nginx.headers.conf ← shared CORS + security headers, included by + every block in nginx.conf that sets a header ``` --- diff --git a/nginx.conf b/nginx.conf index f306e59..78bb401 100644 --- a/nginx.conf +++ b/nginx.conf @@ -22,27 +22,21 @@ server { font/woff2; # Omit text/html — the fragment gateway doesn't benefit from it - # ── CORS — required for web-fragment fetch from a different origin ──────── - # The fragment HTML is fetched cross-origin by the host app (e.g. localhost - # in dev, the data-gateway domain in production). Static content carries no - # credentials so a wildcard origin is safe here. - add_header Access-Control-Allow-Origin "*" always; - add_header Access-Control-Allow-Methods "GET, OPTIONS" always; - add_header Access-Control-Allow-Headers "Content-Type, x-web-fragment-id, x-fragment-mode" always; + # ── Shared CORS + security headers ──────────────────────────────────────── + # IMPORTANT: add_header does not merge across levels. Any location block + # below that declares an add_header of its own MUST include this file too, + # or it silently serves responses with no CORS and no security headers. + # See nginx.headers.conf. Enforced by tests/nginx-config.spec.js. + include /etc/nginx/kb-headers.conf; # Handle OPTIONS preflight without hitting try_files if ($request_method = OPTIONS) { return 204; } - # ── Security headers ────────────────────────────────────────────────────── - add_header X-Content-Type-Options "nosniff" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin" always; - # ── Long cache for immutable assets (hashed filenames) ─────────────────── location ~* \.(css|js|woff2?|ttf|eot|ico|svg|png|jpg|gif|webp)$ { + include /etc/nginx/kb-headers.conf; expires 1y; add_header Cache-Control "public, immutable"; } @@ -50,8 +44,10 @@ server { # ── Health check endpoint ──────────────────────────────────────────────── location = /healthz { access_log off; + # default_type, not add_header: a header added after `return` never + # reaches the response. + default_type text/plain; return 200 "ok\n"; - add_header Content-Type text/plain; } # ── Knowledge base assets: /__wf/knowledge-base/* → serve from dist root ── @@ -68,6 +64,7 @@ server { # Handles both the data-gateway fragment path and the dedicated hostname. # Strip the /knowledge-base/ prefix so files resolve from the dist root. location ^~ /knowledge-base/ { + include /etc/nginx/kb-headers.conf; rewrite ^/knowledge-base/(.*)$ /$1 break; # Tell any intermediate proxy (e.g. web-fragments FragmentGateway) not to # transcode/re-encode this response. Prevents Content-Encoding header diff --git a/nginx.headers.conf b/nginx.headers.conf new file mode 100644 index 0000000..facd0d0 --- /dev/null +++ b/nginx.headers.conf @@ -0,0 +1,39 @@ +# Shared response headers — CORS + security. +# +# WHY THIS IS A SEPARATE FILE +# +# nginx's `add_header` does not merge across configuration levels: a block that +# declares *any* add_header discards every add_header inherited from its parent. +# So a `location` that only wants to set Cache-Control silently drops the whole +# CORS and security header set declared at `server` level. +# +# Every block in nginx.conf that declares an add_header of its own therefore +# includes this file, and this file is the single place the shared set is +# defined. Adding a header here reaches every response; adding one directly to a +# location block instead is what causes the bug. +# +# Lives outside conf.d/ deliberately — nginx loads conf.d/*.conf as top-level +# server configuration, and this is a fragment, not a server block. + +# ── CORS — required for web-fragment fetch from a different origin ──────────── +# The fragment HTML is fetched cross-origin by the host app (e.g. localhost in +# dev, the data-gateway domain in production). Static content carries no +# credentials so a wildcard origin is safe here. +add_header Access-Control-Allow-Origin "*" always; +add_header Access-Control-Allow-Methods "GET, OPTIONS" always; +add_header Access-Control-Allow-Headers "Content-Type, x-web-fragment-id, x-fragment-mode" always; + +# ── Security ───────────────────────────────────────────────────────────────── +# X-Frame-Options stays SAMEORIGIN rather than DENY: web-fragments isolates the +# fragment's JS context in a hidden iframe, and DENY blocks it — the fragment +# then fails to load with no error. Asserted in tests/standalone.spec.js. +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header Referrer-Policy "strict-origin" always; + +# Deliberately NOT set: X-XSS-Protection. The legacy XSS auditor is gone from +# every current browser, and the header has a history of introducing +# vulnerabilities rather than preventing them. Content-Security-Policy is the +# replacement and is tracked separately (#42) — it needs the inline