Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
31 changes: 27 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,14 +370,18 @@ 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
│ └── fixtures/ ← Vendored docs-example dist.tar.gz + single-page bundle
├── 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
```

---
Expand Down
25 changes: 11 additions & 14 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,32 @@ 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";
}

# ── 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 ──
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions nginx.headers.conf
Original file line number Diff line number Diff line change
@@ -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 <style> in
# Base.astro, the shadow-DOM compat styles and the mermaid bootstrap script
# accounted for, so it is not a one-line addition here.
16 changes: 16 additions & 0 deletions tests/fragment-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ if (!existsSync(DIST)) {

const app = express();

// nginx: include /etc/nginx/kb-headers.conf — the shared CORS + security set.
// Applied to every response, which is what the nginx config does now that each
// location declaring an add_header re-includes the snippet. Kept in sync with
// nginx.headers.conf; tests/nginx-config.spec.js asserts the nginx side.
app.use((_req, res, next) => {
res.set({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, x-web-fragment-id, x-fragment-mode',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'Referrer-Policy': 'strict-origin',
});
next();
});

// nginx: location ^~ /__wf/knowledge-base/ { rewrite ^/__wf/knowledge-base/(.*)$ /$1 }
// Map the fragment-asset prefix onto the normal page prefix so one static handler
// serves both (e.g. /__wf/knowledge-base/style.css → dist/style.css).
Expand Down
114 changes: 114 additions & 0 deletions tests/nginx-config.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* tests/nginx-config.spec.js
*
* Static checks on nginx.conf itself (no server, no browser).
*
* 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.
* That makes "add a Cache-Control to this location" a one-line change that
* silently strips CORS and every security header from those responses — which
* is exactly what had happened.
*
* The E2E suites cannot catch it: they run against tests/fragment-server.mjs,
* an Express mirror of the nginx *rewrites*, and no nginx is started anywhere in
* CI. So the config is asserted as text instead.
*/

import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CONF = readFileSync(join(ROOT, 'nginx.conf'), 'utf8');
const HEADERS_CONF = readFileSync(join(ROOT, 'nginx.headers.conf'), 'utf8');

const INCLUDE = 'include /etc/nginx/kb-headers.conf;';

/**
* Splits nginx.conf into its `location` blocks by brace depth.
* Good enough for this file, which has no nested locations.
*/
function locationBlocks(conf) {
const blocks = [];
const re = /location\s+([^{]+?)\s*\{/g;
let match;
while ((match = re.exec(conf)) !== null) {
let depth = 1;
let i = re.lastIndex;
while (i < conf.length && depth > 0) {
if (conf[i] === '{') depth++;
else if (conf[i] === '}') depth--;
i++;
}
blocks.push({ selector: match[1].trim(), body: conf.slice(re.lastIndex, i - 1) });
}
return blocks;
}

/** Strips `#` comments so assertions never match commentary. */
const uncomment = (text) => text.split('\n').map((l) => l.replace(/#.*$/, '')).join('\n');

test.describe('nginx.conf header inheritance', () => {
test('the shared header set is defined once, in nginx.headers.conf', () => {
const shared = uncomment(HEADERS_CONF);
for (const header of [
'Access-Control-Allow-Origin',
'Access-Control-Allow-Methods',
'Access-Control-Allow-Headers',
'X-Content-Type-Options',
'X-Frame-Options',
'Referrer-Policy',
]) {
expect(shared, `${header} must be in the shared snippet`).toContain(header);
expect(uncomment(CONF), `${header} must not be redeclared in nginx.conf`).not.toContain(header);
}
});

test('the server block includes the shared headers', () => {
expect(uncomment(CONF)).toContain(INCLUDE);
});

test('every location that declares add_header also includes the shared headers', () => {
const offenders = locationBlocks(uncomment(CONF))
.filter((block) => /\badd_header\b/.test(block.body))
.filter((block) => !block.body.includes(INCLUDE))
.map((block) => block.selector);

expect(
offenders,
'these location blocks declare add_header, which discards every inherited ' +
'add_header — they must include /etc/nginx/kb-headers.conf as well',
).toEqual([]);
});

test('X-Frame-Options is not DENY — it would block the web-fragments iframe', () => {
expect(uncomment(HEADERS_CONF)).toMatch(/X-Frame-Options\s+"SAMEORIGIN"/);
});

test('the deprecated X-XSS-Protection header is not set', () => {
expect(uncomment(CONF) + uncomment(HEADERS_CONF)).not.toContain('X-XSS-Protection');
});

test('healthz sets its content type with default_type, not a post-return add_header', () => {
const healthz = locationBlocks(uncomment(CONF)).find((b) => b.selector === '= /healthz');
expect(healthz, '/healthz location block').toBeTruthy();
expect(healthz.body).toContain('default_type text/plain;');
expect(healthz.body).not.toMatch(/add_header\s+Content-Type/);
});
});

test.describe('Dockerfile', () => {
const DOCKERFILE = readFileSync(join(ROOT, 'Dockerfile'), 'utf8');

test('runs an unprivileged nginx pinned by digest', () => {
const from = DOCKERFILE.split('\n').find((l) => l.startsWith('FROM '));
expect(from).toContain('nginx-unprivileged');
expect(from, 'base image must be pinned by digest, like the GitHub Actions are')
.toMatch(/@sha256:[a-f0-9]{64}/);
});

test('ships the shared header snippet the config includes', () => {
expect(DOCKERFILE).toContain('nginx.headers.conf /etc/nginx/kb-headers.conf');
});
});
27 changes: 27 additions & 0 deletions tests/standalone.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ test.describe('HTTP headers', () => {
const xfo = (res.headers()['x-frame-options'] ?? '').toUpperCase();
expect(xfo).not.toBe('DENY');
});

// The CORS and security headers must reach *every* response, not just the
// ones served by the catch-all. In nginx a location block declaring any
// add_header discards the inherited set, which had left both the static-asset
// block and the whole /knowledge-base/ prefix without them.
// nginx.conf is asserted directly in tests/nginx-config.spec.js; these check
// the paths end to end against the mirroring server.
for (const [label, path] of [
['a page', '/knowledge-base/'],
['a sub-app page', '/knowledge-base/user-guide/'],
['a static asset', '/knowledge-base/style.css'],
['a fragment-prefixed asset', '/__wf/knowledge-base/style.css'],
]) {
test(`serves CORS and security headers on ${label}`, async ({ request }) => {
const res = await request.get(path);
const headers = res.headers();
expect(headers['access-control-allow-origin']).toBe('*');
expect(headers['x-content-type-options']).toBe('nosniff');
expect(headers['referrer-policy']).toBe('strict-origin');
expect((headers['x-frame-options'] ?? '').toUpperCase()).toBe('SAMEORIGIN');
});
}

test('does not send the deprecated X-XSS-Protection header', async ({ request }) => {
const res = await request.get('/knowledge-base/');
expect(res.headers()['x-xss-protection']).toBeUndefined();
});
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down