i-repo 2.3.0 → 2.4.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.
@@ -6,19 +6,24 @@
6
6
  import { Command } from "commander";
7
7
  import { input, password as passwordPrompt, select, confirm } from "@inquirer/prompts";
8
8
  import { IReporterClient } from "../sdk/index.js";
9
- import { getConfig, getConfigWithSource, setConfig, deleteConfig, } from "../config/store.js";
10
- import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, getConfigPath, findProjectRoot, } from "../config/scoped-store.js";
9
+ import { getConfig, getConfigWithSource, clearStoredCredentials, } from "../config/store.js";
10
+ import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, findProjectRoot, } from "../config/scoped-store.js";
11
+ import { assertEndpointAllowed, persistCredentials } from "../config/client.js";
11
12
  import { logHttp } from "../core/logger.js";
12
13
  import { printSuccess, printError, printWarning, printInfo, printDetails, printInteractionHint, withSpinner, t, } from "../ui/index.js";
13
14
  import { t as tr } from "../i18n/index.js";
14
15
  function saveCredentials(endpoint, user, password, scope, savePassword) {
15
- setConfig("endpoint", endpoint, scope);
16
- setConfig("user", user, scope);
17
- if (savePassword) {
18
- setConfig("password", password, scope);
19
- }
20
- const savedPath = getConfigPath(scope);
16
+ const { savedPath, redirectedToUser, userPath } = persistCredentials({
17
+ endpoint,
18
+ user,
19
+ password,
20
+ scope,
21
+ savePassword,
22
+ });
21
23
  printInfo(tr("auth.savedTo", { path: savedPath }));
24
+ if (redirectedToUser) {
25
+ printInfo(tr("auth.userOnlyRedirected", { path: userPath }));
26
+ }
22
27
  if (!savePassword) {
23
28
  printInfo(tr("auth.passwordNotStored"));
24
29
  }
@@ -47,7 +52,15 @@ authCommand
47
52
  if (!endpoint) {
48
53
  endpoint = await input({
49
54
  message: tr("auth.endpoint"),
50
- validate: (v) => v.startsWith("http") || tr("auth.validUrl"),
55
+ validate: (v) => {
56
+ try {
57
+ const u = new URL(v);
58
+ return u.protocol === "https:" || u.protocol === "http:" || tr("auth.validUrl");
59
+ }
60
+ catch {
61
+ return tr("auth.validUrl");
62
+ }
63
+ },
51
64
  });
52
65
  }
53
66
  if (!user) {
@@ -63,6 +76,8 @@ authCommand
63
76
  validate: (v) => v.length > 0 || tr("auth.passwordRequired"),
64
77
  });
65
78
  }
79
+ // Block plain-http / non-https endpoints before sending credentials.
80
+ assertEndpointAllowed(endpoint);
66
81
  // Authenticate via login() and verify with a lightweight API call
