@7365admin1/layer-common 4.85.0 → 4.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.86.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4891c16: Security headers on every app (HSTS, nosniff, frame-ancestors/X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP, minimal enforced CSP + full Report-Only CSP), drop `x-powered-by`, explicit `SameSite=Lax` on the session cookies, `sanitizeHtml` for rich text rendered with `v-html`, html2pdf.js 0.14 (jsPDF 4.2.1).
8
+
3
9
  ## 4.85.0
4
10
 
5
11
  ### Minor Changes
@@ -1980,11 +1986,11 @@
1980
1986
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
1981
1987
  own rule from two endpoints the console already calls, unprojected:
1982
1988
 
1983
- GET /api/members/user/:user/app/admin the Seven365 staff membership
1984
- GET /api/roles/id/:role that membership's role document
1989
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
1990
+ GET /api/roles/id/:role that membership's role document
1985
1991
 
1986
- owner = member.type === "admin" && role.type === "admin" && role.default === true
1987
- staff = member.type === "admin" && role.type === "admin"
1992
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
1993
+ staff = member.type === "admin" && role.type === "admin"
1988
1994
 
1989
1995
  `role.default` is the marker because it is the only property of a platform
1990
1996
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -19,7 +19,7 @@
19
19
  <v-col class="font-weight-bold">{{ label }}:</v-col>
20
20
 
21
21
  <v-col cols="9" class="px-10">
22
- <span v-if="key === 'content'" v-html="getFieldValue(key)" />
22
+ <span v-if="key === 'content'" v-html="sanitizeHtml(getFieldValue(key))" />
23
23
  <span v-else-if="key === 'noExpiration'">
24
24
  <BulletinExpirationChip
25
25
  :value="getFieldValue(key) as boolean"
package/nuxt.config.ts CHANGED
@@ -1,8 +1,23 @@
1
1
  // https://nuxt.com/docs/api/configuration/nuxt-config
2
2
  import vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
