Skip to content

fix(ssh): add opt-in SSRF guard for SSH bastion hosts - #62

Open
notSumit25 wants to merge 5 commits into
mainfrom
fix/ssh-tunnel-ssrf-guard
Open

fix(ssh): add opt-in SSRF guard for SSH bastion hosts#62
notSumit25 wants to merge 5 commits into
mainfrom
fix/ssh-tunnel-ssrf-guard

Conversation

@notSumit25

Copy link
Copy Markdown
Collaborator

SshTunnelService passed request.getSshHost() straight to jsch.getSession() with no validation, so an authenticated user who can create a connection could aim the tunnel at the cloud metadata endpoint (169.254.169.254) or internal hosts and use the backend as a probe inside the network. This is CodeQL alert #138 (java/ssrf), open since the initial commit — the file had never been modified, and the "fixed then reappeared" timeline on that alert was scan flapping, not a reverted fix.

SshHostGuard resolves the host and checks every returned address rather than matching the literal string, so a public hostname whose A record points at a private or link-local address is still refused. Covers loopback, wildcard, link-local, RFC1918, CGNAT, multicast, IPv6 ULA, and IPv4-mapped IPv6 forms. It sits in createSession, so establishTunnel and testSshConnection are both covered by one call site.

testSshConnection calls the guard outside its try block: that method catches broad Exception and returns false, which would render a blocked host as an ordinary auth failure.

The guard ships disabled. Bastions legitimately live on RFC1918 networks, so enabling it by default would break existing self-hosted installs on upgrade. Note this means a default install is as exposed as before while the CodeQL alert closes — the sanitizer is on the call path regardless of the flag — so a green #138 does not mean deployments are protected. Documented in CLAUDE.md.

SshTunnelService passed request.getSshHost() straight to jsch.getSession()
with no validation, so an authenticated user who can create a connection
could aim the tunnel at the cloud metadata endpoint (169.254.169.254) or
internal hosts and use the backend as a probe inside the network. This is
CodeQL alert #138 (java/ssrf), open since the initial commit — the file had
never been modified, and the "fixed then reappeared" timeline on that alert
was scan flapping, not a reverted fix.

SshHostGuard resolves the host and checks every returned address rather than
matching the literal string, so a public hostname whose A record points at a
private or link-local address is still refused. Covers loopback, wildcard,
link-local, RFC1918, CGNAT, multicast, IPv6 ULA, and IPv4-mapped IPv6 forms.
It sits in createSession, so establishTunnel and testSshConnection are both
covered by one call site.

testSshConnection calls the guard outside its try block: that method catches
broad Exception and returns false, which would render a blocked host as an
ordinary auth failure.

The guard ships disabled. Bastions legitimately live on RFC1918 networks, so
enabling it by default would break existing self-hosted installs on upgrade.
Note this means a default install is as exposed as before while the CodeQL
alert closes — the sanitizer is on the call path regardless of the flag — so
a green #138 does not mean deployments are protected. Documented in CLAUDE.md.
…on alerts

Addresses the remaining open code-scanning alerts on main. The 138 alerts
were only 5 rules; 118 were a single mechanical pattern.

polynomial-redos (118): almost all were s.matches(".*RE.*"). String.matches
already anchors, so the wrapping .* exists only to undo that anchoring, and
.* plus an alternation is what backtracks. Rewritten to
PatternUtil.containsPattern (find() over a cached Pattern) at 174 sites in
17 files -- more than the 118 flagged, since CodeQL only reports where taint
reaches, and the untainted ones are the same hazard. Equivalence was checked
by differential test over all 175 literals rather than by inspection.

This changes behavior on multi-line input: `.` does not cross a newline, so
the anchored form failed to match a keyword after a line break and find()
matches it. That is a fix for intent classifiers, and only affects callers
that do not pre-normalize.

The rest were compiled Pattern constants with ambiguous quantifiers, fixed
individually with possessive quantifiers or bounded gaps. PlanPatternLibrary
also had a latent bug: [^from]+ is a character class, so any column with f,
r, o or m in it (order_id) defeated the collapse.

