mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014b13e07e | ||
|
|
cea229e800 | ||
|
|
c939df7564 |
No files matched your search
+15
-7
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
} from "../domain/terminalPromptSecurity";
|
||||
@@ -452,6 +452,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
), [sessionId]);
|
||||
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
|
||||
const sensitivePromptOutputTailRef = useRef("");
|
||||
const heredocPromptOriginRef = useRef("");
|
||||
const promptSecurityPendingCrRef = useRef(false);
|
||||
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
||||
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
||||
|
||||
@@ -2011,10 +2013,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
chunk: string,
|
||||
meta?: TerminalSessionDataMeta,
|
||||
) => {
|
||||
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
chunk,
|
||||
);
|
||||
const folded = foldTerminalPromptSecurityOutput({
|
||||
origin: heredocPromptOriginRef.current,
|
||||
current: sensitivePromptOutputTailRef.current,
|
||||
pendingCr: promptSecurityPendingCrRef.current,
|
||||
}, chunk);
|
||||
heredocPromptOriginRef.current = folded.origin;
|
||||
sensitivePromptOutputTailRef.current = folded.current;
|
||||
promptSecurityPendingCrRef.current = folded.pendingCr;
|
||||
const promptSecurityOptions = {
|
||||
allowHostStyleGreaterThan: isNetworkDevice,
|
||||
alternateScreen: termRef.current
|
||||
@@ -2030,17 +2036,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
autocompleteCloseRef.current?.();
|
||||
} else {
|
||||
sensitivePromptOutputTailRef.current = "";
|
||||
heredocPromptOriginRef.current = "";
|
||||
promptSecurityPendingCrRef.current = false;
|
||||
}
|
||||
return;
|
||||
} else if (isUntrustedTerminalInputPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = false;
|
||||
|
||||
@@ -77,6 +77,8 @@ function isConfirmedTerminalShellPrompt(promptText, options = {}) {
|
||||
if (code >= 0xE000 && code <= 0xF8FF) return true;
|
||||
}
|
||||
if (/[$#%]$/u.test(prompt)) return true;
|
||||
// POSIX sh/bash/ksh default PS2. Trusted only with heredoc context (#3796).
|
||||
if (prompt === ">") return isHeredocPs2Continuation(promptText, options);
|
||||
if (!prompt.endsWith(">")) return false;
|
||||
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
|
||||
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
|
||||
@@ -84,6 +86,53 @@ function isConfirmedTerminalShellPrompt(promptText, options = {}) {
|
||||
&& /^[A-Za-z0-9_.-]+(?:\([^)]{1,128}\))?>$/u.test(prompt);
|
||||
}
|
||||
|
||||
function splitTerminalLogicalLines(value) {
|
||||
const lines = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const ch = value[i];
|
||||
if (ch !== "\n" && ch !== "\r") continue;
|
||||
lines.push(value.slice(start, i));
|
||||
if (ch === "\r" && value[i + 1] === "\n") i += 1;
|
||||
start = i + 1;
|
||||
}
|
||||
lines.push(value.slice(start));
|
||||
return lines;
|
||||
}
|
||||
|
||||
function isBashPs2ContinuationLine(line) {
|
||||
const trimmed = line.trim();
|
||||
return trimmed === ">" || trimmed.startsWith("> ");
|
||||
}
|
||||
|
||||
function commandAfterConfirmedShellPrompt(line, options) {
|
||||
const text = line.trim();
|
||||
for (let i = 0; i < text.length - 1; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch !== "$" && ch !== "#" && ch !== "%" && ch !== ">") continue;
|
||||
const rest = text.slice(i + 1);
|
||||
if (!/^\s+\S/u.test(rest)) continue;
|
||||
const candidate = text.slice(0, i + 1);
|
||||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||||
if ((ch === "$" || ch === "#" || ch === "%") && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||||
continue;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bare ">" is bash PS2 only inside a heredoc opened by a confirmed shell
|
||||
// command (#3796). read -s -p '> ' and other origins stay fail-closed.
|
||||
function isHeredocPs2Continuation(value, options) {
|
||||
const lines = splitTerminalLogicalLines(stripTerminalControlSequences(value));
|
||||
let index = lines.length - 2;
|
||||
while (index >= 0 && isBashPs2ContinuationLine(lines[index])) index -= 1;
|
||||
if (index < 0) return false;
|
||||
const command = commandAfterConfirmedShellPrompt(lines[index], options);
|
||||
return command != null && command.includes("<<");
|
||||
}
|
||||
|
||||
function isPlausibleShellPromptBody(body) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return true;
|
||||
@@ -117,7 +166,8 @@ function isUntrustedTerminalInputPrompt(value, options = {}) {
|
||||
if (options.alternateScreen && /^:\s*$/.test(prompt)) return false;
|
||||
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
// Pass the original text so a bare ">" can see heredoc lines above it.
|
||||
return !isConfirmedTerminalShellPrompt(value, options);
|
||||
}
|
||||
|
||||
module.exports = { isUntrustedTerminalInputPrompt };
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isSensitiveTerminalChallenge,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
@@ -122,6 +123,69 @@ test('ordinary shell commands ending with a colon are not untrusted input prompt
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
|
||||
});
|
||||
|
||||
test('bash PS2 is trusted only while a heredoc continuation is open (#3796)', () => {
|
||||
// CentOS-7-style bash has no bracketed paste, so a heredoc paste echoes the
|
||||
// default PS2 "> " after each line. That must keep broadcast on.
|
||||
const heredocEcho = '[root@h ~]# cat >> f << EOF\r\n> ';
|
||||
assert.equal(isConfirmedTerminalShellPrompt(heredocEcho), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(heredocEcho), false);
|
||||
const heredocBody = '[root@h ~]# cat >> f << EOF\r\n> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n> ';
|
||||
assert.equal(isConfirmedTerminalShellPrompt(heredocBody), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(heredocBody), false);
|
||||
// No preceding command, or an echo-disabled prompt labelled ">", fails closed.
|
||||
assert.equal(isConfirmedTerminalShellPrompt('>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('> '), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('> '), true);
|
||||
const secretPrompt = "[root@h ~]# read -s -p '> ' secret\r\n> ";
|
||||
assert.equal(isConfirmedTerminalShellPrompt(secretPrompt), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(secretPrompt), true);
|
||||
// The echoed terminator line is ordinary output, not a prompt latch.
|
||||
assert.equal(isConfirmedTerminalShellPrompt('> EOF'), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('> EOF'), false);
|
||||
// Labelled challenges stay fail-closed. zsh "heredoc>" is out of scope.
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Custom>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Token>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password:'), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Custom>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Token>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Password:'), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('heredoc>'), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('heredoc>'), false);
|
||||
});
|
||||
|
||||
test('prompt observation keeps the heredoc opener and drops it after the shell returns (#3796)', () => {
|
||||
let state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, '[root@h ~]# cat >> f << EOF\r\n');
|
||||
state = foldTerminalPromptSecurityOutput(state, '> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n');
|
||||
state = foldTerminalPromptSecurityOutput(state, '> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), false);
|
||||
|
||||
state = foldTerminalPromptSecurityOutput(state, "\r\n[root@h ~]# ");
|
||||
state = foldTerminalPromptSecurityOutput(state, '\r\n> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
|
||||
state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, "[root@h ~]# read -s -p '> ' secret\r\n");
|
||||
state = foldTerminalPromptSecurityOutput(state, '> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
|
||||
state = foldTerminalPromptSecurityOutput({ origin: '', current: '' }, '> ');
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
});
|
||||
|
||||
test('prompt observation coalesces a CRLF split across chunks (#3796)', () => {
|
||||
let state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, "[root@h ~]# cat >> f << EOF\r");
|
||||
state = foldTerminalPromptSecurityOutput(state, '\n> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), false);
|
||||
});
|
||||
|
||||
test('vim ex command-line is not an untrusted input prompt (#3422)', () => {
|
||||
assert.equal(isUntrustedTerminalInputPrompt(':', { alternateScreen: true }), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(': ', { alternateScreen: true }), false);
|
||||
@@ -147,6 +211,14 @@ test('renderer and main-process prompt classifiers stay aligned', () => {
|
||||
['Challenge # 1:', undefined, true],
|
||||
['router> show ip:', { allowHostStyleGreaterThan: true }, false],
|
||||
['router> show ip:', undefined, true],
|
||||
['>', undefined, true],
|
||||
['> ', undefined, true],
|
||||
['[root@h ~]# cat >> f << EOF\r\n> ', undefined, false],
|
||||
['[root@h ~]# cat >> f << EOF\r\n> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n> ', undefined, false],
|
||||
["[root@h ~]# read -s -p '> ' secret\r\n> ", undefined, true],
|
||||
['Custom>', undefined, true],
|
||||
['Token>', undefined, true],
|
||||
['Password:', undefined, true],
|
||||
];
|
||||
|
||||
for (const [prompt, options, expected] of cases) {
|
||||
|
||||
@@ -72,6 +72,45 @@ export function appendTerminalPromptSecurityTail(previous: string, chunk: string
|
||||
return combined.slice(boundary + 1).slice(-MAX_PROMPT_SECURITY_TAIL_CHARS);
|
||||
}
|
||||
|
||||
function nextHeredocPromptOrigin(origin: string, completedLine: string): string {
|
||||
const plain = stripTerminalControlSequences(completedLine).trim();
|
||||
// A blank line ends the continuation. A PS2 echo (">" or "> ...") does not.
|
||||
if (isBashPs2ContinuationLine(plain)) return origin;
|
||||
return plain.slice(0, MAX_PROMPT_SECURITY_TAIL_CHARS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current logical line, plus the heredoc opener when the line is a bare ">".
|
||||
* `appendTerminalPromptSecurityTail` keeps only the current line, which is all
|
||||
* `observeTerminalInputPrompt` used to pass in. The opener is the last
|
||||
* completed non-PS2 line, so a long heredoc body does not have to be retained.
|
||||
*/
|
||||
export function foldTerminalPromptSecurityOutput(
|
||||
state: { origin: string; current: string; pendingCr?: boolean },
|
||||
chunk: string,
|
||||
): { origin: string; current: string; promptText: string; pendingCr: boolean } {
|
||||
// A PTY can split CRLF across chunks. The LF is the rest of that break,
|
||||
// not a second empty line that would drop the heredoc opener.
|
||||
let pendingCr = state.pendingCr === true;
|
||||
let input = chunk;
|
||||
if (pendingCr && input.startsWith('\n')) {
|
||||
input = input.slice(1);
|
||||
pendingCr = false;
|
||||
}
|
||||
const combined = `${state.current}${input}`;
|
||||
let origin = state.origin;
|
||||
for (const line of splitTerminalLogicalLines(combined).slice(0, -1)) {
|
||||
origin = nextHeredocPromptOrigin(origin, line);
|
||||
}
|
||||
const current = appendTerminalPromptSecurityTail(state.current, input);
|
||||
const promptText = lastLogicalLine(current).trim() === '>' && origin
|
||||
? `${origin}\n${current}`
|
||||
: current;
|
||||
if (input.endsWith('\r')) pendingCr = true;
|
||||
else if (input.length > 0) pendingCr = false;
|
||||
return { origin, current, promptText, pendingCr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a prompt-shaped authentication challenge. Vocabulary intentionally
|
||||
* matches the SSH keyboard-interactive boundary and also covers PIN/auth-code
|
||||
@@ -120,6 +159,8 @@ export function isConfirmedTerminalShellPrompt(
|
||||
if (code >= 0xE000 && code <= 0xF8FF) return true;
|
||||
}
|
||||
if (/[$#%]$/u.test(prompt)) return true;
|
||||
// POSIX sh/bash/ksh default PS2. Trusted only with heredoc context (#3796).
|
||||
if (prompt === '>') return isHeredocPs2Continuation(promptText, options);
|
||||
if (!prompt.endsWith('>')) return false;
|
||||
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
|
||||
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
|
||||
@@ -139,6 +180,64 @@ export function shouldUsePluginTerminalCompletionProvider(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function splitTerminalLogicalLines(value: string): string[] {
|
||||
const lines: string[] = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const ch = value[i];
|
||||
if (ch !== '\n' && ch !== '\r') continue;
|
||||
lines.push(value.slice(start, i));
|
||||
if (ch === '\r' && value[i + 1] === '\n') i += 1;
|
||||
start = i + 1;
|
||||
}
|
||||
lines.push(value.slice(start));
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Bash default PS2 echo: ">" or "> ...". Not "heredoc>" / "Custom>". */
|
||||
function isBashPs2ContinuationLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return trimmed === '>' || trimmed.startsWith('> ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Command text after the first confirmed shell prompt on this line.
|
||||
* Returns null when the line is not a confirmed prompt plus typed input.
|
||||
*/
|
||||
function commandAfterConfirmedShellPrompt(
|
||||
line: string,
|
||||
options: ConfirmedPromptOptions,
|
||||
): string | null {
|
||||
const text = line.trim();
|
||||
for (let i = 0; i < text.length - 1; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch !== '$' && ch !== '#' && ch !== '%' && ch !== '>') continue;
|
||||
const rest = text.slice(i + 1);
|
||||
if (!/^\s+\S/u.test(rest)) continue;
|
||||
const candidate = text.slice(0, i + 1);
|
||||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||||
if ((ch === '$' || ch === '#' || ch === '%') && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||||
continue;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare ">" is bash PS2 only while output is still inside a heredoc opened by
|
||||
* a confirmed shell command (#3796). Any other origin, including
|
||||
* `read -s -p '> '`, stays fail-closed.
|
||||
*/
|
||||
function isHeredocPs2Continuation(value: string, options: ConfirmedPromptOptions): boolean {
|
||||
const lines = splitTerminalLogicalLines(stripTerminalControlSequences(value));
|
||||
let index = lines.length - 2;
|
||||
while (index >= 0 && isBashPs2ContinuationLine(lines[index])) index -= 1;
|
||||
if (index < 0) return false;
|
||||
const command = commandAfterConfirmedShellPrompt(lines[index], options);
|
||||
return command != null && command.includes('<<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Body text before a `$` / `#` / `%` terminator that is plausible as a shell
|
||||
* PS1 (bare marker, user@host, path, shell-version, or lowercase identity).
|
||||
@@ -204,7 +303,8 @@ export function isUntrustedTerminalInputPrompt(
|
||||
// Mid-command punctuation after a real shell prompt must keep broadcasting
|
||||
// (#2709). Standalone `Label:` / `Custom>` challenges still fail closed.
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
// Pass the original text so a bare ">" can see heredoc lines above it.
|
||||
return !isConfirmedTerminalShellPrompt(value, options);
|
||||
}
|
||||
|
||||
export { MAX_PROMPT_SECURITY_TAIL_CHARS };
|
||||
Reference in new issue
Block a user