67
82
  await withSpinner("authenticating", async () => {
68
83
  const client = new IReporterClient({
@@ -124,7 +139,9 @@ authCommand
124
139
  .description("Clear stored credentials")
125
140
  .action(() => {
126
141
  try {
127
- deleteConfig("user");
142
+ // Remove BOTH user and password from every scope (shared with the Ink
143
+ // logout executor so the two surfaces never diverge).
144
+ clearStoredCredentials();
128
145
  printSuccess(tr("auth.logoutSuccess"));
129
146
  }
130
147
  catch (error) {
@@ -6,7 +6,7 @@
6
6
  import { Command } from "commander";
7
7
  import { getConfigWithSource, setConfig, resetConfig, validKeys, isValidKey, } from "../config/store.js";
8
8
  import { parseDisabledCommands, isConfigPath } from "../config/command-gate.js";
9
- import { getAllConfigPaths, } from "../config/scoped-store.js";
9
+ import { getAllConfigPaths, USER_ONLY_KEYS, } from "../config/scoped-store.js";
10
10
  import { setLocale, t as tr } from "../i18n/index.js";
11
11
  import { printSuccess, printError, printWarning, printDetails, printInfo, out, t, } from "../ui/index.js";
12
12
  const validScopes = ["user", "project", "local"];
@@ -82,6 +82,11 @@ configCommand
82
82
  if (key === "password") {
83
83
  printWarning(tr("config.passwordWarning"));
84
84
  }
85
+ // A user-only key written to project/local is silently ignored at
86
+ // runtime (see USER_ONLY_KEYS) — warn so the value isn't trusted to apply.
87
+ if (USER_ONLY_KEYS.has(key) && opts.scope !== "user") {
88
+ printWarning(tr("config.userOnlyScopeWarning", { key, scope: opts.scope }));
89
+ }
85
90
  const coerced = coerceValue(key, value);
86
91
  if (coerced === null) {
87
92
  process.exitCode = 1;
@@ -13,11 +13,64 @@
13
13
  * 6. TTY prompt: interactive password input (if terminal is attached)
14
14
  */
15
15
  import { IReporterClient } from "../sdk/index.js";
16
+ import type { ConfigScope } from "./scoped-store.js";
16
17
  export interface CreateClientOptions {
17
18
  endpoint?: string;
18
19
  user?: string;
19
20
  password?: string;
20
21
  }
22
+ /** Env var that opts in to plain-http on ANY host (including public ones). */
23
+ export declare const INSECURE_ENV = "IREPO_ALLOW_INSECURE";
24
+ /** Whether the user has explicitly opted in to insecure (plain http) endpoints. */
25
+ export declare function isInsecureAllowed(): boolean;
26
+ /**
27
+ * Whether a host is on the local machine or a private/internal network, where
28
+ * plain http is the common (and far lower risk) reality for on-prem i-Reporter
29
+ * servers. Covers loopback, RFC1918 private IPv4, link-local, IPv6
30
+ * loopback/unique-local/link-local, *.local (mDNS), *.localhost, and bare
31
+ * single-label intranet hostnames (which can't be reached from the public
32
+ * internet without a search domain).
33
+ */
34
+ export declare function isPrivateOrLocalHost(hostname: string): boolean;
35
+ /**
36
+ * Validate that an endpoint is safe to send credentials to.
37
+ *
38
+ * Plain http sends the Login user/password and the session cookie in clear
39
+ * text, readable by any passive eavesdropper on the path. Policy:
40
+ * - https → always allowed.
41
+ * - http to a local/private host (loopback, RFC1918, *.local, intranet name)
42
+ * → allowed with a warning. On-prem i-Reporter servers
43
+ * commonly run plain http on the internal LAN.
44
+ * - http to a public host → rejected, unless IREPO_ALLOW_INSECURE=1 (warned).
45
+ * - any other scheme (file:, data:, ...) → always rejected.
46
+ * Throws ConfigError so the failure is classified (config-error exit code).
47
+ */
48
+ export declare function assertEndpointAllowed(endpoint: string): void;
49
+ export interface PersistCredentialsResult {
50
+ /** Path of the requested scope, where the shareable `user` was written. */
51
+ savedPath: string | undefined;
52
+ /** endpoint/password were forced to the user scope (requested scope ≠ user). */
53
+ redirectedToUser: boolean;
54
+ /** The user-scope config path (where endpoint/password actually live). */
55
+ userPath: string;
56
+ }
57
+ /**
58
+ * Persist login credentials, honoring USER_ONLY_KEYS. `endpoint` and `password`
59
+ * only take effect from the user scope at runtime, so they are ALWAYS written
60
+ * there — writing them to project/local would create config that silently does
61
+ * nothing, and a project-scoped password would risk a plaintext VCS leak. Only
62
+ * the shareable, non-sensitive `user` goes to the requested scope.
63
+ *
64
+ * Shared by the CLI `auth login` and the Ink AuthLoginScreen so both surfaces
65
+ * apply the same trust policy.
66
+ */
67
+ export declare function persistCredentials(args: {
68
+ endpoint: string;
69
+ user: string;
70
+ password: string;
71
+ scope: ConfigScope;
72
+ savePassword: boolean;
73
+ }): PersistCredentialsResult;
21
74
  /**
22
75
  * Create an IReporterClient, resolving credentials from
23
76
  * CLI flags -> environment variables -> config store (scoped) -> interactive prompt.
@@ -16,7 +16,120 @@ import { IReporterClient } from "../sdk/index.js";
16
16
  import { password as passwordPrompt } from "@inquirer/prompts";
17
17
  import { logHttp } from "../core/logger.js";
18
18
  import { ConfigError } from "../core/errors.js";
19
- import { getConfig } from "./store.js";
19
+ import { getConfig, setConfig } from "./store.js";
20
+ import { getConfigPath, getUserConfigPath } from "./scoped-store.js";
21
+ /** Env var that opts in to plain-http on ANY host (including public ones). */
22
+ export const INSECURE_ENV = "IREPO_ALLOW_INSECURE";
23
+ /** Whether the user has explicitly opted in to insecure (plain http) endpoints. */
24
+ export function isInsecureAllowed() {
25
+ const v = process.env[INSECURE_ENV];
26
+ return v === "1" || v?.toLowerCase() === "true";
27
+ }
28
+ /**
29
+ * Whether a host is on the local machine or a private/internal network, where
30
+ * plain http is the common (and far lower risk) reality for on-prem i-Reporter
31
+ * servers. Covers loopback, RFC1918 private IPv4, link-local, IPv6
32
+ * loopback/unique-local/link-local, *.local (mDNS), *.localhost, and bare
33
+ * single-label intranet hostnames (which can't be reached from the public
34
+ * internet without a search domain).
35
+ */
36
+ export function isPrivateOrLocalHost(hostname) {
37
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 [] brackets
38
+ if (h === "localhost" || h.endsWith(".localhost"))
39
+ return true;
40
+ if (h.endsWith(".local"))
41
+ return true;
42
+ // bare single-label hostname (no dots) — an intranet name, not a public FQDN
43
+ if (!h.includes(".") && !h.includes(":"))
44
+ return true;
45
+ if (h === "::1")
46
+ return true; // IPv6 loopback
47
+ if (/^f[cd][0-9a-f]{2}:/.test(h))
48
+ return true; // IPv6 unique-local fc00::/7
49
+ if (h.startsWith("fe80:"))
50
+ return true; // IPv6 link-local
51
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
52
+ if (m) {
53
+ const a = Number(m[1]);
54
+ const b = Number(m[2]);
55
+ if (a === 127)
56
+ return true; // loopback 127/8
57
+ if (a === 10)
58
+ return true; // 10/8
59
+ if (a === 192 && b === 168)
60
+ return true; // 192.168/16
61
+ if (a === 172 && b >= 16 && b <= 31)
62
+ return true; // 172.16/12
63
+ if (a === 169 && b === 254)
64
+ return true; // link-local 169.254/16
65
+ }
66
+ return false;
67
+ }
68
+ /**
69
+ * Validate that an endpoint is safe to send credentials to.
70
+ *
71
+ * Plain http sends the Login user/password and the session cookie in clear
72
+ * text, readable by any passive eavesdropper on the path. Policy:
73
+ * - https → always allowed.
74
+ * - http to a local/private host (loopback, RFC1918, *.local, intranet name)
75
+ * → allowed with a warning. On-prem i-Reporter servers
76
+ * commonly run plain http on the internal LAN.
77
+ * - http to a public host → rejected, unless IREPO_ALLOW_INSECURE=1 (warned).
78
+ * - any other scheme (file:, data:, ...) → always rejected.
79
+ * Throws ConfigError so the failure is classified (config-error exit code).
80
+ */
81
+ export function assertEndpointAllowed(endpoint) {
82
+ let url;
83
+ try {
84
+ url = new URL(endpoint);
85
+ }
86
+ catch {
87
+ throw new ConfigError(`Invalid API endpoint URL: ${endpoint}\n` +
88
+ " Expected an absolute URL, e.g. https://host/ConMasAPI/Rests/APIExecute.aspx");
89
+ }
90
+ if (url.protocol === "https:")
91
+ return;
92
+ if (url.protocol === "http:") {
93
+ if (isPrivateOrLocalHost(url.hostname)) {
94
+ process.stderr.write(`Warning: using plain HTTP to ${url.origin} — credentials are not encrypted. ` +
95
+ "Acceptable on a trusted local/private network; prefer https where available.\n");
96
+ return;
97
+ }
98
+ if (isInsecureAllowed()) {
99
+ process.stderr.write(`Warning: sending credentials over plain HTTP to the public host ${url.origin} ` +
100
+ `(${INSECURE_ENV} is set). Traffic can be intercepted on the network.\n`);
101
+ return;
102
+ }
103
+ throw new ConfigError(`Refusing to send credentials over plain http to a public host: ${endpoint}\n` +
104
+ " They would be transmitted in clear text and can be intercepted.\n" +
105
+ " Use an https:// endpoint. Local/private hosts (localhost, 10.x, 192.168.x, *.local)\n" +
106
+ ` are allowed over http; for a public http host set ${INSECURE_ENV}=1 to override (not recommended).`);
107
+ }
108
+ throw new ConfigError(`Unsupported endpoint scheme "${url.protocol}": ${endpoint}\n` +
109
+ ` Only https:// (or http:// to a local/private host) is allowed.`);
110
+ }
111
+ /**
112
+ * Persist login credentials, honoring USER_ONLY_KEYS. `endpoint` and `password`
113
+ * only take effect from the user scope at runtime, so they are ALWAYS written
114
+ * there — writing them to project/local would create config that silently does
115
+ * nothing, and a project-scoped password would risk a plaintext VCS leak. Only
116
+ * the shareable, non-sensitive `user` goes to the requested scope.
117
+ *
118
+ * Shared by the CLI `auth login` and the Ink AuthLoginScreen so both surfaces
119
+ * apply the same trust policy.
120
+ */
121
+ export function persistCredentials(args) {
122
+ setConfig("user", args.user, args.scope);
123
+ setConfig("endpoint", args.endpoint, "user");
124
+ if (args.savePassword) {
125
+ setConfig("password", args.password, "user");
126
+ }
127
+ return {
128
+ savedPath: getConfigPath(args.scope),
129
+ redirectedToUser: args.scope !== "user",
130
+ userPath: getUserConfigPath(),
131
+ };
132
+ }
20
133
  // In-memory password cache for the CLI session lifetime.
21
134
  let cachedPassword;
22
135
  /**
@@ -34,6 +147,8 @@ export async function createClient(opts = {}) {
34
147
  ' Run "i-repo config set endpoint <url>"\n' +
35
148
  " or set IREPO_ENDPOINT environment variable.");
36
149
  }
150
+ // Block plain-http / non-https endpoints before any credential is sent.
151
+ assertEndpointAllowed(endpoint);
37
152
  if (!user) {
38
153
  throw new ConfigError("User is not configured.\n" +
39
154
  ' Run "i-repo config set user <name>"\n' +
@@ -5,6 +5,18 @@ export interface ConfigWithSource<K extends keyof IRepoConfig = keyof IRepoConfi
5
5
  source: ConfigScope | undefined;
6
6
  path: string | undefined;
7
7
  }
8
+ /**
9
+ * Keys that route credentials or relax safety gates. These are read ONLY from
10
+ * the user scope (plus env vars / CLI flags handled at call sites). A
11
+ * checked-out repository's project/local config must NOT be able to silently:
12
+ * - redirect credentials to another endpoint (`endpoint`)
13
+ * - ship a shared plaintext password (`password`)
14
+ * - forward extra parent-env vars to plugin processes (`pluginPassEnv`)
15
+ * - loosen the destructive-delete gate (`allowDelete`)
16
+ * `disabledCommands` is intentionally NOT here: it can only ADD restrictions
17
+ * (never loosen), and team-shared command hiding is a legitimate use.
18
+ */
19
+ export declare const USER_ONLY_KEYS: ReadonlySet<keyof IRepoConfig>;
8
20
  /** ~/.i-repo/i-repo.json */
9
21
  export declare function getUserConfigPath(): string;
10
22
  /** ~/.i-repo/ */
@@ -34,6 +46,8 @@ export declare function writeScopedConfig(scope: ConfigScope, data: Partial<IRep
34
46
  * Priority: local > project > user
35
47
  */
36
48
  export declare function getMergedConfig(): Partial<IRepoConfig>;
49
+ /** Test seam: reset the per-process shadow-warning memo. */
50
+ export declare function __resetUserOnlyShadowMemo(): void;
37
51
  /**
38
52
  * Get a config value with source information.
39
53
  * Checks scopes from highest to lowest priority.
@@ -11,6 +11,23 @@
11
11
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, unlinkSync } from "node:fs";
12
12
  import { join, dirname, resolve } from "node:path";
13
13
  import { homedir } from "node:os";
14
+ /**
15
+ * Keys that route credentials or relax safety gates. These are read ONLY from
16
+ * the user scope (plus env vars / CLI flags handled at call sites). A
17
+ * checked-out repository's project/local config must NOT be able to silently:
18
+ * - redirect credentials to another endpoint (`endpoint`)
19
+ * - ship a shared plaintext password (`password`)
20
+ * - forward extra parent-env vars to plugin processes (`pluginPassEnv`)
21
+ * - loosen the destructive-delete gate (`allowDelete`)
22
+ * `disabledCommands` is intentionally NOT here: it can only ADD restrictions
23
+ * (never loosen), and team-shared command hiding is a legitimate use.
24
+ */
25
+ export const USER_ONLY_KEYS = new Set([
26
+ "endpoint",
27
+ "password",
28
+ "pluginPassEnv",
29
+ "allowDelete",
30
+ ]);
14
31
  // ── Paths ────────────────────────────────────────────────────
15
32
  const CONFIG_DIR_NAME = ".i-repo";
16
33
  const CONFIG_FILE_NAME = "i-repo.json";
@@ -191,13 +208,60 @@ export function getMergedConfig() {
191
208
  const user = readScopedConfig("user");
192
209
  const project = readScopedConfig("project");
193
210
  const local = readScopedConfig("local");
194
- return { ...user, ...project, ...local };
211
+ const merged = { ...user, ...project, ...local };
212
+ // user-only keys: the effective value is the user-scope value alone, so the
213
+ // merged view (config list) reflects what actually takes effect at runtime.
214
+ for (const key of USER_ONLY_KEYS) {
215
+ if (key in user && user[key] !== undefined) {
216
+ merged[key] = user[key];
217
+ }
218
+ else {
219
+ delete merged[key];
220
+ }
221
+ }
222
+ return merged;
223
+ }
224
+ // A user-only key shadowed by project/local is reported once per key per
225
+ // process (getConfigWithSource is called many times per command).
226
+ const userOnlyShadowChecked = new Set();
227
+ /** Resolve a user-only key from the user scope alone, warning if a lower-trust
228
+ * scope tried (and failed) to set it. */
229
+ function getUserScopeOnly(key) {
230
+ if (!userOnlyShadowChecked.has(key)) {
231
+ userOnlyShadowChecked.add(key);
232
+ for (const scope of ["project", "local"]) {
233
+ const p = getConfigPath(scope);
234
+ if (!p)
235
+ continue;
236
+ const c = readJsonFile(p);
237
+ if (key in c && c[key] !== undefined) {
238
+ console.error(`Warning: ignoring "${key}" from ${scope} config (${p}) — for security this ` +
239
+ `key is only honored from your user config. A checked-out repository must not ` +
240
+ `redirect credentials or relax safety gates. ` +
241
+ `Set it with: i-repo config set ${key} <value> --scope user`);
242
+ }
243
+ }
244
+ }
245
+ const userPath = getUserConfigPath();
246
+ const user = readJsonFile(userPath);
247
+ if (key in user && user[key] !== undefined) {
248
+ return { value: user[key], source: "user", path: userPath };
249
+ }
250
+ return { value: undefined, source: undefined, path: undefined };
251
+ }
252
+ /** Test seam: reset the per-process shadow-warning memo. */
253
+ export function __resetUserOnlyShadowMemo() {
254
+ userOnlyShadowChecked.clear();
195
255
  }
196
256
  /**
197
257
  * Get a config value with source information.
198
258
  * Checks scopes from highest to lowest priority.
199
259
  */
200
260
  export function getConfigWithSource(key) {
261
+ // Credential-routing / safety-gate keys: user scope only (ignore project/local).
262
+ if (USER_ONLY_KEYS.has(key)) {
263
+ return getUserScopeOnly(key);
264
+ }
201
265
  // Check local first (highest priority)
202
266
  const localPath = getLocalConfigPath();
203
267
  if (localPath) {
@@ -31,6 +31,13 @@ export declare function getConfigWithSource<K extends keyof IRepoConfig>(key: K)
31
31
  export declare function setConfig<K extends keyof IRepoConfig>(key: K, value: IRepoConfig[K], scope?: ConfigScope): void;
32
32
  export declare function deleteConfig<K extends keyof IRepoConfig>(key: K, scope?: ConfigScope): void;
33
33
  export declare function resetConfig(scope?: ConfigScope): void;
34
+ /**
35
+ * Clear stored credentials (user AND password) from every scope that has a
36
+ * config file. Shared by the CLI `auth logout` and the Ink logout executor so
37
+ * both surfaces behave identically — leaving the password behind while
38
+ * reporting a successful logout is misleading and a credential-removal gap.
39
+ */
40
+ export declare function clearStoredCredentials(): void;
34
41
  export declare function getAllConfig(): Partial<IRepoConfig>;
35
42
  export declare const validKeys: (keyof IRepoConfig)[];
36
43
  export declare function isValidKey(key: string): key is keyof IRepoConfig;
@@ -6,7 +6,8 @@
6
6
  * < Project (<repo>/.i-repo/i-repo.json)
7
7
  * < Local (<repo>/.i-repo/i-repo.local.json)
8
8
  */
9
- import { getMergedConfig, getConfigWithSource as scopedGetConfigWithSource, setScopedConfig, deleteScopedConfig, resetScopedConfig, } from "./scoped-store.js";
9
+ import { existsSync } from "node:fs";
10
+ import { getMergedConfig, getConfigWithSource as scopedGetConfigWithSource, setScopedConfig, deleteScopedConfig, resetScopedConfig, getAllConfigPaths, } from "./scoped-store.js";
10
11
  export function getConfig(key) {
11
12
  return scopedGetConfigWithSource(key).value;
12
13
  }
@@ -22,6 +23,20 @@ export function deleteConfig(key, scope = "user") {
22
23
  export function resetConfig(scope = "user") {
23
24
  resetScopedConfig(scope);
24
25
  }
26
+ /**
27
+ * Clear stored credentials (user AND password) from every scope that has a
28
+ * config file. Shared by the CLI `auth logout` and the Ink logout executor so
29
+ * both surfaces behave identically — leaving the password behind while
30
+ * reporting a successful logout is misleading and a credential-removal gap.
31
+ */
32
+ export function clearStoredCredentials() {
33
+ for (const { scope, path } of getAllConfigPaths()) {
34
+ if (!path || !existsSync(path))
35
+ continue;
36
+ deleteScopedConfig("user", scope);
37
+ deleteScopedConfig("password", scope);
38
+ }
39
+ }
25
40
  export function getAllConfig() {
26
41
  return getMergedConfig();
27
42
  }
@@ -10,6 +10,7 @@
10
10
  import { appendFileSync, chmodSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { getUserConfigDir } from "../config/scoped-store.js";
13
+ import { sanitizeForLog } from "../ui/width.js";
13
14
  const LOG_DIR_NAME = "logs";
14
15
  const MAX_LOG_FILES = 7;
15
16
  /** Get the log directory path: ~/.i-repo/logs/ */
@@ -63,7 +64,11 @@ function appendLines(lines) {
63
64
  try {
64
65
  ensureLogDir();
65
66
  const logPath = getLogFilePath();
66
- appendFileSync(logPath, lines.join("\n") + "\n", {
67
+ // Neutralize terminal escapes centrally so EVERY entry type (HTTP/ERROR/…),
68
+ // including server-derived messages/remarks/statusText/stacks, is inert when
69
+ // the log is viewed later. Keeps \n/\t so the log stays readable.
70
+ const safeLines = lines.map((line) => sanitizeForLog(line));
71
+ appendFileSync(logPath, safeLines.join("\n") + "\n", {
67
72
  encoding: "utf-8",
68
73
  mode: 0o600,
69
74
  });
@@ -54,7 +54,10 @@ export const en = {
54
54
  passwordRequired: "Password is required",
55
55
  authenticatedSuccessfully: "Authenticated successfully",
56
56
  authenticationFailed: "Authentication failed",
57
- logoutSuccess: "Logged out successfully. Stored user cleared.",
57
+ logoutSuccess: "Logged out successfully. Stored user and password cleared.",
58
+ userOnlyRedirected: "Note: endpoint and password were saved to your user config ({path}).\n" +
59
+ " For security they only take effect from the user scope (a checked-out\n" +
60
+ " repository must not redirect credentials), so they are not written to project/local.",
58
61
  statusTitle: "Connection Info",
59
62
  notConfigured: 'Not configured. Run "i-repo auth login" to get started.',
60
63
  notConfiguredInk: "Not configured. Run auth login to get started.",
@@ -159,6 +162,8 @@ export const en = {
159
162
  invalidFormat: "Invalid format. Must be one of: table, json, csv, ndjson",
160
163
  invalidTimeout: "Invalid timeout. Must be a positive number (seconds).",
161
164
  invalidBoolean: "Invalid value for {key}. Must be true or false.",
165
+ userOnlyScopeWarning: '"{key}" is only read from the user scope for security; a value in {scope} ' +
166
+ "scope is ignored at runtime. Use --scope user to make it take effect.",
162
167
  keyNotSet: "{key} is not set",
163
168
  configTitle: "Configuration",
164
169
  resetSuccess: "Configuration reset to defaults [{scope}]",
@@ -54,7 +54,10 @@ export const ja = {
54
54
  passwordRequired: "パスワードは必須です",
55
55
  authenticatedSuccessfully: "認証に成功しました",
56
56
  authenticationFailed: "認証に失敗しました",
57
- logoutSuccess: "ログアウトしました。保存されたユーザー情報をクリアしました。",
57
+ logoutSuccess: "ログアウトしました。保存されたユーザー情報とパスワードをクリアしました。",
58
+ userOnlyRedirected: "注意: エンドポイントとパスワードは user 設定 ({path}) に保存しました。\n" +
59
+ " セキュリティ上これらは user スコープからのみ有効なため (clone したリポジトリが\n" +
60
+ " 認証情報をすり替えられないように)、project/local には書き込みません。",
58
61
  statusTitle: "接続情報",
59
62
  notConfigured: '未設定です。"i-repo auth login" を実行して開始してください。',
60
63
  notConfiguredInk: "未設定です。auth login を実行して開始してください。",
@@ -159,6 +162,8 @@ export const ja = {
159
162
  invalidFormat: "無効な形式です。table, json, csv, ndjson のいずれかを指定してください",
160
163
  invalidTimeout: "無効なタイムアウト値です。正の数を指定してください(秒)。",
161
164
  invalidBoolean: "{key} の値が無効です。true または false を指定してください。",
165
+ userOnlyScopeWarning: "セキュリティ上「{key}」は user スコープからのみ読み込まれます。{scope} スコープの値は" +
166
+ "実行時に無視されます。有効にするには --scope user を使用してください。",
162
167
  keyNotSet: "{key} は未設定です",
163
168
  configTitle: "設定",
164
169
  resetSuccess: "設定をデフォルトにリセットしました [{scope}]",
@@ -61,6 +61,7 @@ export interface LocaleMessages {
61
61
  authenticatedSuccessfully: string;
62
62
  authenticationFailed: string;
63
63
  logoutSuccess: string;
64
+ userOnlyRedirected: string;
64
65
  statusTitle: string;
65
66
  notConfigured: string;
66
67
  notConfiguredInk: string;
@@ -161,6 +162,7 @@ export interface LocaleMessages {
161
162
  invalidFormat: string;
162
163
  invalidTimeout: string;
163
164
  invalidBoolean: string;
165
+ userOnlyScopeWarning: string;
164
166
  keyNotSet: string;
165
167
  configTitle: string;
166
168
  resetSuccess: string;
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { colors } from "../theme.js";
4
4
  import { t as tr } from "../../i18n/index.js";
5
+ import { sanitizeForDisplay } from "../../ui/width.js";
5
6
  export function DetailView({ data, title }) {
6
7
  const entries = Object.entries(data);
7
8
  const maxKeyLen = Math.max(...entries.map(([k]) => k.length), 0);
@@ -10,13 +11,14 @@ export function DetailView({ data, title }) {
10
11
  const dots = dotLeaderLen - key.length;
11
12
  const leader = dots > 2 ? " " + "\u00B7".repeat(dots - 1) + " " : " ";
12
13
  const isNil = value === null || value === undefined;
13
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: colors.text, children: key }), _jsx(Text, { color: colors.border, children: leader }), isNil ? (_jsx(Text, { dimColor: true, children: tr("common.none") })) : (_jsx(Text, { color: colors.text, children: formatValue(value) }))] }, key));
14
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: colors.text, children: sanitizeForDisplay(key) }), _jsx(Text, { color: colors.border, children: leader }), isNil ? (_jsx(Text, { dimColor: true, children: tr("common.none") })) : (_jsx(Text, { color: colors.text, children: formatValue(value) }))] }, key));
14
15
  })] }));
15
16
  }
16
17
  function formatValue(value) {
17
18
  if (value === null || value === undefined)
18
19
  return "(none)";
19
- if (typeof value === "object")
20
- return JSON.stringify(value);
21
- return String(value);
20
+ const s = typeof value === "object" ? JSON.stringify(value) : String(value);
21
+ // Server-derived values may carry terminal escapes (title rewrite, screen
22
+ // clear); neutralize before rendering to the interactive TTY.
23
+ return sanitizeForDisplay(s);
22
24
  }
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { colors, icons } from "../theme.js";
4
4
  import { t as tr } from "../../i18n/index.js";
5
+ import { sanitizeForDisplay } from "../../ui/width.js";
5
6
  export function ErrorView({ message }) {
6
- return (_jsxs(Box, { flexDirection: "column", marginY: 1, borderStyle: "round", borderColor: colors.error, paddingX: 2, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { inverse: true, color: colors.error, bold: true, children: [" ", icons.error, " ", tr("errors.label"), " "] }) }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: colors.error, children: message }) })] }));
7
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, borderStyle: "round", borderColor: colors.error, paddingX: 2, paddingY: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { inverse: true, color: colors.error, bold: true, children: [" ", icons.error, " ", tr("errors.label"), " "] }) }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { color: colors.error, children: sanitizeForDisplay(message) }) })] }));
7
8
  }