sql-injection (15): three different cases. CardinalityEstimationService was
a real hole -- quoteIdentifier wrapped in quotes without doubling an embedded
quote, so a table named `x" ; DROP TABLE users; --` escaped it. Four of the
five other quoteIdentifier implementations here already escape correctly. It
now delegates to the dialect's SamplingProvider (removing an if/else on
dbType) and resolves both identifiers against information_schema first.
MySQLPrivilegeCheckProvider concatenated a database name into a literal; now
bound. QueryExecutorService and the EXPLAIN providers execute user SQL by
design -- that is the Editor, guarded by the policy layer, not parameterizable.

spring-disabled-csrf-protection: correct as-is; every route is STATELESS with
header-carried tokens, so there is no cookie session to forge. Documented in
place rather than changed.

command-line-injection: spawn already passed array args, but authorize_url
comes from a server response and the win32 branch goes through cmd. Now
scheme-validated to http/https.

Verified: full backend suite shows the same 13 failures / 4 errors as the
untouched baseline (confirmed by stashing) -- no new regressions. 25 new/
touched unit tests and 259 MCP tests pass.
…ed URL)

Closes the other two java/ssrf alerts. Address classification moves into a
shared OutboundHostGuard -- resolve the host and check every returned address,
so a public hostname whose A record points at 10.x or 169.254.169.254 is still
refused. SshHostGuard now delegates to it instead of carrying its own copy.

DatabaseHostGuard (#136, ConnectionService) screens the JDBC host. The SSH
guard never covered this: a direct, non-tunnelled connection does not go
through SshTunnelService at all. Applied in buildJdbcUrl and in the Hikari
pool path, and skipped when a tunnel port is present since that targets the
local forwarded port. Ships disabled, same reasoning as the SSH guard --
databases sit on RFC1918 more often than bastions do.

S3LogFetchService (#137) was calling setInstanceFollowRedirects(true), so the
JDK chased a 302 with no chance to inspect the target and a presigned URL on a
public host could hand off to the metadata endpoint. Redirects are now followed
manually with a cap of 5, and every hop is re-checked for https plus a public
address. This one is always on: there is no legitimate reason to fetch a slow
query log from a private address.

Verified: 47 unit tests pass across the new and touched guards; the six
pre-existing failing classes show the same 13 failures / 4 errors as the
untouched baseline, so no new regressions.
@notSumit25
notSumit25 force-pushed the fix/ssh-tunnel-ssrf-guard branch from 81dabb7 to c38471b Compare August 18, 2026 13:36
Comment thread backend/src/main/java/com/dbaagent/service/S3LogFetchService.java
Comment thread backend/src/main/java/com/dbaagent/util/PatternUtil.java Fixed
… checked parts

Follow-up to the CodeQL findings on PR #62's own new code -- two alerts the
first pass introduced rather than fixed.

polynomial-redos (PatternUtil): the find() rewrite removed the outer .* but
not the inner A.*B gap that ~47 of the classifier patterns carry. That gap
backtracks super-linearly when B is absent -- measured at 93s on a 50k-token
input, a real DoS reachable from a chat message. Rather than reshape ~90
patterns and risk changing what they match, containsPattern now caps the
scanned input to 4096 chars. The gap can only backtrack within that window
(worst pattern: 93s -> ~23ms). Real questions and identifiers are far shorter,
so matching is unchanged for every legitimate input.

ssrf (S3LogFetchService): CodeQL did not treat assertFetchableUrl as a barrier
because it returned the tainted URI unchanged. It now rebuilds the URI from
validated components with the scheme pinned to the https literal, so no
unvalidated part of the caller's string survives into openConnection().

Verified: worst pattern bounded under 1s in a test; assertFetchableUrl still
accepts real https S3 URLs and rejects http/file/private-host/redirect targets.
PatternUtil and S3LogFetchService suites green.
CharSequence scanned = input.length() > MAX_SCAN_CHARS
? input.subSequence(0, MAX_SCAN_CHARS)
: input;
return cached(regex).matcher(scanned).find();
…forgery'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
@venkateshsakamuri-lab

Copy link
Copy Markdown
Contributor

@notSumit25 fixing the code scan issues?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants