Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
53cfe2d
Fix SSRF in Tenable integration by requiring auth and validating host…
DMedina6 Aug 7, 2026
f2d6a86
Propagate only sanitized value
DMedina6 Aug 10, 2026
6dd3ada
Tenable host_url allowlist validation
DMedina6 Aug 10, 2026
a1322ae
Import ConfigModule, backwards compatibility with TENABLE_HOST_URL va…
DMedina6 Aug 10, 2026
e68e3e5
consolidate tenable host URL env vars to single variable, allow both …
DMedina6 Aug 14, 2026
afcda97
Validate and trim at config load; require explicit protocol
DMedina6 Aug 14, 2026
c452f75
Clean up
DMedina6 Aug 14, 2026
6c40c83
Use error code 400 with custom set code for unallowed host URL instea…
DMedina6 Aug 14, 2026
13ca3c3
Merge branch 'master' of https://github.com/mitre/heimdall2 into tena…
DMedina6 Aug 14, 2026
1569459
Address sonarqube finding: extract error mappings to private method
DMedina6 Aug 17, 2026
3d3c39b
Merge branch 'master' into tenable-url-security
DMedina6 Aug 17, 2026
7d4eebf
Review comment adjustments
DMedina6 Aug 18, 2026
c685679
Merge branch 'tenable-url-security' of https://github.com/mitre/heimd…
DMedina6 Aug 18, 2026
0e0ab24
Merge branch 'master' of https://github.com/mitre/heimdall2 into tena…
DMedina6 Aug 18, 2026
694ab1d
Prevent tenable session credentials from being reused by another Heim…
DMedina6 Aug 18, 2026
71bbddd
Do not persist Tenable credentials in local storage
DMedina6 Aug 18, 2026
d0fbc3b
Move URL validation to new shared utility function
DMedina6 Aug 18, 2026
77c178f
Merge branch 'master' into tenable-url-security
Amndeep7 Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/.env-example

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll also need to update the envvar wiki page

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll also probably need to update the heimdall helm chart

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll also probably need to update the tenable integration part of the integrations wiki page

Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ NGINX_HOST=<Templated out as the 'server_name' for the NGINX configuration (no d
## External interfaces
SPLUNK_HOST_URL=<The full Uniform Resource Locator (URL) without the port for the Splunk host (no default, must be set if connecting to Splunk)>
TENABLE_HOST_URL=<The full Uniform Resource Locator (URL) without the port for the Tenable.SC host (no default, must be set if connecting to Tenable)>
TENABLE_HOST_URLS=<Comma-separated allowlist of full Tenable.SC host URLs permitted for login (e.g. https://a.example.com,https://b.example.com). Client-supplied host_url values not in this list are rejected>
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
Comment thread
DMedina6 marked this conversation as resolved.
Outdated

# Authentication

Expand Down
17 changes: 17 additions & 0 deletions apps/backend/config/app_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@
}

set(key: string, value: string | undefined): void {
this.envConfig[key] = value;

Check warning on line 30 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Generic Object Injection Sink
}

get(key: string): string | undefined {
return process.env[key] || this.envConfig[key];

Check warning on line 34 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Generic Object Injection Sink

Check warning on line 34 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Generic Object Injection Sink
}

getExternalUrl(): string {
Expand Down Expand Up @@ -61,6 +61,23 @@
}
}

// Comma-separated allowlist of Tenable.SC hosts permitted for the login/proxy endpoints.
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
// Falls back to (and merges with) the single-value TENABLE_HOST_URL for backward compatibility.
getTenableHostUrls(): string[] {
const tenable_host_urls = this.get('TENABLE_HOST_URLS');
const list = tenable_host_urls
? tenable_host_urls
.split(',')
.map((url) => url.trim())
.filter((url) => url.length > 0)
: [];
const singleHostUrl = this.getTenableHostUrl();
if (singleHostUrl && !list.includes(singleHostUrl)) {
list.push(singleHostUrl);
}
return list;
}

