security: harden static site deployment

This commit is contained in:
Fi3w0 2026-08-11 17:16:48 +02:00
parent 57636e230a
commit f8ac123283
12 changed files with 774 additions and 1114 deletions

View file

@ -6,5 +6,11 @@ dist
node_modules
npm-debug.log*
.DS_Store
.env
.env*
!.env.example
.github
.openai
.agents
.codex
*.md
DEPLOY.md

View file

@ -6,9 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>404 - Fi3w0</title>
<meta name="robots" content="noindex">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&amp;family=IBM+Plex+Mono:wght@400;500;600&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css?v=4">
<link rel="stylesheet" href="/css/editorial.css?v=4">
<link rel="icon" href="/icon.svg" type="image/svg+xml">

View file

@ -1,6 +1,8 @@
# Server deployment
The production image builds the Webpack site with Node and serves the generated `dist/` directory with Nginx. The container does not publish a host port; Traefik reaches it over an external Docker network.
The production image builds the Webpack site with Node and serves the generated `dist/` directory with an unprivileged Nginx process on port `8080`. The container does not publish a host port; Traefik reaches it over an external Docker network.
The runtime is read-only, drops every Linux capability, cannot gain new privileges, uses a bounded in-memory `/tmp`, and has CPU, memory, process, and log limits. Browser security headers are set by Nginx, and the site makes no third-party font, icon, or script requests.
## Defaults

View file

@ -1,21 +1,29 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS build
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
RUN npm ci --ignore-scripts --no-audit --no-fund
COPY . .
COPY webpack.common.js webpack.config.prod.js ./
COPY index.html 404.html favicon.ico icon.png icon.svg robots.txt site.webmanifest ./
COPY css ./css
COPY img ./img
COPY js ./js
RUN npm run build
FROM nginx:1.27-alpine AS runtime
FROM nginxinc/nginx-unprivileged:1.29.4-alpine AS runtime
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
COPY --chown=101:101 deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --chown=101:101 --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
USER 101:101
EXPOSE 8080
STOPSIGNAL SIGQUIT
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1

View file

@ -16,8 +16,8 @@
--cyan: #8fd4d0;
--lime: #c8ee80;
--yellow: #f3d277;
--display: "Archivo Black", "Arial Black", sans-serif;
--mono: "IBM Plex Mono", "Courier New", monospace;
--display: "Arial Black", "Helvetica Neue", system-ui, sans-serif;
--mono: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--page-pad: clamp(1.25rem, 4vw, 4rem);
--max-width: 92rem;
--radius-sm: 0;

View file

@ -16,8 +16,8 @@
--cyan: #8fd4d0;
--lime: #c8ee80;
--yellow: #f3d277;
--display: "Syne", "Avenir Next", sans-serif;
--mono: "IBM Plex Mono", "Courier New", monospace;
--display: "Arial Black", "Helvetica Neue", system-ui, sans-serif;
--mono: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--page-pad: clamp(1.25rem, 4vw, 4.5rem);
--max-width: 96rem;
--radius-sm: 0.5rem;

View file

@ -1,18 +1,28 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 8080 default_server;
listen [::]:8080 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
server_tokens off;
autoindex off;
access_log off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'" always;
add_header X-Permitted-Cross-Domain-Policies "none" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; media-src 'self'; manifest-src 'self'; worker-src 'none'; upgrade-insecure-requests" always;
if ($request_method !~ ^(GET|HEAD)$) {
return 405;
}
gzip on;
gzip_vary on;

View file

@ -2,30 +2,39 @@ name: fiws-page
services:
fiws-page:
image: fiws-page:latest
container_name: fiws-page
image: "${IMAGE_NAME:-fiws-page}:${IMAGE_TAG:-latest}"
build:
context: .
dockerfile: Dockerfile
target: runtime
restart: unless-stopped
init: true
user: "101:101"
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp
- /tmp:rw,noexec,nosuid,nodev,size=16m,uid=101,gid=101,mode=1770
pids_limit: 64
mem_limit: 128m
cpus: 0.50
stop_grace_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
expose:
- "80"
- "8080"
networks:
- proxy
labels:
traefik.enable: "true"
traefik.docker.network: "${TRAEFIK_NETWORK:-proxy}"
traefik.http.services.fiws-page.loadbalancer.server.port: "80"
traefik.http.services.fiws-page.loadbalancer.server.port: "8080"
traefik.http.routers.fiws-page.rule: "Host(`${DOMAIN:-fiwlabs.dev}`)"
traefik.http.routers.fiws-page.entrypoints: "${TRAEFIK_HTTPS_ENTRYPOINT:-websecure}"