3
+ import { SECURITY_HEADERS } from "./utils/security-headers";
3
4
  export default defineNuxtConfig({
4
5
  ssr: false,
5
6
  devtools: { enabled: false },
7
+
8
+ // Security headers for every app that extends this layer: the HTML shell gets
9
+ // them from Nitro, the static files from the `_headers` file the Cloudflare
10
+ // preset writes from these same rules. See utils/security-headers.ts.
11
+ routeRules: {
12
+ "/**": { headers: SECURITY_HEADERS },
13
+ // Stored files read with the signed-in session (API-core
14
+ // GET /api/files/object/<key>) instead of the public CDN. Unused until an
15
+ // app's API_DO_STORAGE_ENDPOINT points at /api/storage/<folder>; that switch
16
+ // goes with SPACES_PRIVATE_PREFIXES - see the storage runbook.
17
+ ...(process.env.API_CORE
18
+ ? { "/api/storage/**": { proxy: `${process.env.API_CORE}/api/files/object/**` } }
19
+ : {}),
20
+ },
6
21
  build: {
7
22
  transpile: ["vuetify"],
8
23
  },
@@ -48,6 +63,9 @@ export default defineNuxtConfig({
48
63
  cookieConfig: {
49
64
  domain: (process.env.DOMAIN as string) ?? "localhost",
50
65
  secure: true,
66
+ // Explicit, not the browser default: the API also reads `sid` from the
67
+ // cookie, so a cross-site form post must never carry it (CSRF).
68
+ sameSite: "lax",
51
69
  maxAge: 30 * 24 * 60 * 60,
52
70
  },
53
71
  APP: (process.env.APP as string) ?? "App",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.85.0",
5
+ "version": "4.86.0",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -22,6 +22,7 @@
22
22
  "pages",
23
23
  "plugins",
24
24
  "public",
25
+ "server",
25
26
  "services",
26
27
  "types",
27
28
  "utils",
@@ -57,8 +58,9 @@
57
58
  "@mdi/font": "^7.4.47",
58
59
  "@types/qrcode": "^1.5.6",
59
60
  "ckeditor5": "47.4.0",
61
+ "dompurify": "^3.3.1",
60
62
  "hls.js": "^1.6.13",
61
- "html2pdf.js": "^0.10.2",
63
+ "html2pdf.js": "^0.14.0",
62
64
  "html5-qrcode": "^2.3.8",
63
65
  "moment-timezone": "^0.6.0",
64
66
  "nuxt-signature-pad": "^1.8.0",
@@ -0,0 +1,7 @@
1
+ // Nuxt's renderer stamps `x-powered-by: Nuxt` on the HTML shell. It tells a
2
+ // scanner the framework for free and helps nobody, so drop it.
3
+ export default defineNitroPlugin((nitroApp) => {
4
+ nitroApp.hooks.hook("render:response", (response) => {
5
+ if (response?.headers) delete response.headers["x-powered-by"];
6
+ });
7
+ });
@@ -0,0 +1,16 @@
1
+ import DOMPurify from "dompurify";
2
+
3
+ /**
4
+ * The one way rich text typed by a user (announcements, facility descriptions,
5
+ * policies) reaches `v-html`. Keeps formatting, drops scripts, event handlers,
6
+ * `javascript:` links and embedded frames/forms. Browser-only (the apps are
7
+ * `ssr: false`); without a DOM it returns "" rather than unsanitised HTML.
8
+ */
9
+ export function sanitizeHtml(html: unknown): string {
10
+ if (html === null || html === undefined || html === "") return "";
11
+ if (typeof window === "undefined" || !DOMPurify.isSupported) return "";
12
+ return DOMPurify.sanitize(String(html), {
13
+ USE_PROFILES: { html: true },
14
+ FORBID_TAGS: ["style", "form", "input", "button", "iframe", "object", "embed"],
15
+ });
16
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Response headers every web app sends, on the HTML shell (Nitro route rules)
3
+ * and on the static files (nitro writes the same rules into Cloudflare's
4
+ * `_headers`). One list, so the eleven apps cannot drift apart.
5
+ *
6
+ * The ENFORCED Content-Security-Policy only carries the directives that cannot
7
+ * break a screen: who may frame us (clickjacking), no plugins, no <base> swap.
8
+ * The full policy ships as Report-Only first: the apps load Google Fonts,
9
+ * camera players and documents in iframes, sockets and the Spaces CDN, and a
10
+ * wrong guess there blanks a page. Violations show in the browser console;
11
+ * once a release runs clean, move it to the enforced header.
12
+ *
13
+ * HSTS deliberately has no `preload`: preload is decided for the whole
14
+ * iservice365.org domain at the apex and is hard to undo (owner/leads action).
15
+ */
16
+ export const CSP_ENFORCED = [
17
+ "frame-ancestors 'self'",
18
+ "object-src 'none'",
19
+ "base-uri 'self'",
20
+ ].join("; ");
21
+
22
+ export const CSP_REPORT_ONLY = [
23
+ "default-src 'self'",
24
+ "script-src 'self'",
25
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
26
+ "font-src 'self' data: https://fonts.gstatic.com",
27
+ "img-src 'self' data: blob: https:",
28
+ "media-src 'self' blob: https:",
29
+ "connect-src 'self' https: wss:",
30
+ "frame-src 'self' https:",
31
+ "worker-src 'self' blob:",
32
+ "object-src 'none'",
33
+ "base-uri 'self'",
34
+ "frame-ancestors 'self'",
35
+ "form-action 'self' https:",
36
+ "upgrade-insecure-requests",
37
+ ].join("; ");
38
+
39
+ export const SECURITY_HEADERS: Record<string, string> = {
40
+ "strict-transport-security": "max-age=31536000; includeSubDomains",
41
+ "x-content-type-options": "nosniff",
42
+ "x-frame-options": "SAMEORIGIN",
43
+ "referrer-policy": "strict-origin-when-cross-origin",
44
+ // camera/mic: QR scan, photo capture; geolocation: attendance;
45
+ // usb/serial/hid: card readers (useWebUsb). Everything else is off.
46
+ "permissions-policy":
47
+ "camera=(self), microphone=(self), geolocation=(self), usb=(self), serial=(self), hid=(self), payment=(self), display-capture=(self), bluetooth=(), midi=(), magnetometer=(), gyroscope=(), accelerometer=()",
48
+ // allow-popups: payment and document windows we open keep working.
49
+ "cross-origin-opener-policy": "same-origin-allow-popups",
50
+ "content-security-policy": CSP_ENFORCED,
51
+ "content-security-policy-report-only": CSP_REPORT_ONLY,
52
+ };