@mug-lab/cookie-auth-proxy 0.1.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.
@@ -0,0 +1,154 @@
1
+ const fs = require("node:fs");
2
+ const net = require("node:net");
3
+ const path = require("node:path");
4
+ const { buildConfig } = require("./config");
5
+ const { createRuntimeState } = require("./runtime");
6
+
7
+ /**
8
+ * 起動せずに設定と起動時Importを検証する。
9
+ */
10
+ function validateStartup(argv) {
11
+ const config = buildConfig(argv);
12
+ config.runtime = createRuntimeState(config);
13
+ return config;
14
+ }
15
+
16
+ /**
17
+ * dry-runコマンドの結果を標準出力向けに作る。
18
+ */
19
+ function dryRun(argv) {
20
+ const config = validateStartup(argv);
21
+ return [
22
+ "Dry run OK.",
23
+ `Proxy: ${config.publicOriginUrl.origin}`,
24
+ `Client: ${config.clientOriginUrl.origin}`,
25
+ `API: ${config.apiOriginUrl ? config.apiOriginUrl.origin : "(not set)"}`,
26
+ `Stubs: ${config.runtime.stubs.length}`,
27
+ `JSON Overrides: ${config.runtime.jsonOverrides.length}`,
28
+ ];
29
+ }
30
+
31
+ /**
32
+ * 設定、Importフォルダ、port空きなどを診断する。
33
+ */
34
+ async function doctor(argv) {
35
+ const reports = [];
36
+ let config;
37
+ try {
38
+ config = validateStartup(argv);
39
+ reports.push(ok("config", "Configuration can be loaded."));
40
+ } catch (error) {
41
+ reports.push(fail("config", error.message));
42
+ return reports;
43
+ }
44
+
45
+ reports.push(...checkImportDirectory("stubs", config.startupStubImportDir));
46
+ reports.push(
47
+ ...checkImportDirectory("overrides", config.startupOverrideImportDir),
48
+ );
49
+ reports.push(await checkPort("proxy port", config.listenHost, config.listenPort));
50
+ reports.push(await checkPort("admin port", config.adminHost, config.adminPort));
51
+
52
+ if (!config.apiOriginUrl) {
53
+ reports.push(warn("api origin", "API origin is not set yet."));
54
+ } else {
55
+ reports.push(ok("api origin", config.apiOriginUrl.origin));
56
+ }
57
+
58
+ if (config.apiOriginAllowedPatterns.length === 0) {
59
+ reports.push(
60
+ warn(
61
+ "api origin allowlist",
62
+ "apiOriginAllowedPatterns is empty. Consider restricting API origins.",
63
+ ),
64
+ );
65
+ } else {
66
+ reports.push(ok("api origin allowlist", "Allowlist is configured."));
67
+ }
68
+
69
+ return reports;
70
+ }
71
+
72
+ /**
73
+ * 診断結果を表示用の文字列へ変換する。
74
+ */
75
+ function formatDoctorReports(reports) {
76
+ return reports.map((report) => `${report.level} ${report.name}: ${report.message}`);
77
+ }
78
+
79
+ /**
80
+ * 診断結果にエラーが含まれるか判定する。
81
+ */
82
+ function hasDoctorError(reports) {
83
+ return reports.some((report) => report.level === "ERROR");
84
+ }
85
+
86
+ /**
87
+ * 起動時Importフォルダの存在とJSONファイル数を確認する。
88
+ */
89
+ function checkImportDirectory(name, directory) {
90
+ if (!directory) return [warn(name, "Import directory is not configured.")];
91
+ const resolved = path.resolve(process.cwd(), directory);
92
+ try {
93
+ const stat = fs.statSync(resolved);
94
+ if (!stat.isDirectory()) {
95
+ return [fail(name, `${directory} exists but is not a directory.`)];
96
+ }
97
+ const jsonCount = fs
98
+ .readdirSync(resolved, { withFileTypes: true })
99
+ .filter(
100
+ (entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".json"),
101
+ ).length;
102
+ return [ok(name, `${directory} (${jsonCount} JSON files)`)];
103
+ } catch (error) {
104
+ if (error.code === "ENOENT") {
105
+ return [warn(name, `${directory} does not exist yet.`)];
106
+ }
107
+ return [fail(name, `${directory}: ${error.message}`)];
108
+ }
109
+ }
110
+
111
+ /**
112
+ * portが空いているか確認する。
113
+ */
114
+ function checkPort(name, host, port) {
115
+ return new Promise((resolve) => {
116
+ const server = net.createServer();
117
+ server.once("error", (error) => {
118
+ resolve(fail(name, `${host}:${port} is not available (${error.code}).`));
119
+ });
120
+ server.once("listening", () => {
121
+ server.close(() => resolve(ok(name, `${host}:${port} is available.`)));
122
+ });
123
+ server.listen(port, host);
124
+ });
125
+ }
126
+
127
+ /**
128
+ * OK診断結果を作る。
129
+ */
130
+ function ok(name, message) {
131
+ return { level: "OK", name, message };
132
+ }
133
+
134
+ /**
135
+ * WARN診断結果を作る。
136
+ */
137
+ function warn(name, message) {
138
+ return { level: "WARN", name, message };
139
+ }
140
+
141
+ /**
142
+ * ERROR診断結果を作る。
143
+ */
144
+ function fail(name, message) {
145
+ return { level: "ERROR", name, message };
146
+ }
147
+
148
+ module.exports = {
149
+ doctor,
150
+ dryRun,
151
+ formatDoctorReports,
152
+ hasDoctorError,
153
+ validateStartup,
154
+ };
package/src/init.js ADDED
@@ -0,0 +1,201 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const readline = require("node:readline/promises");
4
+
5
+ const DEFAULT_CONFIG_FILENAME = "cookie-auth-proxy.config.json";
6
+ const DEFAULT_SUPPORT_DIR = "cookie-auth-proxy";
7
+
8
+ /**
9
+ * initサブコマンドの引数を解析する。
10
+ *
11
+ * @param {string[]} argv initより後ろのCLI引数。
12
+ * @returns {{configPath: string, force: boolean, supportDir?: string}} init実行設定。
13
+ */
14
+ function parseInitArgs(argv) {
15
+ const args = { _: [], force: false };
16
+
17
+ for (let i = 0; i < argv.length; i += 1) {
18
+ const raw = argv[i];
19
+ if (raw === "--force" || raw === "-f") {
20
+ args.force = true;
21
+ continue;
22
+ }
23
+
24
+ if (raw === "--config") {
25
+ args.config = argv[i + 1];
26
+ i += 1;
27
+ continue;
28
+ }
29
+
30
+ if (raw.startsWith("--config=")) {
31
+ args.config = raw.slice("--config=".length);
32
+ continue;
33
+ }
34
+
35
+ if (raw === "--support-dir" || raw === "--data-dir") {
36
+ args.supportDir = argv[i + 1];
37
+ i += 1;
38
+ continue;
39
+ }
40
+
41
+ if (raw.startsWith("--support-dir=")) {
42
+ args.supportDir = raw.slice("--support-dir=".length);
43
+ continue;
44
+ }
45
+
46
+ if (raw.startsWith("--data-dir=")) {
47
+ args.supportDir = raw.slice("--data-dir=".length);
48
+ continue;
49
+ }
50
+
51
+ args._.push(raw);
52
+ }
53
+
54
+ return {
55
+ configPath: args.config || args._[0] || DEFAULT_CONFIG_FILENAME,
56
+ force: args.force,
57
+ supportDir: args.supportDir,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * プロジェクトルート向けの設定ファイルを作成する。
63
+ *
64
+ * @param {string[]} argv initより後ろのCLI引数。
65
+ * @returns {Promise<{configPath: string, supportPaths: string[]}>} 作成した設定ファイルと補助ファイルの絶対パス。
66
+ */
67
+ async function initConfig(argv) {
68
+ const options = parseInitArgs(argv);
69
+ const supportDir = await resolveSupportDir(options.supportDir);
70
+ const stubDir = toConfigPath(path.join(supportDir, "stubs"));
71
+ const overrideDir = toConfigPath(path.join(supportDir, "overrides"));
72
+ const schemaPath = toConfigPath(
73
+ path.join(supportDir, "cookie-auth-proxy.schema.json"),
74
+ );
75
+ const targetPath = path.resolve(process.cwd(), options.configPath);
76
+ const sourcePath = path.join(
77
+ __dirname,
78
+ "..",
79
+ "cookie-auth-proxy.config.example.json",
80
+ );
81
+ const files = [
82
+ {
83
+ sourcePath,
84
+ targetPath,
85
+ displayPath: options.configPath,
86
+ },
87
+ {
88
+ sourcePath: path.join(__dirname, "..", "stubs", "README.md"),
89
+ targetPath: path.resolve(process.cwd(), stubDir, "README.md"),
90
+ displayPath: toConfigPath(path.join(stubDir, "README.md")),
91
+ },
92
+ {
93
+ sourcePath: path.join(__dirname, "..", "overrides", "README.md"),
94
+ targetPath: path.resolve(process.cwd(), overrideDir, "README.md"),
95
+ displayPath: toConfigPath(path.join(overrideDir, "README.md")),
96
+ },
97
+ {
98
+ sourcePath: path.join(__dirname, "cookie-auth-proxy.schema.json"),
99
+ targetPath: path.resolve(process.cwd(), schemaPath),
100
+ displayPath: schemaPath,
101
+ },
102
+ ];
103
+
104
+ files.forEach((file) => assertCanCopyInitFile(file, options.force));
105
+ copyConfigFile({
106
+ sourcePath,
107
+ targetPath,
108
+ schemaPath,
109
+ startupStubImportDir: stubDir,
110
+ startupOverrideImportDir: overrideDir,
111
+ });
112
+ files.slice(1).forEach(copyInitFile);
113
+
114
+ return {
115
+ configPath: targetPath,
116
+ supportPaths: files.slice(1).map((file) => file.targetPath),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * stubs/overridesを作成する親フォルダを決める。
122
+ */
123
+ async function resolveSupportDir(inputDir) {
124
+ if (inputDir !== undefined) return normalizeSupportDir(inputDir);
125
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return DEFAULT_SUPPORT_DIR;
126
+
127
+ const rl = readline.createInterface({
128
+ input: process.stdin,
129
+ output: process.stdout,
130
+ });
131
+
132
+ try {
133
+ const answer = await rl.question(
134
+ `Destination folder for stubs/overrides [${DEFAULT_SUPPORT_DIR}]: `,
135
+ );
136
+ return normalizeSupportDir(answer);
137
+ } finally {
138
+ rl.close();
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 入力された補助ファイル用フォルダをconfig向けの相対パスへ正規化する。
144
+ */
145
+ function normalizeSupportDir(value) {
146
+ const trimmed = String(value || "").trim();
147
+ if (!trimmed) return DEFAULT_SUPPORT_DIR;
148
+ if (path.isAbsolute(trimmed)) {
149
+ throw new Error("Support directory must be relative to the project root.");
150
+ }
151
+
152
+ return toConfigPath(path.normalize(trimmed));
153
+ }
154
+
155
+ /**
156
+ * initで配布するテンプレートファイルをコピーできる状態か確認する。
157
+ */
158
+ function assertCanCopyInitFile({ targetPath, displayPath }, force) {
159
+ if (fs.existsSync(targetPath) && !force) {
160
+ throw new Error(
161
+ `${displayPath} already exists. Use --force to overwrite it.`,
162
+ );
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 設定テンプレートを読み込み、stubs/overridesの初期パスを書き換えて保存する。
168
+ */
169
+ function copyConfigFile({
170
+ sourcePath,
171
+ targetPath,
172
+ startupStubImportDir,
173
+ startupOverrideImportDir,
174
+ schemaPath,
175
+ }) {
176
+ const config = JSON.parse(fs.readFileSync(sourcePath, "utf8"));
177
+ config.$schema = toConfigPath(
178
+ path.relative(path.dirname(targetPath), path.resolve(process.cwd(), schemaPath)),
179
+ );
180
+ config.startupStubImportDir = startupStubImportDir;
181
+ config.startupOverrideImportDir = startupOverrideImportDir;
182
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
183
+ fs.writeFileSync(`${targetPath}`, `${JSON.stringify(config, null, 2)}\n`);
184
+ }
185
+
186
+ /**
187
+ * initで配布するテンプレートファイルをコピーする。
188
+ */
189
+ function copyInitFile({ sourcePath, targetPath }) {
190
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
191
+ fs.copyFileSync(sourcePath, targetPath);
192
+ }
193
+
194
+ /**
195
+ * Windowsでもconfigにはスラッシュ区切りで保存する。
196
+ */
197
+ function toConfigPath(value) {
198
+ return value.split(path.sep).join("/");
199
+ }
200
+
201
+ module.exports = { DEFAULT_CONFIG_FILENAME, DEFAULT_SUPPORT_DIR, initConfig };
package/src/logger.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * プロキシ、管理サーバ、runtime読み込みで共通利用する最小限のロガー。
3
+ * logLevelはconfigに持たせ、各モジュールへloggerインスタンスを配らずに
4
+ * ログ出力量を調整できるようにしている。
5
+ *
6
+ * @param {object} config マージ済みプロキシ設定。
7
+ * @param {"silent"|"error"|"info"|"debug"} level ログ重要度。
8
+ * @param {...unknown} parts consoleへ渡すログ要素。
9
+ */
10
+ function log(config, level, ...parts) {
11
+ const rank = { silent: 0, error: 1, info: 2, debug: 3 };
12
+ if ((rank[config.logLevel] || rank.info) < rank[level]) return;
13
+
14
+ const stream = level === "error" ? console.error : console.log;
15
+ stream(`[${new Date().toISOString()}]`, ...parts);
16
+ }
17
+
18
+ module.exports = { log };
package/src/proxy.js ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+
3
+ const crypto = require("node:crypto");
4
+ const { buildConfig } = require("./config");
5
+ const {
6
+ doctor,
7
+ dryRun,
8
+ formatDoctorReports,
9
+ hasDoctorError,
10
+ } = require("./diagnostics");
11
+ const { initConfig } = require("./init");
12
+ const { createRuntimeState } = require("./runtime");
13
+ const { startAdminServer } = require("./server/admin-server");
14
+ const { startProxyServer } = require("./server/proxy-server");
15
+ const { version } = require("../package.json");
16
+
17
+ /**
18
+ * CLIエントリポイント。
19
+ * 機能本体はサブモジュールへ分離し、起動時の問題を追いやすくしている。
20
+ */
21
+ async function main() {
22
+ const argv = process.argv.slice(2);
23
+ if (shouldShowVersion(argv)) {
24
+ console.log(version);
25
+ return;
26
+ }
27
+
28
+ if (shouldShowHelp(argv)) {
29
+ console.log(helpText(argv[0]));
30
+ return;
31
+ }
32
+
33
+ if (argv[0] === "init") {
34
+ const result = await initConfig(argv.slice(1));
35
+ console.log(`Created ${result.configPath}`);
36
+ result.supportPaths.forEach((filePath) =>
37
+ console.log(`Created ${filePath}`),
38
+ );
39
+ return;
40
+ }
41
+
42
+ if (argv[0] === "doctor") {
43
+ const reports = await doctor(argv.slice(1));
44
+ formatDoctorReports(reports).forEach((line) => console.log(line));
45
+ process.exitCode = hasDoctorError(reports) ? 1 : 0;
46
+ return;
47
+ }
48
+
49
+ if (argv.includes("--dry-run")) {
50
+ dryRun(argv.filter((entry) => entry !== "--dry-run")).forEach((line) =>
51
+ console.log(line),
52
+ );
53
+ return;
54
+ }
55
+
56
+ const config = buildConfig(argv);
57
+ config.adminCsrfToken = crypto.randomBytes(32).toString("base64url");
58
+ config.runtime = createRuntimeState(config);
59
+
60
+ startProxyServer(config);
61
+ startAdminServer(config);
62
+ }
63
+
64
+ /**
65
+ * バージョン表示だけで終了するCLI指定か判定する。
66
+ */
67
+ function shouldShowVersion(argv) {
68
+ return argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v");
69
+ }
70
+
71
+ /**
72
+ * ヘルプ表示だけで終了するCLI指定か判定する。
73
+ */
74
+ function shouldShowHelp(argv) {
75
+ return (
76
+ argv.includes("--help") ||
77
+ argv.includes("-h")
78
+ );
79
+ }
80
+
81
+ /**
82
+ * サブコマンドに応じたCLIヘルプ本文を返す。
83
+ */
84
+ function helpText(command) {
85
+ if (command === "init") {
86
+ return [
87
+ "Usage: cookie-auth-proxy init [configPath] [options]",
88
+ "",
89
+ "Create a project config file and support folders.",
90
+ "",
91
+ "Options:",
92
+ " --config <path> Config file path. Default: cookie-auth-proxy.config.json",
93
+ " --support-dir <path> Folder for stubs, overrides, and schema files.",
94
+ " --force, -f Overwrite existing files.",
95
+ " --help, -h Show this help.",
96
+ ].join("\n");
97
+ }
98
+
99
+ if (command === "doctor") {
100
+ return [
101
+ "Usage: cookie-auth-proxy doctor [options]",
102
+ "",
103
+ "Check config, import folders, ports, and API origin settings.",
104
+ "",
105
+ "Options:",
106
+ " --config <path> Config file path.",
107
+ " --api <origin> API origin for validation.",
108
+ " --client <origin> Client dev server origin.",
109
+ " --host <host> Proxy listen host.",
110
+ " --port <port> Proxy listen port.",
111
+ " --admin-host <host> Admin UI listen host.",
112
+ " --admin-port <port> Admin UI listen port.",
113
+ " --allow-remote-access Allow non-loopback listen hosts.",
114
+ " --help, -h Show this help.",
115
+ ].join("\n");
116
+ }
117
+
118
+ return [
119
+ "Usage: cookie-auth-proxy [apiOrigin] [options]",
120
+ " cookie-auth-proxy init [configPath] [options]",
121
+ " cookie-auth-proxy doctor [options]",
122
+ "",
123
+ "Local development proxy for cookie-authenticated web apps.",
124
+ "",
125
+ "Options:",
126
+ " --config <path> Config file path.",
127
+ " --api <origin> API origin. Also accepted as the first positional argument.",
128
+ " --client <origin> Client dev server origin.",
129
+ " --cookie <cookie> Cookie header injected into API requests.",
130
+ " --host <host> Proxy listen host. Default: localhost",
131
+ " --port <port> Proxy listen port. Default: 4300",
132
+ " --admin-host <host> Admin UI listen host. Default: localhost",
133
+ " --admin-port <port> Admin UI listen port. Default: 4301",
134
+ " --allow-remote-access Allow non-loopback listen hosts.",
135
+ " --startup-stub-import-dir <path>",
136
+ " Folder loaded as stubs on startup.",
137
+ " --startup-override-import-dir <path>",
138
+ " Folder loaded as JSON Overrides on startup.",
139
+ " --server-app-path <path>",
140
+ " Path opened by the admin UI Open Server App button.",
141
+ " --logLevel <silent|error|info|debug>",
142
+ " Log level. Default: info",
143
+ " --dry-run Validate config and startup imports, then exit.",
144
+ " --version, -v Show version.",
145
+ " --help, -h Show this help.",
146
+ "",
147
+ "Examples:",
148
+ " cookie-auth-proxy --config cookie-auth-proxy.config.json",
149
+ " cookie-auth-proxy https://test-api.invalid",
150
+ " cookie-auth-proxy init --support-dir tools/cookie-auth-proxy",
151
+ " cookie-auth-proxy doctor --config cookie-auth-proxy.config.json",
152
+ ].join("\n");
153
+ }
154
+
155
+ main().catch((error) => {
156
+ console.error(error.message);
157
+ process.exitCode = 1;
158
+ });
package/src/regex.js ADDED
@@ -0,0 +1,101 @@
1
+ const MAX_PATTERN_LENGTH = 1000;
2
+
3
+ /**
4
+ * ユーザー入力の正規表現を、明らかに危険な形だけ拒否してからRegExpへ変換する。
5
+ * 完全なReDoS防止ではなく、ローカル開発用設定の事故を減らすための軽量ガード。
6
+ */
7
+ function compileSafeRegex(pattern, label) {
8
+ assertSafeRegexPattern(pattern, label);
9
+ try {
10
+ return new RegExp(pattern);
11
+ } catch (error) {
12
+ const wrapped = new Error(`${label}: invalid regular expression.`);
13
+ wrapped.statusCode = 400;
14
+ wrapped.cause = error;
15
+ throw wrapped;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * 危険度が高い正規表現パターンを拒否する。
21
+ */
22
+ function assertSafeRegexPattern(pattern, label) {
23
+ const value = String(pattern);
24
+
25
+ if (value.length > MAX_PATTERN_LENGTH) {
26
+ throwRegexError(label, "pattern is too long.");
27
+ }
28
+
29
+ if (hasBackreference(value)) {
30
+ throwRegexError(label, "backreferences are not allowed.");
31
+ }
32
+
33
+ if (hasNestedQuantifier(value)) {
34
+ throwRegexError(label, "nested quantified groups are not allowed.");
35
+ }
36
+ }
37
+
38
+ /**
39
+ * \1 や \k<name> のようなバックリファレンスを検出する。
40
+ */
41
+ function hasBackreference(pattern) {
42
+ return /(^|[^\\])\\(?:[1-9]\d*|k<[^>]+>)/.test(pattern);
43
+ }
44
+
45
+ /**
46
+ * (a+)+ や (.*)+ のような、バックトラックが爆発しやすい形を検出する。
47
+ */
48
+ function hasNestedQuantifier(pattern) {
49
+ const source = stripCharacterClassesAndEscapes(pattern);
50
+ const groupWithInnerQuantifierThenOuterQuantifier =
51
+ /\((?:\?:|\?=|\?!|\?<=|\?<!)?[^)]*(?:[+*]|\{\d+(?:,\d*)?\})[^)]*\)(?:[+*?]|\{\d+(?:,\d*)?\})/;
52
+
53
+ return groupWithInnerQuantifierThenOuterQuantifier.test(source);
54
+ }
55
+
56
+ /**
57
+ * 構造検査の誤検知を減らすため、文字クラスとエスケープ済み文字をプレースホルダ化する。
58
+ */
59
+ function stripCharacterClassesAndEscapes(pattern) {
60
+ let result = "";
61
+ let inClass = false;
62
+
63
+ for (let i = 0; i < pattern.length; i += 1) {
64
+ const char = pattern[i];
65
+
66
+ if (char === "\\") {
67
+ result += "x";
68
+ i += 1;
69
+ continue;
70
+ }
71
+
72
+ if (inClass) {
73
+ if (char === "]") {
74
+ inClass = false;
75
+ result += "x";
76
+ }
77
+ continue;
78
+ }
79
+
80
+ if (char === "[") {
81
+ inClass = true;
82
+ result += "x";
83
+ continue;
84
+ }
85
+
86
+ result += char;
87
+ }
88
+
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * 管理APIで入力エラーとして返せるErrorを投げる。
94
+ */
95
+ function throwRegexError(label, reason) {
96
+ const error = new Error(`${label}: potentially unsafe regular expression: ${reason}`);
97
+ error.statusCode = 400;
98
+ throw error;
99
+ }
100
+
101
+ module.exports = { assertSafeRegexPattern, compileSafeRegex };