kliner 0.1.0-beta → 0.1.0-beta.2

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/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "kliner",
3
- "version": "0.1.0-beta",
3
+ "version": "0.1.0-beta.2",
4
4
  "description": "Developer-friendly service-worker web proxy framework",
5
5
  "type": "module",
6
- "bin": { "kliner": "bin/cli.js" },
6
+ "bin": {
7
+ "kliner": "bin/cli.js"
8
+ },
7
9
  "main": "./src/server.js",
8
10
  "exports": {
9
11
  ".": "./src/server.js",
@@ -17,9 +19,12 @@
17
19
  "test": "node --test"
18
20
  },
19
21
  "license": "MIT",
20
- "engines": { "node": ">=18.0.0" },
22
+ "engines": {
23
+ "node": ">=18.0.0"
24
+ },
21
25
  "dependencies": {
22
26
  "chalk": "^5.3.0",
27
+ "cheerio": "^1.2.0",
23
28
  "commander": "^12.1.0",
24
29
  "cors": "^2.8.5",
25
30
  "dotenv": "^16.4.0",
@@ -27,6 +32,16 @@
27
32
  "undici": "^6.19.0",
28
33
  "ws": "^8.18.0"
29
34
  },
30
- "devDependencies": { "esbuild": "^0.23.0", "nodemon": "^3.1.0" },
31
- "files": ["bin", "src", "scripts", "templates", "LICENSE", "README.md"]
32
- }
35
+ "devDependencies": {
36
+ "esbuild": "^0.23.0",
37
+ "nodemon": "^3.1.0"
38
+ },
39
+ "files": [
40
+ "bin",
41
+ "src",
42
+ "scripts",
43
+ "templates",
44
+ "LICENSE",
45
+ "README.md"
46
+ ]
47
+ }
package/src/proxy.js CHANGED
@@ -1,20 +1,64 @@
1
1
  import { request } from "undici";
2
+ import { load } from "cheerio";
2
3
  import { decodeKlinerUrl, encodeKlinerUrl } from "./codec.js";
3
4
  import { isAllowed } from "./config.js";
4
5
 
5
6
  const hopByHop = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
6
7
 
