@ferrule-io/ok-fine 0.3.8 → 0.3.9

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/dist/http/ui.js CHANGED
@@ -17,6 +17,9 @@ const CONTENT_TYPES = {
17
17
  ".txt": "text/plain; charset=utf-8",
18
18
  };
19
19
  const IMMUTABLE = "public, max-age=31536000, immutable";
20
+ const PERMISSIONS_POLICY = "camera=(), display-capture=(), geolocation=(), microphone=()";
21
+ const SVG_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
22
+ const DEFAULT_CSP = "frame-ancestors 'none'";
20
23
  /** Unique origins of the string http(s) URLs among `endpoints`; ignores anything else. */
21
24
  export function endpointOrigins(...endpoints) {
22
25
  const origins = new Set();
@@ -67,50 +70,61 @@ export async function registerUiRoutes(app, options) {
67
70
  app.log.warn({ dir: options.dir }, "web UI assets not found; /ui disabled");
68
71
  return false;
69
72
  }
70
- const csp = [
73
+ const htmlCsp = [
71
74
  "default-src 'self'",
72
75
  "script-src 'self'",
73
- // Shiki emits inline style attributes.
76
+ // Shiki emits inline style attributes for code block syntax highlighting.
74
77
  "style-src 'self' 'unsafe-inline'",
75
- "img-src 'self' data: https:",
78
+ "img-src 'self' data:",
76
79
  "font-src 'self' data:",
77
80
  `connect-src 'self'${options.connectSrc.map((origin) => ` ${origin}`).join("")}`,
78
81
  "object-src 'none'",
79
82
  "base-uri 'none'",
80
83
  "form-action 'none'",
81
84
  "frame-ancestors 'none'",
85
+ ...(options.publicBaseUrl.startsWith("https://") ? ["upgrade-insecure-requests"] : []),
82
86
  ].join("; ");
83
- const sendHtml = (reply) => reply
84
- .header("content-type", "text/html; charset=utf-8")
85
- .header("cache-control", "no-cache")
86
- .header("referrer-policy", "no-referrer")
87
- .header("x-frame-options", "DENY")
88
- .header("x-content-type-options", "nosniff")
89
- .header("content-security-policy", csp)
90
- .send(index.body);
91
- app.get("/", async (_req, reply) => reply.redirect("/ui/", 302));
92
- app.get("/ui", async (_req, reply) => reply.redirect("/ui/", 301));
93
- app.get("/ui/config.json", async (_req, reply) => reply.header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(options.clientConfig));
94
- app.get("/ui/*", async (req, reply) => {
95
- const rel = req.params["*"];
96
- if (rel === "" || rel === "index.html")
87
+ await app.register(async (ui) => {
88
+ ui.addHook("onSend", async (_req, reply, payload) => {
89
+ reply.header("x-content-type-options", "nosniff");
90
+ reply.header("x-frame-options", "DENY");
91
+ reply.header("referrer-policy", "no-referrer");
92
+ reply.header("cross-origin-opener-policy", "same-origin");
93
+ reply.header("permissions-policy", PERMISSIONS_POLICY);
94
+ const contentType = reply.getHeader("content-type");
95
+ if (typeof contentType === "string" && contentType.startsWith("image/svg+xml")) {
96
+ reply.header("content-security-policy", SVG_CSP);
97
+ }
98
+ else if (!reply.hasHeader("content-security-policy")) {
99
+ reply.header("content-security-policy", DEFAULT_CSP);
100
+ }
101
+ return payload;
102
+ });
103
+ const sendHtml = (reply) => reply
104
+ .header("content-type", "text/html; charset=utf-8")
105
+ .header("cache-control", "no-cache")
106
+ .header("content-security-policy", htmlCsp)
107
+ .send(index.body);
108
+ ui.get("/", async (_req, reply) => reply.redirect("/ui/", 302));
109
+ ui.get("/ui", async (_req, reply) => reply.redirect("/ui/", 301));
110
+ ui.get("/ui/config.json", async (_req, reply) => reply.header("cache-control", "no-store").send(options.clientConfig));
111
+ ui.get("/ui/*", async (req, reply) => {
112
+ const rel = req.params["*"];
113
+ if (rel === "" || rel === "index.html")
114
+ return sendHtml(reply);
115
+ const asset = assets.get(rel);
116
+ if (asset) {
117
+ return reply.header("content-type", asset.type).header("cache-control", asset.cache).send(asset.body);
118
+ }
119
+ const last = rel.slice(rel.lastIndexOf("/") + 1);
120
+ if (/\.[A-Za-z0-9]+$/.test(last)) {
121
+ return reply
122
+ .code(404)
123
+ .header("cache-control", "no-cache")
124
+ .send({ error: { code: "not_found", message: "not found" } });
125
+ }
97
126
  return sendHtml(reply);
98
- const asset = assets.get(rel);
99
- if (asset) {
100
- return reply
101
- .header("content-type", asset.type)
102
- .header("cache-control", asset.cache)
103
- .header("x-content-type-options", "nosniff")
104
- .send(asset.body);
105
- }
106
- const last = rel.slice(rel.lastIndexOf("/") + 1);
107
- if (/\.[A-Za-z0-9]+$/.test(last)) {
108
- return reply
109
- .code(404)
110
- .header("x-content-type-options", "nosniff")
111
- .send({ error: { code: "not_found", message: "not found" } });
112
- }
113
- return sendHtml(reply);
127
+ });
114
128
  });
115
129
  app.log.info({ url: `${options.publicBaseUrl}/ui/` }, "web UI enabled");
116
130
  return true;
package/dist/server.js CHANGED
@@ -150,7 +150,10 @@ export async function startServer(config, options = {}) {
150
150
  }
151
151
  const host = parseHostHeader(req.headers.host);
152
152
  if (!host || !allowedHosts[host]) {
153
- return reply.code(403).send({
153
+ return reply
154
+ .code(403)
155
+ .header("x-content-type-options", "nosniff")
156
+ .send({
154
157
  error: {
155
158
  code: "forbidden",
156
159
  message: `Host header '${req.headers.host ?? ""}' is not allowed in AUTH_MODE=none`,
@@ -173,7 +176,10 @@ export async function startServer(config, options = {}) {
173
176
  }
174
177
  }
175
178
  if (!originAllowed) {
176
- return reply.code(403).send({
179
+ return reply
180
+ .code(403)
181
+ .header("x-content-type-options", "nosniff")
182
+ .send({
177
183
  error: {
178
184
  code: "forbidden",
179
185
  message: `Origin '${rawOrigin ?? ""}' is not allowed in AUTH_MODE=none`,