View file

@ -16,9 +16,6 @@
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="https://fiwlabs.dev/">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&amp;family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&amp;display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css?v=9">
<link rel="stylesheet" href="css/editorial.css?v=9">
@ -108,39 +105,39 @@
<div class="signal-strip" aria-label="Core interests">
<div class="signal-track">
<div class="signal-set">
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/archlinux/11171a" alt="" loading="lazy" onerror="this.remove()">Arch Linux</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/ubuntu/11171a" alt="" loading="lazy" onerror="this.remove()">Ubuntu Server</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/docker/11171a" alt="" loading="lazy" onerror="this.remove()">Docker</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/modrinth/11171a" alt="" loading="lazy" onerror="this.remove()">Minecraft tooling</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/wayland/11171a" alt="" loading="lazy" onerror="this.remove()">Wayland</span><i></i>
<span class="signal-item">Arch Linux</span><i></i>
<span class="signal-item">Ubuntu Server</span><i></i>
<span class="signal-item">Docker</span><i></i>
<span class="signal-item">Minecraft tooling</span><i></i>
<span class="signal-item">Wayland</span><i></i>
<span class="signal-item"><img class="signal-icon" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22%2311171a%22%3E%3Cpath%20d%3D%22M12%202C7.03%202%203%203.34%203%205s4.03%203%209%203%209-1.34%209-3-4.03-3-9-3z%22%2F%3E%3Cpath%20d%3D%22M3%208.2v3.3c0%201.66%204.03%203%209%203s9-1.34%209-3V8.2c-1.6%201.28-5.1%202.3-9%202.3s-7.4-1.02-9-2.3z%22%2F%3E%3Cpath%20d%3D%22M3%2013.7V17c0%201.66%204.03%203%209%203s9-1.34%209-3v-3.3c-1.6%201.28-5.1%202.3-9%202.3s-7.4-1.02-9-2.3z%22%2F%3E%3C%2Fsvg%3E" alt="">Self-hosting</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/json/11171a" alt="" loading="lazy" onerror="this.remove()">Things configured with JSON</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/gnubash/11171a" alt="" loading="lazy" onerror="this.remove()">Bash</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/rust/11171a" alt="" loading="lazy" onerror="this.remove()">Rust</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/go/11171a" alt="" loading="lazy" onerror="this.remove()">Go</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/lua/11171a" alt="" loading="lazy" onerror="this.remove()">Lua</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/kotlin/11171a" alt="" loading="lazy" onerror="this.remove()">Kotlin</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/typescript/11171a" alt="" loading="lazy" onerror="this.remove()">TypeScript</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/githubactions/11171a" alt="" loading="lazy" onerror="this.remove()">GitHub Actions</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/ffmpeg/11171a" alt="" loading="lazy" onerror="this.remove()">FFmpeg</span><i></i>
<span class="signal-item">Things configured with JSON</span><i></i>
<span class="signal-item">Bash</span><i></i>
<span class="signal-item">Rust</span><i></i>
<span class="signal-item">Go</span><i></i>
<span class="signal-item">Lua</span><i></i>
<span class="signal-item">Kotlin</span><i></i>
<span class="signal-item">TypeScript</span><i></i>
<span class="signal-item">GitHub Actions</span><i></i>
<span class="signal-item">FFmpeg</span><i></i>
<span class="signal-item"><img class="signal-icon" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%229.6%22%20fill%3D%22none%22%20stroke%3D%22%2311171a%22%20stroke-width%3D%221.4%22%2F%3E%3Cpath%20d%3D%22M12%2C2.4%20A9.6%2C9.6%200%200%2C1%2012%2C21.6%20Z%22%20fill%3D%22%2311171a%22%2F%3E%3Ccircle%20cx%3D%228%22%20cy%3D%229.6%22%20r%3D%221.15%22%20fill%3D%22%2311171a%22%2F%3E%3Cpath%20d%3D%22M6.4%2013.8%20Q8.3%2016.6%2010.6%2014.5%22%20fill%3D%22none%22%20stroke%3D%22%2311171a%22%20stroke-width%3D%221.4%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E" alt="">macOS</span><i></i>
</div>
<div class="signal-set" aria-hidden="true">
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/archlinux/11171a" alt="" loading="lazy" onerror="this.remove()">Arch Linux</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/ubuntu/11171a" alt="" loading="lazy" onerror="this.remove()">Ubuntu Server</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/docker/11171a" alt="" loading="lazy" onerror="this.remove()">Docker</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/modrinth/11171a" alt="" loading="lazy" onerror="this.remove()">Minecraft tooling</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/wayland/11171a" alt="" loading="lazy" onerror="this.remove()">Wayland</span><i></i>
<span class="signal-item">Arch Linux</span><i></i>
<span class="signal-item">Ubuntu Server</span><i></i>
<span class="signal-item">Docker</span><i></i>
<span class="signal-item">Minecraft tooling</span><i></i>
<span class="signal-item">Wayland</span><i></i>
<span class="signal-item"><img class="signal-icon" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22%2311171a%22%3E%3Cpath%20d%3D%22M12%202C7.03%202%203%203.34%203%205s4.03%203%209%203%209-1.34%209-3-4.03-3-9-3z%22%2F%3E%3Cpath%20d%3D%22M3%208.2v3.3c0%201.66%204.03%203%209%203s9-1.34%209-3V8.2c-1.6%201.28-5.1%202.3-9%202.3s-7.4-1.02-9-2.3z%22%2F%3E%3Cpath%20d%3D%22M3%2013.7V17c0%201.66%204.03%203%209%203s9-1.34%209-3v-3.3c-1.6%201.28-5.1%202.3-9%202.3s-7.4-1.02-9-2.3z%22%2F%3E%3C%2Fsvg%3E" alt="">Self-hosting</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/json/11171a" alt="" loading="lazy" onerror="this.remove()">Things configured with JSON</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/gnubash/11171a" alt="" loading="lazy" onerror="this.remove()">Bash</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/rust/11171a" alt="" loading="lazy" onerror="this.remove()">Rust</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/go/11171a" alt="" loading="lazy" onerror="this.remove()">Go</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/lua/11171a" alt="" loading="lazy" onerror="this.remove()">Lua</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/kotlin/11171a" alt="" loading="lazy" onerror="this.remove()">Kotlin</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/typescript/11171a" alt="" loading="lazy" onerror="this.remove()">TypeScript</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/githubactions/11171a" alt="" loading="lazy" onerror="this.remove()">GitHub Actions</span><i></i>
<span class="signal-item"><img class="signal-icon" src="https://cdn.simpleicons.org/ffmpeg/11171a" alt="" loading="lazy" onerror="this.remove()">FFmpeg</span><i></i>
<span class="signal-item">Things configured with JSON</span><i></i>
<span class="signal-item">Bash</span><i></i>
<span class="signal-item">Rust</span><i></i>
<span class="signal-item">Go</span><i></i>
<span class="signal-item">Lua</span><i></i>
<span class="signal-item">Kotlin</span><i></i>
<span class="signal-item">TypeScript</span><i></i>
<span class="signal-item">GitHub Actions</span><i></i>
<span class="signal-item">FFmpeg</span><i></i>
<span class="signal-item"><img class="signal-icon" src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%229.6%22%20fill%3D%22none%22%20stroke%3D%22%2311171a%22%20stroke-width%3D%221.4%22%2F%3E%3Cpath%20d%3D%22M12%2C2.4%20A9.6%2C9.6%200%200%2C1%2012%2C21.6%20Z%22%20fill%3D%22%2311171a%22%2F%3E%3Ccircle%20cx%3D%228%22%20cy%3D%229.6%22%20r%3D%221.15%22%20fill%3D%22%2311171a%22%2F%3E%3Cpath%20d%3D%22M6.4%2013.8%20Q8.3%2016.6%2010.6%2014.5%22%20fill%3D%22none%22%20stroke%3D%22%2311171a%22%20stroke-width%3D%221.4%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E" alt="">macOS</span><i></i>
</div>
</div>