@@ -3,7 +3,7 @@ import { Box, Text, useInput } from "ink";
3
3
  import { Fragment, useMemo, useState } from "react";
4
4
  import { boxChars, colors, icons } from "../theme.js";
5
5
  import { t as tr } from "../../i18n/index.js";
6
- import { displayWidth, truncateToWidth } from "../../ui/width.js";
6
+ import { displayWidth, truncateToWidth, sanitizeForDisplay } from "../../ui/width.js";
7
7
  import { decodeRawNav } from "../keymap.js";
8
8
  /** 1 ページあたりの行数 (ActionBar のページ送りヒント表示判定にも使う) */
9
9
  export const TABLE_PAGE_SIZE = 20;
@@ -33,10 +33,10 @@ export function TableView({ data, columns }) {
33
33
  // Column widths by visual width (CJK-aware), cap to avoid overflow
34
34
  const widths = useMemo(() => cols.map((col) => {
35
35
  const maxData = data.reduce((max, row) => {
36
- const w = displayWidth(String(row[col] ?? ""));
36
+ const w = displayWidth(sanitizeForDisplay(String(row[col] ?? "")));
37
37
  return w > max ? w : max;
38
38
  }, 0);
39
- const headerW = displayWidth(col);
39
+ const headerW = displayWidth(sanitizeForDisplay(col));
40
40
  const natural = Math.min(Math.max(headerW, maxData) + 2, MAX_COL_WIDTH);
41
41
  return Math.max(natural, MIN_COL_WIDTHS[col] ?? 0);
42
42
  }), [cols, data]);
@@ -45,13 +45,13 @@ export function TableView({ data, columns }) {
45
45
  const v = boxChars.vertical;
46
46
  const sep = boxChars.leftMid +
47
47
  widths.map((w, i) => boxChars.horizontal.repeat(w) + (i < widths.length - 1 ? boxChars.midMid : boxChars.rightMid)).join("");
48
- const headerCells = cols.map((col, i) => padToVisualWidth(" " + col, widths[i]));
48
+ const headerCells = cols.map((col, i) => padToVisualWidth(" " + sanitizeForDisplay(col), widths[i]));
49
49
  const lines = [];
50
50
  lines.push(v + headerCells.join(v) + v);
51
51
  lines.push(sep);
52
52
  for (const row of pageData) {
53
53
  const cells = cols.map((col, i) => {
54
- const raw = truncateToWidth(String(row[col] ?? "─"), widths[i] - 2);
54
+ const raw = truncateToWidth(sanitizeForDisplay(String(row[col] ?? "─")), widths[i] - 2);
55
55
  return padToVisualWidth(" " + raw + " ", widths[i]);
56
56
  });
57
57
  lines.push(v + cells.join(v) + v);
@@ -8,9 +8,9 @@ import { readFile } from "node:fs/promises";
8
8
  import { basename } from "node:path";
9
9
  import { PublicStatus } from "../../sdk/index.js";
10
10
  import { saveBinaryDownload, inferFilename, isTextContent } from "../../utils/file.js";
11
- import { getConfig, getConfigWithSource, setConfig, deleteConfig, resetConfig, isValidKey, validKeys, } from "../../config/store.js";
11
+ import { getConfig, getConfigWithSource, setConfig, clearStoredCredentials, resetConfig, isValidKey, validKeys, } from "../../config/store.js";
12
12
  import { parseDisabledCommands, isConfigPath } from "../../config/command-gate.js";
13
- import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, } from "../../config/scoped-store.js";
13
+ import { getUserConfigPath, getProjectConfigPath, getLocalConfigPath, USER_ONLY_KEYS, } from "../../config/scoped-store.js";
14
14
  import { t as tr, setLocale } from "../../i18n/index.js";
15
15
  import { assertNumericId } from "../../core/errors.js";
16
16
  import { escapeCsv } from "../../ui/formatters/csv.js";
@@ -104,7 +104,7 @@ const authLogin = async () => {
104
104
  return { type: "message", text: tr("executors.useAuthLoginScreen") };
105
105
  };
106
106
  const authLogout = async () => {
107
- deleteConfig("user");
107
+ clearStoredCredentials();
108
108
  return { type: "message", text: tr("auth.logoutSuccess") };
109
109
  };
110
110
  const authStatus = async () => {
@@ -144,18 +144,23 @@ const configSet = async (p) => {
144
144
  return { type: "error", message: tr("config.invalidFormat") };
145
145
  if (key === "language" && !["en", "ja"].includes(value))
146
146
  return { type: "error", message: tr("config.invalidLanguage") };
147
+ // user-only keys set to project/local are ignored at runtime — warn, like the
148
+ // CLI config setter, so the TUI doesn't report unusable config as applied.
149
+ const userOnlyNote = USER_ONLY_KEYS.has(key) && scope !== "user"
150
+ ? `${tr("config.userOnlyScopeWarning", { key, scope })}\n`
151
+ : "";
147
152
  if (key === "timeout") {
148
153
  const n = Number(value);
149
154
  if (isNaN(n) || n <= 0)
150
155
  return { type: "error", message: tr("config.invalidTimeout") };
151
156
  setConfig(key, n, scope);
152
- return { type: "message", text: `${key} = ${value} [${scope}]` };
157
+ return { type: "message", text: `${userOnlyNote}${key} = ${value} [${scope}]` };
153
158
  }
154
159
  if (key === "quiet" || key === "allowDelete") {
155
160
  if (!["true", "false"].includes(value))
156
161
  return { type: "error", message: tr("config.invalidBoolean", { key }) };
157
162
  setConfig(key, value === "true", scope);
158
- return { type: "message", text: `${key} = ${value} [${scope}]` };
163
+ return { type: "message", text: `${userOnlyNote}${key} = ${value} [${scope}]` };
159
164
  }
160
165
  if (key === "disabledCommands") {
161
166
  // The config command can never be disabled (lockout prevention),
@@ -165,13 +170,13 @@ const configSet = async (p) => {
165
170
  const joined = kept.join(", ");
166
171
  setConfig(key, joined, scope);
167
172
  const note = kept.length < paths.length ? `${tr("gate.cannotDisableConfig")}\n` : "";
168
- return { type: "message", text: `${note}${key} = ${joined} [${scope}]` };
173
+ return { type: "message", text: `${userOnlyNote}${note}${key} = ${joined} [${scope}]` };
169
174
  }
170
175
  setConfig(key, value, scope);
171
176
  if (key === "language")
172
177
  setLocale(value);
173
178
  const displayValue = key === "password" ? "****" : value;
174
- return { type: "message", text: `${key} = ${displayValue} [${scope}]` };
179
+ return { type: "message", text: `${userOnlyNote}${key} = ${displayValue} [${scope}]` };
175
180
  };
176
181
  const configGet = async (p) => {
177
182
  const key = p.key;
@@ -6,8 +6,9 @@ import { IReporterClient } from "../../sdk/index.js";
6
6
  import { Header } from "../components/Layout.js";
7
7
  import { SpinnerView } from "../components/SpinnerView.js";
8
8
  import { colors, icons } from "../theme.js";
9
- import { getConfig, setConfig } from "../../config/store.js";
10
- import { findProjectRoot, getConfigPath, getUserConfigPath, getProjectConfigPath, getLocalConfigPath } from "../../config/scoped-store.js";
9
+ import { getConfig } from "../../config/store.js";
10
+ import { findProjectRoot, getUserConfigPath, getProjectConfigPath, getLocalConfigPath } from "../../config/scoped-store.js";
11
+ import { assertEndpointAllowed, persistCredentials } from "../../config/client.js";
11
12
  import { logHttp, logInteractiveCommand } from "../../core/logger.js";
12
13
  import { t as tr } from "../../i18n/index.js";
13
14
  export function AuthLoginScreen({ onDone, onBack }) {
@@ -17,6 +18,7 @@ export function AuthLoginScreen({ onDone, onBack }) {
17
18
  const [passwordValue, setPasswordValue] = useState("");
18
19
  const [selectedScope, setSelectedScope] = useState(null);
19
20
  const [savedPath, setSavedPath] = useState(null);
21
+ const [redirectedUserPath, setRedirectedUserPath] = useState(null);
20
22
  const [errorMsg, setErrorMsg] = useState("");
21
23
  const timerRef = useRef(undefined);
22
24
  useEffect(() => {
@@ -49,6 +51,8 @@ export function AuthLoginScreen({ onDone, onBack }) {
49
51
  setPasswordValue(pw);
50
52
  logInteractiveCommand("auth login", { user });
51
53
  try {
54
+ // Block plain-http to a public host (same HTTPS policy as the CLI path).
55
+ assertEndpointAllowed(endpoint);
52
56
  const client = new IReporterClient({ apiEndpoint: endpoint, onHttp: logHttp });
53
57
  await client.login({ user, password: pw });
54
58
  await client.systems.getUrlScheme();
@@ -98,16 +102,22 @@ export function AuthLoginScreen({ onDone, onBack }) {
98
102
  { label: `${icons.success} ${tr("common.yes")}`, value: "yes" },
99
103
  ], onChange: (value) => {
100
104
  if (selectedScope) {
101
- setConfig("endpoint", endpoint, selectedScope);
102
- setConfig("user", user, selectedScope);
103
- if (value === "yes") {
104
- setConfig("password", passwordValue, selectedScope);
105
- }
106
- setSavedPath(getConfigPath(selectedScope) ?? null);
105
+ // endpoint/password are user-only keys: persistCredentials
106
+ // redirects them to the user scope (only `user` goes to the
107
+ // chosen scope), matching the CLI path.
108
+ const { savedPath: sp, redirectedToUser, userPath } = persistCredentials({
109
+ endpoint,
110
+ user,
111
+ password: passwordValue,
112
+ scope: selectedScope,
113
+ savePassword: value === "yes",
114
+ });
115
+ setSavedPath(sp ?? null);
116
+ setRedirectedUserPath(redirectedToUser ? userPath : null);
107
117
  }
108
118
  setStep("done");
109
119
  timerRef.current = setTimeout(() => onDone(passwordValue), 1500);
110
- } })] })), step === "done" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.success, children: [icons.success, " ", tr("auth.authenticatedAs", { user })] }), savedPath && (_jsxs(Text, { color: colors.dim, children: [" ", tr("auth.savedTo", { path: savedPath })] }))] })), step === "error" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.error, children: [icons.error, " ", tr("auth.authFailed")] }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.dim, children: errorMsg }) })] })), (step === "endpoint" || step === "user" || step === "password" || step === "error") && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.dim, children: step === "endpoint"
120
+ } })] })), step === "done" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.success, children: [icons.success, " ", tr("auth.authenticatedAs", { user })] }), savedPath && (_jsxs(Text, { color: colors.dim, children: [" ", tr("auth.savedTo", { path: savedPath })] })), redirectedUserPath && (_jsxs(Text, { color: colors.dim, children: [" ", tr("auth.userOnlyRedirected", { path: redirectedUserPath })] }))] })), step === "error" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.error, children: [icons.error, " ", tr("auth.authFailed")] }), _jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: colors.dim, children: errorMsg }) })] })), (step === "endpoint" || step === "user" || step === "password" || step === "error") && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.dim, children: step === "endpoint"
111
121
  ? tr("common.escapeToGoBack")