7
- export function rewriteResponse(text, baseUrl, contentType) {
8
+ export function sanitizeResponseHeader(name, value) {
9
+ const normalizedName = name.toLowerCase();
10
+ if (normalizedName === "x-frame-options") return null;
11
+ if (normalizedName !== "content-security-policy") return value;
12
+ const directives = String(value).split(";").filter((directive) => directive.trim().split(/\s+/, 1)[0].toLowerCase() !== "frame-ancestors");
13
+ return directives.join(";").trim() || null;
14
+ }
15
+
16
+ function rewriteTarget(value, baseUrl, servicePath) {
17
+ if (/^(?:data:|javascript:|mailto:|#|blob:|about:)/i.test(value)) return value;
18
+ try { return `${servicePath}${encodeKlinerUrl(new URL(value, baseUrl).href)}`; } catch { return value; }
19
+ }
20
+
21
+ function rewriteSrcset(value, baseUrl, servicePath) {
22
+ return value.split(",").map((candidate) => {
23
+ const parts = candidate.trim().split(/\s+/);
24
+ if (parts.length === 0) return candidate;
25
+ parts[0] = rewriteTarget(parts[0], baseUrl, servicePath);
26
+ return parts.join(" ");
27
+ }).join(", ");
28
+ }
29
+
30
+ export function rewriteResponse(text, baseUrl, contentType, servicePath = "/service/") {
8
31
  if (/text\/html/i.test(contentType)) {
9
- return text.replace(/(\b(?:href|src|action|poster)\s*=\s*["'])([^"']+)(["'])/gi, (match, start, value, end) => {
10
- if (/^(?:data:|javascript:|mailto:|#)/i.test(value)) return match;
11
- return `${start}${new URL(value, baseUrl).href}${end}`;
32
+ const $ = load(text, { decodeEntities: false });
33
+ const documentBase = $("base[href]").first().attr("href");
34
+ const resolvedBase = documentBase ? new URL(documentBase, baseUrl).href : baseUrl;
35
+ $("a[href], area[href], link[href], img[src], script[src], iframe[src], frame[src], form[action], video[src], audio[src], source[src], track[src], object[data], [poster]").each((_index, element) => {
36
+ for (const attribute of ["href", "src", "action", "data", "poster"]) {
37
+ const value = $(element).attr(attribute);
38
+ if (value) $(element).attr(attribute, rewriteTarget(value, resolvedBase, servicePath));
39
+ }
12
40
  });
41
+ $("img[srcset], source[srcset]").each((_index, element) => {
42
+ const value = $(element).attr("srcset");
43
+ if (value) $(element).attr("srcset", rewriteSrcset(value, resolvedBase, servicePath));
44
+ });
45
+ $("meta[http-equiv]").each((_index, element) => {
46
+ if ($(element).attr("http-equiv")?.toLowerCase() !== "refresh") return;
47
+ const value = $(element).attr("content") ?? "";
48
+ $(element).attr("content", value.replace(/(url\s*=\s*)(.*)$/i, (_match, prefix, target) => `${prefix}${rewriteTarget(target.trim(), resolvedBase, servicePath)}`));
49
+ });
50
+ $("[style]").each((_index, element) => $(element).attr("style", rewriteCss($(element).attr("style"), resolvedBase, servicePath)));
51
+ $("style").each((_index, element) => $(element).text(rewriteCss($(element).text(), resolvedBase, servicePath)));
52
+ return $.html();
13
53
  }
14
- if (/text\/css/i.test(contentType)) return text.replace(/url\(\s*(["']?)([^)"']+)\1\s*\)/gi, (match, quote, value) => `url(${quote}${new URL(value, baseUrl).href}${quote})`);
54
+ if (/text\/css/i.test(contentType)) return rewriteCss(text, baseUrl, servicePath);
15
55
  return text;
16
56
  }
17
57
 
58
+ function rewriteCss(text = "", baseUrl, servicePath) {
59
+ return text.replace(/url\(\s*(["']?)([^)"']+)\1\s*\)/gi, (match, quote, value) => `url(${quote}${rewriteTarget(value.trim(), baseUrl, servicePath)}${quote})`);
60
+ }
61
+
18
62
  export async function proxyRequest(req, res, config, logger = () => {}) {
19
63
  let target;
20
64
  try { target = new URL(decodeKlinerUrl(req.params[0] || req.params.encoded)); } catch (error) {
@@ -26,10 +70,18 @@ export async function proxyRequest(req, res, config, logger = () => {}) {
26
70
  try {
27
71
  const headers = { ...req.headers };
28
72
  delete headers.host; delete headers.connection; delete headers["content-length"];
29
- const upstream = await request(target, { method: req.method, headers, body: ["GET", "HEAD"].includes(req.method) ? undefined : req, signal: controller.signal, maxRedirections: 5 });
73
+ headers["accept-encoding"] = "identity";
74
+ const upstream = await request(target, { method: req.method, headers, body: ["GET", "HEAD"].includes(req.method) ? undefined : req, signal: controller.signal });
30
75
  logger(req.method, target.href, upstream.statusCode, upstream.headers["content-type"]);
31
76
  for (const [name, value] of Object.entries(upstream.headers)) {
32
77
  if (hopByHop.has(name) || name === "content-length" || name === "content-encoding") continue;
78
+ const sanitized = sanitizeResponseHeader(name, value);
79
+ if (sanitized === null) continue;
80
+ if (name === "set-cookie") {
81
+ const cookies = Array.isArray(value) ? value : [value];
82
+ res.setHeader(name, cookies.map((cookie) => cookie.replace(/;\s*Domain=[^;]*/gi, "").replace(/;\s*SameSite=None/gi, "")));
83
+ continue;
84
+ }
33
85
  if (name === "location") {
34
86
  const redirect = new URL(value, target);
35
87
  if (isAllowed(redirect, config)) res.setHeader(name, `${config.servicePath}${encodeKlinerUrl(redirect.href)}`);
@@ -42,9 +94,10 @@ export async function proxyRequest(req, res, config, logger = () => {}) {
42
94
  if (/text\/html|text\/css/i.test(type)) {
43
95
  const body = await upstream.body.text();
44
96
  if (Buffer.byteLength(body) > config.maxResponseSize) throw Object.assign(new Error("Response too large"), { code: "KLINER_RESPONSE_TOO_LARGE" });
45
- res.send(rewriteResponse(body, target.href, type));
97
+ res.send(rewriteResponse(body, target.href, type, config.servicePath));
46
98
  } else upstream.body.pipe(res);
47
99
  } catch (error) {
100
+ if (config.logging.level === "debug") console.error(error);
48
101
  if (res.headersSent) res.destroy(error);
49
102
  else res.status(error.name === "AbortError" ? 504 : 502).json({ error: error.name === "AbortError" ? "KLINER_TIMEOUT" : "KLINER_TARGET_UNREACHABLE" });
50
103
  } finally { clearTimeout(timeout); }
package/src/server.js CHANGED
@@ -15,6 +15,7 @@ export async function createApp(config) {
15
15
  app.get("/status", (_req, res) => res.json({ status: "ok", allowlist: config.allowlist, servicePath: config.servicePath }));
16
16
  const clientPath = path.dirname(fileURLToPath(import.meta.url));
17
17
  app.get("/kli.js", (_req, res) => res.sendFile(path.join(clientPath, "client", "kli.js")));
18
+ app.use(express.static(path.join(clientPath, "..", "klinerdemo")));
18
19
  app.all(`${config.servicePath}:encoded(*)`, (req, res) => proxyRequest(req, res, config, (method, target, status, type) => {
19
20
  if (config.logging.level !== "silent") console.log(`[${new Date().toISOString().slice(11, 19)}] ${method} ${target} ${status} ${type ?? ""}`);
20
21
  }));