yxorp 0.2.3 → 0.2.4

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/README.md CHANGED
@@ -264,6 +264,14 @@ For more advanced patterns, see the [path-to-regexp documentation](https://githu
264
264
 
265
265
  ---
266
266
 
267
+ ### A Note on Script Security
268
+
269
+ Mock and rewrite scripts (`"script": "./path/to/file.js"`) are loaded with Node's `require()` and run with the same privileges as Yxorp itself — full filesystem, network, and process access, with no sandboxing.
270
+
271
+ Only point Yxorp at config files (and the scripts they reference) that you trust, the same way you'd treat any other local Node script. Don't load a config from an untrusted source.
272
+
273
+ ---
274
+
267
275
  ## Full Example
268
276
 
269
277
  Here's a complete config showing all features in action:
@@ -1,14 +1,15 @@
1
- import { IncomingMessage } from 'http';
1
+ /// <reference types="node" />
2
+ import { IncomingMessage, ServerResponse } from 'http';
2
3
  import { Middleware } from '../services/pipeline.service';
3
4
  import { RewriteRulesMatcher } from '../services/rules-matchers/rewrite-rules-matcher.service';
4
5
  import { MockRulesMatcher } from '../services/rules-matchers/mock-rules-matcher.service';
5
6
  import { LoggerService } from '../services/logger.service';
6
- export declare class BootstrapMiddleware implements Middleware<[req: IncomingMessage, res: any]> {
7
+ export declare class BootstrapMiddleware implements Middleware<[req: IncomingMessage, res: ServerResponse]> {
7
8
  private rewriteRulesMatcher;
8
9
  private mockRulesMatcher;
9
10
  private logger;
10
11
  constructor(rewriteRulesMatcher: RewriteRulesMatcher, mockRulesMatcher: MockRulesMatcher, logger: LoggerService);
11
- use(req: IncomingMessage, _res: any, next: () => void): void;
12
+ use(req: IncomingMessage, _res: ServerResponse, next: () => void): void;
12
13
  private setRewriteRule;
13
14
  private setMockRule;
14
15
  private setQueryParams;
@@ -7,7 +7,7 @@ exports.MockMiddleware = void 0;
7
7
  const mime_1 = __importDefault(require("mime"));
8
8
  const promises_1 = __importDefault(require("fs/promises"));
9
9
  const path_1 = __importDefault(require("path"));
10
- const request_timing_1 = require("../utils/request-timing");
10
+ const access_log_1 = require("../utils/access-log");
11
11
  class MockMiddleware {
12
12
  constructor(logger) {
13
13
  this.logger = logger;
@@ -31,14 +31,14 @@ class MockMiddleware {
31
31
  res.setHeader('content-type', 'application/json');
32
32
  res.end(JSON.stringify({ error: `Mock script "${mockRule.script}" does not export a function` }));
33
33
  this.logger.error(`Mock script "${fullPath}" does not export a function (module.exports = (req, res) => {...})`);
34
- this.logger.info(`mock ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
34
+ this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
35
35
  return;
36
36
  }
37
37
  await handler(req, res);
38
38
  if (!res.headersSent) {
39
39
  res.setHeader('content-type', 'application/json');
40
40
  }
41
- this.logger.info(`mock ${res.statusCode || 200} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
41
+ this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode || 200, req));
42
42
  return;
43
43
  }
44
44
  if ('file' in mockRule) {
@@ -50,7 +50,7 @@ class MockMiddleware {
50
50
  }
51
51
  res.setHeader('content-length', file.length);
52
52
  res.end(file);
53
- this.logger.info(`mock ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
53
+ this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
54
54
  return;
55
55
  }
56
56
  next();
@@ -61,7 +61,7 @@ class MockMiddleware {
61
61
  // calling next() would send the request into ProxyMiddleware/httpProxy.web,
62
62
  // which would try to write to an already-finished response.
63
63
  if (res.headersSent) {
64
- this.logger.info(`mock ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
64
+ this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
65
65
  if (!res.writableEnded) {
66
66
  res.end();
67
67
  }
@@ -28,7 +28,16 @@ class ProxyMiddleware {
28
28
  prependPath: !target,
29
29
  target: target || proxyOptions.target,
30
30
  };
31
- this.httpProxy.web(req, res, options).catch((error) => this.logger.error(error));
31
+ this.httpProxy.web(req, res, options).catch((error) => {
32
+ // httpxy rejects web()'s promise when the proxy has no 'error' listener
33
+ // (e.g. target ECONNREFUSED/DNS failure) — without this, the client
34
+ // would otherwise hang forever waiting for a response that never comes.
35
+ this.logger.error(error);
36
+ if (!res.headersSent) {
37
+ res.statusCode = 502;
38
+ res.end();
39
+ }
40
+ });
32
41
  }
33
42
  }
34
43
  exports.ProxyMiddleware = ProxyMiddleware;
@@ -1,16 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ProxyResMiddleware = void 0;
4
- const request_timing_1 = require("../utils/request-timing");
4
+ const access_log_1 = require("../utils/access-log");
5
+ const headers_1 = require("../utils/headers");
5
6
  class ProxyResMiddleware {
6
7
  constructor(logger) {
7
8
  this.logger = logger;
8
9
  }
9
10
  use(proxyRes, req, res) {
10
11
  try {
11
- // Skip transfer-encoding since we reconstruct the body with known length
12
+ // Hop-by-hop headers (incl. transfer-encoding, since we reconstruct the
13
+ // body with a known length) must not be forwarded to the client.
12
14
  for (let key in proxyRes.headers) {
13
- if (key === 'transfer-encoding')
15
+ if ((0, headers_1.isHopByHopHeader)(key, proxyRes.headers))
14
16
  continue;
15
17
  res.setHeader(key, proxyRes.headers[key]);
16
18
  }
@@ -24,7 +26,7 @@ class ProxyResMiddleware {
24
26
  // it sets req.rewriteLogged in both cases) — log here only for plain
25
27
  // pass-through, so every proxied request gets exactly one log line.
26
28
  if (!req.rewriteLogged) {
27
- this.logger.info(`proxy ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
29
+ this.logger.info((0, access_log_1.formatAccessLog)('proxy', res.statusCode, req));
28
30
  }
29
31
  }
30
32
  catch (e) {
@@ -7,7 +7,7 @@ exports.RewriteMiddleware = void 0;
7
7
  const http_encoding_1 = require("http-encoding");
8
8
  const promises_1 = __importDefault(require("fs/promises"));
9
9
  const path_1 = __importDefault(require("path"));
10
- const request_timing_1 = require("../utils/request-timing");
10
+ const access_log_1 = require("../utils/access-log");
11
11
  class RewriteMiddleware {
12
12
  constructor(logger) {
13
13
  this.logger = logger;
@@ -62,7 +62,7 @@ class RewriteMiddleware {
62
62
  */
63
63
  logRewrite(req, proxyRes) {
64
64
  req.rewriteLogged = true;
65
- this.logger.info(`rewrite ${proxyRes.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
65
+ this.logger.info((0, access_log_1.formatAccessLog)('rewrite', proxyRes.statusCode, req));
66
66
  }
67
67
  }
68
68
  exports.RewriteMiddleware = RewriteMiddleware;
@@ -24,6 +24,11 @@ export declare class StaticMiddleware implements Middleware<[req: IncomingMessag
24
24
  * than enumerating the entire tree just to find one file.
25
25
  */
26
26
  private resolveFile;
27
+ /**
28
+ * Confirms `filePath` is a regular file AND, after resolving symlinks on
29
+ * both sides, still lives inside `directory` — a symlink placed under
30
+ * `directory` that points outside it must not be served.
31
+ */
27
32
  private isFile;
28
33
  private findCaseInsensitive;
29
34
  }
@@ -7,7 +7,7 @@ exports.StaticMiddleware = void 0;
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const promises_1 = __importDefault(require("fs/promises"));
9
9
  const mime_1 = __importDefault(require("mime"));
10
- const request_timing_1 = require("../utils/request-timing");
10
+ const access_log_1 = require("../utils/access-log");
11
11
  class StaticMiddleware {
12
12
  constructor(config, logger) {
13
13
  this.config = config;
@@ -37,7 +37,7 @@ class StaticMiddleware {
37
37
  }
38
38
  res.setHeader('content-length', file.length);
39
39
  res.end(file);
40
- this.logger.info(`static ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
40
+ this.logger.info((0, access_log_1.formatAccessLog)('static', res.statusCode, req));
41
41
  }
42
42
  catch (e) {
43
43
  this.logger.error(e);
@@ -75,7 +75,7 @@ class StaticMiddleware {
75
75
  return undefined;
76
76
  }
77
77
  const exactPath = path_1.default.join(directory, ...segments);
78
- if (await this.isFile(exactPath)) {
78
+ if (await this.isFile(exactPath, directory)) {
79
79
  return exactPath;
80
80
  }
81
81
  if (!rule.caseInsensitive) {
@@ -89,12 +89,25 @@ class StaticMiddleware {
89
89
  }
90
90
  current = path_1.default.join(current, match);
91
91
  }
92
- return (await this.isFile(current)) ? current : undefined;
92
+ return (await this.isFile(current, directory)) ? current : undefined;
93
93
  }
94
- async isFile(filePath) {
94
+ /**
95
+ * Confirms `filePath` is a regular file AND, after resolving symlinks on
96
+ * both sides, still lives inside `directory` — a symlink placed under
97
+ * `directory` that points outside it must not be served.
98
+ */
99
+ async isFile(filePath, directory) {
95
100
  try {
96
101
  const stats = await promises_1.default.stat(filePath);
97
- return stats.isFile();
102
+ if (!stats.isFile()) {
103
+ return false;
104
+ }
105
+ const [realFile, realDirectory] = await Promise.all([
106
+ promises_1.default.realpath(filePath),
107
+ promises_1.default.realpath(directory),
108
+ ]);
109
+ const relative = path_1.default.relative(realDirectory, realFile);
110
+ return !relative.startsWith('..') && !path_1.default.isAbsolute(relative);
98
111
  }
99
112
  catch {
100
113
  return false;
@@ -5,9 +5,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.watchConfig = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
8
9
  function watchConfig(configPath, onReload, onError) {
10
+ const dir = path_1.default.dirname(configPath);
11
+ const filename = path_1.default.basename(configPath);
9
12
  let debounceTimer = null;
10
- return fs_1.default.watch(configPath, () => {
13
+ // Watch the parent directory rather than the file itself — editors that
14
+ // save via atomic rename (vim, etc.) replace the inode, after which
15
+ // fs.watch on the old file path stops firing.
16
+ return fs_1.default.watch(dir, (_eventType, changedFile) => {
17
+ if (changedFile && changedFile !== filename)
18
+ return;
11
19
  if (debounceTimer)
12
20
  return;
13
21
  debounceTimer = setTimeout(() => {
@@ -1,6 +1,6 @@
1
1
  import { YxorpConfig } from '../types/yxorp-config';
2
2
  export declare class Config {
3
- private config;
3
+ private config?;
4
4
  set(config: YxorpConfig): void;
5
5
  get(): YxorpConfig;
6
6
  }
@@ -6,6 +6,9 @@ class Config {
6
6
  this.config = config;
7
7
  }
8
8
  get() {
9
+ if (!this.config) {
10
+ throw new Error('Config has not been initialized — call set() before get()');
11
+ }
9
12
  return this.config;
10
13
  }
11
14
  }
@@ -2,13 +2,14 @@
2
2
  /// <reference types="node" />
3
3
  /// <reference types="node" />
4
4
  import { IncomingMessage, ServerResponse } from 'http';
5
+ import { ProxyServer } from 'httpxy';
5
6
  import { Pipeline } from './pipeline.service';
6
7
  export declare class HttpProxy {
7
8
  private pipeline;
8
- readonly on: (...args: any[]) => any;
9
+ readonly on: ProxyServer<IncomingMessage, ServerResponse>['on'];
9
10
  private proxy;
10
11
  constructor(pipeline: Pipeline<[proxyRes: IncomingMessage, req: IncomingMessage, res: ServerResponse]>);
11
12
  execute: (args_0: IncomingMessage, args_1: IncomingMessage, args_2: ServerResponse<IncomingMessage>) => Promise<void>;
12
- web(req: IncomingMessage, res: ServerResponse, options?: Record<string, any>): any;
13
- ws(req: IncomingMessage, socket: any, options: Record<string, any>, head?: Buffer): any;
13
+ web(req: IncomingMessage, res: ServerResponse, options?: Record<string, any>): Promise<void>;
14
+ ws(req: IncomingMessage, socket: any, options: Record<string, any>, head?: Buffer): Promise<void>;
14
15
  }
@@ -12,7 +12,7 @@ export interface MethodPathRule {
12
12
  export declare abstract class MethodPathRulesMatcher<T extends MethodPathRule> {
13
13
  protected abstract getRules(): T[];
14
14
  match(url: string, method: string): T | undefined;
15
- params(url: string, rule: T): Object | undefined;
15
+ params(url: string, rule: T): Record<string, string> | undefined;
16
16
  /**
17
17
  * Finds the matching rule and its path params in a single pass — avoids
18
18
  * compiling/running the path-to-regexp matcher twice (once via `match()`,
@@ -20,7 +20,7 @@ export declare abstract class MethodPathRulesMatcher<T extends MethodPathRule> {
20
20
  */
21
21
  matchWithParams(url: string, method: string): {
22
22
  rule: T;
23
- params: Object;
23
+ params: Record<string, string>;
24
24
  } | undefined;
25
25
  private find;
26
26
  private matchPath;
@@ -23,7 +23,7 @@ function createServer(config, logger, rewriteRulesMatcher, mockRulesMatcher, rem
23
23
  // 4. HttpServer wraps the server pipeline
24
24
  const server = new http_server_service_1.HttpServer(serverPipeline, logger);
25
25
  // 5. Attach proxy pipeline to proxyRes event
26
- proxy.on('proxyRes', ((proxyRes, req, res) => {
26
+ proxy.on('proxyRes', (proxyRes, req, res) => {
27
27
  // proxy.execute() is async — an uncaught rejection here would otherwise
28
28
  // become an unhandled promise rejection and can crash the whole process,
29
29
  // leaving the client hanging with no response.
@@ -34,7 +34,7 @@ function createServer(config, logger, rewriteRulesMatcher, mockRulesMatcher, rem
34
34
  res.end();
35
35
  }
36
36
  });
37
- }));
37
+ });
38
38
  // 6. WebSocket upgrade handling
39
39
  server.addListener('upgrade', (req, socket, head) => {
40
40
  const url = req.url || '';
@@ -0,0 +1,7 @@
1
+ import { IncomingMessage } from 'http';
2
+ /**
3
+ * Formats a single access-log line with the label column padded to a fixed
4
+ * width so entries from different middlewares (mock, rewrite, proxy,
5
+ * static) line up in the log output.
6
+ */
7
+ export declare function formatAccessLog(label: string, statusCode: number | string | undefined, req: IncomingMessage): string;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatAccessLog = void 0;
4
+ const request_timing_1 = require("./request-timing");
5
+ const LABEL_WIDTH = 8;
6
+ /**
7
+ * Formats a single access-log line with the label column padded to a fixed
8
+ * width so entries from different middlewares (mock, rewrite, proxy,
9
+ * static) line up in the log output.
10
+ */
11
+ function formatAccessLog(label, statusCode, req) {
12
+ return `${label.padEnd(LABEL_WIDTH)}${statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`;
13
+ }
14
+ exports.formatAccessLog = formatAccessLog;
@@ -0,0 +1,8 @@
1
+ /// <reference types="node" />
2
+ import { IncomingHttpHeaders } from 'http';
3
+ /**
4
+ * RFC 2616 §13.5.1 hop-by-hop headers must not be forwarded between a proxy
5
+ * and its client — plus any header dynamically named in the `Connection`
6
+ * header's value.
7
+ */
8
+ export declare function isHopByHopHeader(name: string, headers: IncomingHttpHeaders): boolean;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isHopByHopHeader = void 0;
4
+ const HOP_BY_HOP_HEADERS = new Set([
5
+ 'connection',
6
+ 'keep-alive',
7
+ 'proxy-authenticate',
8
+ 'proxy-authorization',
9
+ 'te',
10
+ 'trailer',
11
+ 'transfer-encoding',
12
+ 'upgrade',
13
+ ]);
14
+ /**
15
+ * RFC 2616 §13.5.1 hop-by-hop headers must not be forwarded between a proxy
16
+ * and its client — plus any header dynamically named in the `Connection`
17
+ * header's value.
18
+ */
19
+ function isHopByHopHeader(name, headers) {
20
+ const lower = name.toLowerCase();
21
+ if (HOP_BY_HOP_HEADERS.has(lower)) {
22
+ return true;
23
+ }
24
+ const connection = headers.connection;
25
+ if (connection) {
26
+ const named = (Array.isArray(connection) ? connection.join(',') : connection)
27
+ .split(',')
28
+ .map((value) => value.trim().toLowerCase());
29
+ if (named.includes(lower)) {
30
+ return true;
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+ exports.isHopByHopHeader = isHopByHopHeader;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yxorp",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "A local reverse proxy for rewriting, mocking, and debugging API responses.",
5
5
  "files": [
6
6
  "dist/",
@@ -27,7 +27,6 @@
27
27
  "mime": "^3.0.0",
28
28
  "path-to-regexp": "^8.4.2",
29
29
  "qs": "^6.15.2",
30
- "tslib": "^2.8.1",
31
30
  "winston": "^3.19.0"
32
31
  },
33
32
  "devDependencies": {