112
122
  : tr("optionForm.navPrev", { arrow: icons.arrow }) }) }))] }));
113
123
  }
@@ -16,6 +16,7 @@ import { colors, icons } from "../theme.js";
16
16
  import { commandGroups } from "../registry/commands.js";
17
17
  import { executors } from "../registry/executors.js";
18
18
  import { getConfig } from "../../config/store.js";
19
+ import { assertEndpointAllowed } from "../../config/client.js";
19
20
  import { uiGateReason } from "../../config/command-gate.js";
20
21
  import { logHttp, logInteractiveCommand } from "../../core/logger.js";
21
22
  import { t as tr } from "../../i18n/index.js";
@@ -97,6 +98,8 @@ export function CommandScreen({ group, onBack, sessionPassword, onPasswordResolv
97
98
  setPhase({ step: "password-prompt", command: cmd, params });
98
99
  return;
99
100
  }
101
+ // Block plain-http to a public host (same HTTPS policy as the CLI path).
102
+ assertEndpointAllowed(endpoint);
100
103
  client = new IReporterClient({
101
104
  apiEndpoint: endpoint,
102
105
  credentials: { user, password: pw },
@@ -171,10 +171,24 @@ function parsePassEnv(raw) {
171
171
  const p = part.trim().toUpperCase();
172
172
  if (!p)
173
173
  continue;
174
- if (p.endsWith("*"))
175
- prefixes.push(p.slice(0, -1));
176
- else
174
+ if (p.endsWith("*")) {
175
+ const pfx = p.slice(0, -1);
176
+ // A bare "*" (empty prefix) or a 1-char prefix matches almost every
177
+ // variable name, which silently defeats the allow-list and forwards the
178
+ // whole parent environment (AWS_SECRET_ACCESS_KEY, DB URLs, ...) to an
179
+ // arbitrary PATH binary. Require at least 2 chars; drop and warn otherwise.
180
+ if (pfx.length >= 2) {
181
+ prefixes.push(pfx);
182
+ }
183
+ else {
184
+ console.error(`Warning: ignoring pluginPassEnv pattern "${part.trim()}" — a wildcard ` +
185
+ `prefix shorter than 2 characters is too broad and would forward most ` +
186
+ `environment variables to plugins.`);
187
+ }
188
+ }
189
+ else {
177
190
  names.add(p);
191
+ }
178
192
  }
179
193
  return { names, prefixes };
180
194
  }
@@ -7,11 +7,21 @@ export declare class HttpClient {
7
7
  private readonly urlEncodedTransport;
8
8
  private readonly urlEncodedFetchBodyMode;
9
9
  private readonly onHttp?;
10
+ /** Node-like runtime (undici): manual redirects are readable, so the fetch
11
+ * path can enforce same-origin. In browsers manual redirects are opaque. */
12
+ private readonly isNodeRuntime;
10
13
  private cookieJar;
11
14
  private warmupPromise;
12
15
  /** 複数の Set-Cookie ヘッダーを cookieJar に反映する */
13
16
  private applySetCookies;
14
17
  private applySetCookiesFromIncoming;
18
+ /**
19
+ * Reject a redirect target that leaves the originally-configured origin.
20
+ * `URL.origin` normalizes default ports (https://h:443 -> https://h), so an
21
+ * explicit-port redirect to the same host still passes; an https->http
22
+ * downgrade or a different host/port is blocked.
23
+ */
24
+ private assertSameOrigin;
15
25
  constructor(config: IReporterConfig);
16
26
  /**
17
27
  * リクエストの所要時間と成否を onHttp に通知しつつ fn を実行する。
@@ -80,6 +90,7 @@ export declare class HttpClient {
80
90
  get hasSession(): boolean;
81
91
  private createUrlEncodedHeaders;
82
92
  private createUrlEncodedRequestBody;
93
+ private withCookieHeader;
83
94
  private sendFetchRequest;
84
95
  private readArrayBuffer;
85
96
  }
@@ -10,6 +10,9 @@ export class HttpClient {
10
10
  urlEncodedTransport;
11
11
  urlEncodedFetchBodyMode;
12
12
  onHttp;
13
+ /** Node-like runtime (undici): manual redirects are readable, so the fetch
14
+ * path can enforce same-origin. In browsers manual redirects are opaque. */
15
+ isNodeRuntime;
13
16
  cookieJar = new Map();
14
17
  warmupPromise = null;
15
18
  /** 複数の Set-Cookie ヘッダーを cookieJar に反映する */
@@ -32,13 +35,26 @@ export class HttpClient {
32
35
  const list = Array.isArray(raw) ? raw : [raw];
33
36
  this.applySetCookies(list);
34
37
  }
38
+ /**
39
+ * Reject a redirect target that leaves the originally-configured origin.
40
+ * `URL.origin` normalizes default ports (https://h:443 -> https://h), so an
41
+ * explicit-port redirect to the same host still passes; an https->http
42
+ * downgrade or a different host/port is blocked.
43
+ */
44
+ assertSameOrigin(target) {
45
+ const base = new URL(this.baseUrl);
46
+ if (target.origin !== base.origin) {
47
+ throw new NetworkError(`Refusing to follow cross-origin redirect from ${base.origin} to ${target.origin}: ` +
48
+ `session cookies and login credentials would be exposed to a different host.`);
49
+ }
50
+ }
35
51
  constructor(config) {
36
52
  this.baseUrl = config.apiEndpoint.replace(/\/$/, "");
37
53
  this.timeout = config.timeout ?? 30_000;
38
54
  this.userSignal = config.signal;
55
+ this.isNodeRuntime = typeof process !== "undefined" && process.versions?.node != null;
39
56
  this.urlEncodedTransport =
40
- config.urlEncodedTransport ??
41
- (typeof process !== "undefined" && process.versions?.node != null ? "node" : "fetch");
57
+ config.urlEncodedTransport ?? (this.isNodeRuntime ? "node" : "fetch");
42
58
  this.urlEncodedFetchBodyMode = config.urlEncodedFetchBodyMode ?? "string";
43
59
  this.onHttp = config.onHttp;
44
60
  }
@@ -238,7 +254,17 @@ export class HttpClient {
238
254
  if (loc &&
239
255
  [301, 302, 303, 307, 308].includes(result.statusCode) &&
240
256
  redirectsLeft > 0) {
241
- currentUrl = new URL(loc, currentUrl).href;
257
+ const next = new URL(loc, currentUrl);
258
+ // Cookies and the (credential-bearing) POST body are re-attached on every
259
+ // hop in singleNodePost. Unlike fetch's `redirect:"follow"`, which strips
260
+ // sensitive headers on a cross-origin redirect, this manual loop would
261
+ // re-send the session cookie and the Login user/password to wherever the
262
+ // server points. A malicious/compromised server (or MITM) returning
263
+ // `30x Location: http://attacker/` could harvest them — including an
264
+ // https->http downgrade. Only follow redirects that stay on the
265
+ // originally-configured origin.
266
+ this.assertSameOrigin(next);
267
+ currentUrl = next.href;
242
268
  redirectsLeft--;
243
269
  continue;
244
270
  }
@@ -377,25 +403,77 @@ export class HttpClient {
377
403
  ? body
378
404
  : body.toString();
379
405
  }
380
- async sendFetchRequest(body, headers, signal = createRequestSignal(this.timeout, this.userSignal)) {
381
- let response;
382
- try {
383
- response = await globalThis.fetch(this.baseUrl, {
384
- method: "POST",
385
- headers,
386
- body,
387
- signal,
388
- redirect: "follow",
389
- });
406
+ withCookieHeader(headers) {
407
+ const out = { ...headers };
408
+ if (this.cookieJar.size > 0) {
409
+ out["Cookie"] = Array.from(this.cookieJar.entries())
410
+ .map(([name, value]) => `${name}=${value}`)
411
+ .join("; ");
390
412
  }
391
- catch (err) {
392
- throw toNetworkError(err, this.timeout, this.baseUrl);
413
+ return out;
414
+ }
415
+ async sendFetchRequest(body, headers, signal = createRequestSignal(this.timeout, this.userSignal)) {
416
+ // Browser: redirect: "manual" yields an opaque, unfollowable response (status
417
+ // 0, no readable Location), so we cannot inspect the target to enforce
418
+ // same-origin here. Defer to the browser's native redirect handling and its
419
+ // built-in cross-origin credential protections — a manually-set Cookie is a
420
+ // forbidden header the browser drops, so session cookies are never replayed
421
+ // cross-origin. (Cross-origin redirect enforcement on the security-critical
422
+ // path is handled by the Node transport, which the CLI uses by default.)
423
+ if (!this.isNodeRuntime) {
424
+ let response;
425
+ try {
426
+ response = await globalThis.fetch(this.baseUrl, {
427
+ method: "POST",
428
+ headers: this.withCookieHeader(headers),
429
+ body,
430
+ signal,
431
+ redirect: "follow",
432
+ });
433
+ }
434
+ catch (err) {
435
+ throw toNetworkError(err, this.timeout, this.baseUrl);
436
+ }
437
+ this.applySetCookies(response.headers.getSetCookie?.() ?? []);
438
+ if (!response.ok) {
439
+ throw new HttpError(response.status, response.statusText);
440
+ }
441
+ return response;
393
442
  }
394
- this.applySetCookies(response.headers.getSetCookie?.() ?? []);
395
- if (!response.ok) {
396
- throw new HttpError(response.status, response.statusText);
443
+ // Node (undici): follow redirects MANUALLY so we can enforce the same-origin
444
+ // check before replaying the request. With redirect: "follow", a cross-origin
445
+ // 307/308 preserves and re-sends the URL-encoded body — which for Login
446
+ // carries user/password — to the redirect target. Mirrors nodePostUrlEncoded.
447
+ let currentUrl = this.baseUrl;
448
+ let redirectsLeft = 5;
449
+ while (true) {
450
+ let response;
451
+ try {
452
+ response = await globalThis.fetch(currentUrl, {
453
+ method: "POST",
454
+ headers: this.withCookieHeader(headers),
455
+ body,
456
+ signal,
457
+ redirect: "manual",
458
+ });
459
+ }
460
+ catch (err) {
461
+ throw toNetworkError(err, this.timeout, currentUrl);
462
+ }
463
+ this.applySetCookies(response.headers.getSetCookie?.() ?? []);
464
+ const loc = response.headers.get("location");
465
+ if (loc && [301, 302, 303, 307, 308].includes(response.status) && redirectsLeft > 0) {
466
+ const next = new URL(loc, currentUrl);
467
+ this.assertSameOrigin(next); // throws on cross-origin / https->http downgrade
468
+ currentUrl = next.href;
469
+ redirectsLeft--;
470
+ continue;
471
+ }
472
+ if (!response.ok) {
473
+ throw new HttpError(response.status, response.statusText);
474
+ }
475
+ return response;
397
476
  }
398
- return response;
399
477
  }
400
478
  async readArrayBuffer(response) {
401
479
  try {
@@ -19,6 +19,17 @@ export declare function stripAnsi(str: string): string;
19
19
  * raw 忠実を壊すべきでない)。色付けは本関数より後段で適用するため除去されない。
20
20
  */
21
21
  export declare function sanitizeForDisplay(str: string): string;
22
+ /**
23
+ * ログファイル向けの無害化。`sanitizeForDisplay` と違い、ログの行構造
24
+ * (`\n`) とインデント (`\t`) は保持し、それ以外の端末乗っ取り/破壊しうる
25
+ * 制御文字 (ESC/BEL/CR ほか C0・DEL・C1) を可視の `\uXXXX` にエスケープする。
26
+ *
27
+ * サーバ由来の文字列 (エラーメッセージ・remarks・statusText・スタック等) が
28
+ * `~/.i-repo/logs/*.log` に生で書かれると、後で cat/less -R で開いたときに
29
+ * タイトル書換・画面消去・偽プロンプトが発動しうる。バイトは記録しつつ無効化
30
+ * する (JSON 出力経路と同じ「エスケープして残す」方針)。
31
+ */
32
+ export declare function sanitizeForLog(str: string): string;
22
33
  /** Terminal cell width of a single code point: 0, 1, or 2 */
23
34
  export declare function charWidth(cp: number): number;
24
35
  /** Visual display width of a string (ANSI codes stripped, CJK = 2 columns) */
package/dist/ui/width.js CHANGED
@@ -27,6 +27,21 @@ export function sanitizeForDisplay(str) {
27
27
  // eslint-disable-next-line no-control-regex
28
28
  return str.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
29
29
  }
30
+ /**
31
+ * ログファイル向けの無害化。`sanitizeForDisplay` と違い、ログの行構造
32
+ * (`\n`) とインデント (`\t`) は保持し、それ以外の端末乗っ取り/破壊しうる
33
+ * 制御文字 (ESC/BEL/CR ほか C0・DEL・C1) を可視の `\uXXXX` にエスケープする。
34
+ *
35
+ * サーバ由来の文字列 (エラーメッセージ・remarks・statusText・スタック等) が
36
+ * `~/.i-repo/logs/*.log` に生で書かれると、後で cat/less -R で開いたときに
37
+ * タイトル書換・画面消去・偽プロンプトが発動しうる。バイトは記録しつつ無効化
38
+ * する (JSON 出力経路と同じ「エスケープして残す」方針)。
39
+ */
40
+ export function sanitizeForLog(str) {
41
+ // 0x09(タブ) と 0x0a(改行) だけ残し、他の C0・DEL・C1 をエスケープ。
42
+ // eslint-disable-next-line no-control-regex
43
+ return str.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
44
+ }
30
45
  /** Terminal cell width of a single code point: 0, 1, or 2 */
31
46
  export function charWidth(cp) {
32
47
  // Zero-width: ZWSP, ZWJ, combining marks, Hangul jungseong/jongseong, variation selectors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i-repo",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Modern CLI for ConMas i-Reporter - Built for humans and AI",
5
5
  "type": "module",
6
6
  "bin": {