getDatabaseName(): string {
const databaseName = this.get('DATABASE_NAME');
const nodeEnvironment = this.get('NODE_ENV');
Expand Down Expand Up @@ -91,8 +108,8 @@
sslKey = this.get('DATABASE_SSL_KEY');
} else {
// Verify file exists
if (fs.statSync(this.get('DATABASE_SSL_KEY')!).isFile()) {

Check warning on line 111 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Found statSync from package "fs" with non literal argument at index 0
sslKey = fs.readFileSync(this.get('DATABASE_SSL_KEY')!);

Check warning on line 112 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Found readFileSync from package "fs" with non literal argument at index 0
} else {
throw new Error('SSL Key file does not exist');
}
Expand All @@ -104,8 +121,8 @@
sslCert = this.get('DATABASE_SSL_CERT');
} else {
// Verify file exists
if (fs.statSync(this.get('DATABASE_SSL_CERT')!).isFile()) {

Check warning on line 124 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Found statSync from package "fs" with non literal argument at index 0
sslCert = fs.readFileSync(this.get('DATABASE_SSL_CERT')!);

Check warning on line 125 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Found readFileSync from package "fs" with non literal argument at index 0
} else {
throw new Error('SSL Cert file does not exist');
}
Expand All @@ -117,8 +134,8 @@
sslCA = this.get('DATABASE_SSL_CA');
} else {
// Verify file exists
if (fs.statSync(this.get('DATABASE_SSL_CA')!).isFile()) {

Check warning on line 137 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Found statSync from package "fs" with non literal argument at index 0
sslCA = fs.readFileSync(this.get('DATABASE_SSL_CA')!);

Check warning on line 138 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Found readFileSync from package "fs" with non literal argument at index 0
} else {
throw new Error('SSL CA file does not exist');
}
Expand Down Expand Up @@ -162,7 +179,7 @@
return false;
} else {
const pattern =
/^(?:([^:\/?#\s]+):\/{2})?(?:([^@\/?#\s]+)@)?([^\/?#\s]+)?(?:\/([^?#\s]*))?(?:[?]([^#\s]+))?\S*$/;

Check warning on line 182 in apps/backend/config/app_config.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe Regular Expression
const matches = url.match(pattern);

if (matches === null) {
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export class ConfigService {
return this.appConfig.getTenableHostUrl();
}

getTenableHostUrls(): string[] {
return this.appConfig.getTenableHostUrls();
}

getDbConfig(): SequelizeOptions {
return this.appConfig.getDbConfig();
}
Expand Down
85 changes: 79 additions & 6 deletions apps/backend/src/tenable/tenable.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
Body,
HttpException,
HttpStatus,
All
All,
UseGuards
} from '@nestjs/common';
import {TenableService} from './tenable.service';
import {JwtAuthGuard} from '../guards/jwt-auth.guard';
import {ConfigService} from '../config/config.service';
import axios from 'axios';
import {Request, Response} from 'express';

Expand All @@ -30,8 +33,66 @@
// It allows users to log in with their Tenable credentials and then proxies all subsequent requests
// to the Tenable API, handling authentication via session storage.
@Controller('api/tenable')
@UseGuards(JwtAuthGuard)
export class TenableController {
constructor(private readonly tenableService: TenableService) {}
constructor(
private readonly tenableService: TenableService,
private readonly configService: ConfigService
) {}

// Returns the matching allowlisted origin for host_url, or null if none match
// or if host_url carries a path/query/fragment beyond a bare origin.
private resolveAllowedHostUrl(host_url: string): string | null {
const allowlist = this.configService.getTenableHostUrls();
if (allowlist.length === 0) {
return null;
}
// Default to https when no scheme is provided, so "example.com" is treated
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
// the same as "https://example.com".
const trimmedInput = host_url.trim();
const normalizedInput = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmedInput)
? trimmedInput
: `https://${trimmedInput}`;
try {
// Throws for unparseable input, caught below and treated as not allowed.
const parsed = new URL(normalizedInput);

// Reject any scheme other than http/https (e.g. file:, javascript:, ftp:).
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
return null;
}

// Reject anything beyond a bare origin rather than silently stripping it,
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
// so a tampered/malformed host_url fails loudly instead of being sanitized.
const hasExtra =
(parsed.pathname !== '' && parsed.pathname !== '/') ||
parsed.search !== '' ||
parsed.hash !== '';
if (hasExtra) {
return null;
}
Comment thread
DMedina6 marked this conversation as resolved.
Outdated

// Compare origins (scheme + host + port) rather than raw strings so that
// equivalent URLs (default ports, trailing slash, casing) still match.
const match = allowlist.find((allowed) => {

Check warning on line 77 in apps/backend/src/tenable/tenable.controller.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `.some(…)` over `.find(…)`.

See more on https://sonarcloud.io/project/issues?id=mitre_heimdall2&issues=AZ_s4Uc337GjHCTKfYX1&open=AZ_s4Uc337GjHCTKfYX1&pullRequest=8510
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
const trimmedAllowed = allowed.trim();
const normalizedAllowed = /^[a-z][a-z\d+.-]*:\/\//i.test(trimmedAllowed)
? trimmedAllowed
: `https://${trimmedAllowed}`;
try {
return new URL(normalizedAllowed).origin === parsed.origin;
} catch {
return false;
}
});

// Return the parsed origin (never the raw client value) so nothing beyond
// scheme+host+port ever propagates into the session or outbound requests.
return match ? parsed.origin : null;
} catch {
return null;
}
}

@Post('login')
/**
Expand All @@ -42,7 +103,7 @@
* @returns An object indicating success and the authenticated user's data from Tenable.
* @throws {HttpException} If any credentials are missing or if authentication fails.
*/
async login(

Check failure on line 106 in apps/backend/src/tenable/tenable.controller.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=mitre_heimdall2&issues=AZ_sz9sRrU821wARvZrE&open=AZ_sz9sRrU821wARvZrE&pullRequest=8510
@Req() req: Request,
@Body() body: {host_url: string; accesskey: string; secretkey: string}
) {
Expand All @@ -52,17 +113,29 @@
throw new HttpException('Missing credentials', HttpStatus.BAD_REQUEST);
}

const allowedHostUrl = this.resolveAllowedHostUrl(host_url);
if (!allowedHostUrl) {
throw new HttpException(
Comment thread
Amndeep7 marked this conversation as resolved.
{
status: HttpStatus.FORBIDDEN,
message: 'Tenable host URL is not in the configured allowlist',
code: 'HOST_NOT_ALLOWED'
},
HttpStatus.FORBIDDEN
);
}

try {
// This helps prevent double slashes in the resulting URL if host_url ends with a slash.
const fullUrl = `${host_url.replace(/\/$/, '')}/rest/currentUser`;
// allowedHostUrl is the parsed origin, so no trailing slash to strip.
const fullUrl = `${allowedHostUrl}/rest/currentUser`;
const result = await axios.get(fullUrl, {
headers: {
'x-apikey': `accesskey=${accesskey}; secretkey=${secretkey}`
}
});

// Assign the Tenable credentials to the session
req.session.tenable = {host_url, accesskey, secretkey};
// Store the normalized, allowlisted origin rather than the raw client value.
req.session.tenable = {host_url: allowedHostUrl, accesskey, secretkey};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review comment that seems like it could be feasible so please review it:

JwtAuthGuard proves only that the caller has a Heimdall account; these credentials remain associated solely with the browser’s Express session. The Tenable “sign out” UI only clears client state, and /users/logout revokes the JWT without clearing session.tenable. On a shared/reused browser session, a subsequently authenticated user can call the proxy with the prior user’s Tenable keys. Store the authenticated Heimdall user ID with the credentials, verify it on every proxy request, and clear the credentials on both Tenable and Heimdall logout. Please add an A-login → logout → B-login → proxy-is-denied regression test.

You don't need to write an explicit test unless you want to, but please do validate by hand that logging in with a second account does not allow one to reuse the access/secret key.


// Return the authenticated user data
// Note: result.data is already a plain object, no need to convert it.
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/tenable/tenable.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {Module} from '@nestjs/common';
import {TenableController} from './tenable.controller';
import {TenableService} from './tenable.service';
import {ConfigModule} from '../config/config.module';

Comment thread
DMedina6 marked this conversation as resolved.
// NestJS module definition for the Tenable proxy feature.
// Registers the controller and service needed for routing Tenable requests.

@Module({
imports: [ConfigModule],
// Handles HTTP requests related to Tenable
controllers: [TenableController],
// Provides logic for proxying and interacting with Tenable API
Expand Down
Empty file added problem-context.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete this

Empty file.
Loading