View file

@ -241,12 +241,13 @@ if (gooeyNav && gooeyLinks.length && !reducedMotion) {
});
}
// --- ASCIIText, ported from React Bits (vanilla JS, three.js loaded on
// demand). Replaces the flat "TIDE" watermark on the TideWM card with a
// --- ASCIIText, ported from React Bits (vanilla JS, three.js bundled and
// loaded on demand). Replaces the flat "TIDE" watermark on the TideWM card with a
// rippling, wave-distorted ASCII render once the card scrolls into view.
// The plain-text watermark stays in the DOM as the fallback if WebGL or the
// three.js CDN load ever fails. ---
// three.js module load ever fails. ---
(() => {
const ASCII_FONT_STACK = '"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace';
const targets = [
{
host: document.querySelector("#tide-ascii"),
@ -285,18 +286,13 @@ if (gooeyNav && gooeyLinks.length && !reducedMotion) {
}
if (!hasWebGL) return;
const THREE_CDN_URL = "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js";
let threeLoadPromise = null;
const loadThree = () => {
if (window.THREE) return Promise.resolve();
if (!threeLoadPromise) {
threeLoadPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = THREE_CDN_URL;
script.onload = () => resolve();
script.onerror = () => reject(new Error("three.js failed to load"));
document.head.append(script);
threeLoadPromise = import("three").then((three) => {
window.THREE = three;
});
}
return threeLoadPromise;
@ -350,7 +346,7 @@ if (gooeyNav && gooeyLinks.length && !reducedMotion) {
this.context = this.canvas.getContext("2d");
this.domElement.append(this.canvas);
this.fontSize = fontSize ?? 8;
this.fontFamily = fontFamily ?? "'IBM Plex Mono', monospace";
this.fontFamily = fontFamily ?? ASCII_FONT_STACK;
this.charset = charset ?? " .'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$";
// Optional per-cell character flicker — a subtle "decoding" glitch
// where a handful of glyphs jump to a denser/lighter char and settle
@ -476,12 +472,7 @@ if (gooeyNav && gooeyLinks.length && !reducedMotion) {
this.scene = new window.THREE.Scene();
}
async init() {
try {
await document.fonts.load(`600 200px "IBM Plex Mono"`);
} catch {
// fall back to default font metrics if the webfont isn't ready yet
}
init() {
this.setMesh();
this.setRenderer();
}
@ -490,7 +481,7 @@ if (gooeyNav && gooeyLinks.length && !reducedMotion) {
const THREE = window.THREE;
this.textCanvas = new CanvasTxt(this.textString, {
fontSize: this.textFontSize,
fontFamily: "IBM Plex Mono",
fontFamily: ASCII_FONT_STACK,
color: this.textColor,
});
this.textCanvas.resize();
@ -1159,7 +1150,7 @@ if (gooeyNav && gooeyLinks.length && !reducedMotion) {
let renderTick = 0;
// Monospace glyph *advance width* is narrower than the font-size (for
// IBM Plex Mono, roughly 0.6x) — assuming 1 character == CELL px wide
// A typical monospace font is roughly 0.6x as wide as it is tall — assuming 1 character == CELL px wide
// under-filled the row by the same margin, which is why the water
// stopped a good way short of the right edge even though the box itself
// was full width. Measure the real rendered glyph box instead of

1705
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,11 +16,12 @@
"build": "webpack --config webpack.config.prod.js"
},
"devDependencies": {
"copy-webpack-plugin": "^11.0.0",
"copy-webpack-plugin": "^14.0.0",
"html-webpack-plugin": "^5.6.0",
"three": "0.128.0",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4",
"webpack-dev-server": "^6.0.0",
"webpack-merge": "^5.10.0"
}
}