Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion 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 @@ -40,7 +40,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_URL=<The full Uniform Resource Locator (URL) without the port for the Tenable.SC host (no default, must be set if connecting to Tenable). For multiple allowed hosts, separate with newlines (not commas, since commas are valid URI characters), e.g. TENABLE_HOST_URL='https://a.example.com\nhttps://b.example.com'. Client-supplied host_url values not matching an entry in this list are rejected>
Comment thread
DMedina6 marked this conversation as resolved.
Outdated

# Authentication

Expand Down
31 changes: 26 additions & 5 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 All @@ -52,13 +52,34 @@
}
}

getTenableHostUrl(): string {
// Newline-separated allowlist of Tenable.SC hosts for the login/proxy endpoints
// (commas are valid, unencoded URI characters, so they can't be the delimiter).
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
// Entries must include a protocol; malformed entries throw at startup.
getTenableHostUrl(): string[] {
const tenable_host_url = this.get('TENABLE_HOST_URL');
if (tenable_host_url !== undefined) {
return tenable_host_url;
} else {
return '';
if (tenable_host_url === undefined) {
return [];
}
return tenable_host_url
.split(/\r?\n/)
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
.map((url) => url.trim())
.filter((url) => url.length > 0)
.map((url) => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(
`Invalid TENABLE_HOST_URL entry "${url}": must be a complete URL including protocol (e.g. https://example.com)`
);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(
`Invalid TENABLE_HOST_URL entry "${url}": protocol must be http or https`
);
}
return parsed.origin;
Comment thread
DMedina6 marked this conversation as resolved.
});
}

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

Check warning on line 115 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 116 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 +125,8 @@
sslCert = this.get('DATABASE_SSL_CERT');
} else {
// Verify file exists
if (fs.statSync(this.get('DATABASE_SSL_CERT')!).isFile()) {

Check warning on line 128 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 129 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 +138,8 @@
sslCA = this.get('DATABASE_SSL_CA');
} else {
// Verify file exists
if (fs.statSync(this.get('DATABASE_SSL_CA')!).isFile()) {

Check warning on line 141 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 142 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 +183,7 @@
return false;
} else {
const pattern =
/^(?:([^:\/?#\s]+):\/{2})?(?:([^@\/?#\s]+)@)?([^\/?#\s]+)?(?:\/([^?#\s]*))?(?:[?]([^#\s]+))?\S*$/;

Check warning on line 186 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
2 changes: 1 addition & 1 deletion apps/backend/src/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class ConfigService {
return this.appConfig.getSplunkHostUrl();
}

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

Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/config/dto/startup-settings.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class StartupSettingsDto implements IStartupSettings {
readonly ldap: boolean;
readonly registrationEnabled: boolean;
readonly localLoginEnabled: boolean;
readonly tenableHostUrl: string;
readonly tenableHostUrl: string[];
readonly forceTenableFrontend: boolean;
readonly splunkHostUrl: string;

Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async function bootstrap() {
"'self'",
'https://api.github.com',
'https://sts.amazonaws.com',
configService.getTenableHostUrl(),
...configService.getTenableHostUrl(),
configService.getSplunkHostUrl()
].filter((source) => source)
}
Expand Down
63 changes: 57 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,43 @@
// 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
) {}

// Resolves host_url to its allowlisted origin, or null if not allowed
private resolveAllowedHostUrl(host_url: string): string | null {
const allowlist = this.configService.getTenableHostUrl();
if (allowlist.length === 0) {
return null;
}
try {
const parsed = new URL(host_url); // requires a protocol; throws otherwise

if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
return null;
}

// Reject extra path/query/fragment
const hasExtra =
(parsed.pathname !== '' && parsed.pathname !== '/') ||
parsed.search !== '' ||
parsed.hash !== '';
if (hasExtra) {
return null;
}
Comment thread
DMedina6 marked this conversation as resolved.
Outdated

const match = allowlist.includes(parsed.origin);
Comment thread
DMedina6 marked this conversation as resolved.
Outdated

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.

nominally this will fail if they are using a nonstandard port for the protocol which is allowed, i.e. we add 443 as a port by default for https but they could use basically whatever else and it would be valid but would crash here cause "For URLs using the ftp:, http:, https:, ws:, and wss: schemes, the protocol followed by //, followed by the host. Same as host, the port is only included if it's not the default for the protocol." - https://developer.mozilla.org/en-US/docs/Web/API/URL/origin

i'm pretty sure the envvar says to not include a port on it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ports / non-default ports can now be specified for both the environment variable / app-config, and the frontend / client side input so it's consistent and flexible

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.


// Only the parsed origin (never the raw input) is used downstream
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
return match ? parsed.origin : null;
} catch {
return null;
}
}

@Post('login')
/**
Expand All @@ -42,7 +80,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 83 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 +90,30 @@
throw new HttpException('Missing credentials', HttpStatus.BAD_REQUEST);
}

const allowedHostUrl = this.resolveAllowedHostUrl(host_url);
if (!allowedHostUrl) {
// 400, not 403: the frontend treats any 403 here as a credentials error.
throw new HttpException(
Comment thread
Amndeep7 marked this conversation as resolved.
{
status: HttpStatus.BAD_REQUEST,
message: 'Tenable host URL is not in the configured allowlist',
code: 'HOST_NOT_ALLOWED'
},
HttpStatus.BAD_REQUEST
);
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
FileList
}
})
export default class AuthStep extends Vue {

Check warning on line 74 in apps/frontend/src/components/global/upload_tabs/tenable/AuthStep.vue

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this deprecated Vue class-based component pattern with the Composition API.

See more on https://sonarcloud.io/project/issues?id=mitre_heimdall2&issues=AaABogVRZFi7lbMSHnc5&open=AaABogVRZFi7lbMSHnc5&pullRequest=8510
accesskey = '';
secretkey = '';
hostname = '';
Expand Down Expand Up @@ -138,9 +138,9 @@
mounted() {
this.accesskey = localAccesskey.getDefault('');
this.secretkey = localSecretkey.getDefault('');
// If the hostname is not set, use the default from the server module
// If the hostname is not set, use the first configured default from the server module
// (if not running in server mode the default is empty)
this.hostname = localHostname.getDefault(ServerModule.tenableHostUrl);
this.hostname = localHostname.getDefault(ServerModule.tenableHostUrl[0] ?? '');
}
}
</script>
4 changes: 2 additions & 2 deletions apps/frontend/src/store/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface IServerState {
ldap: boolean;
localLoginEnabled: boolean;
userInfo: IUser;
tenableHostUrl: string;
tenableHostUrl: string[];
forceTenableFrontend: boolean;
splunkHostUrl: string;
}
Expand Down Expand Up @@ -68,7 +68,7 @@ class Server extends VuexModule implements IServerState {
externalUrl: string = '';
allUsers: ISlimUser[] = [];
oidcName = '';
tenableHostUrl: string = '';
tenableHostUrl: string[] = [];
forceTenableFrontend = false; // If true, the frontend will use Tenable.SC Lite features
splunkHostUrl: string = '';
/** Our currently granted JWT token */
Expand Down
22 changes: 12 additions & 10 deletions apps/frontend/src/utilities/tenable_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export class TenableUtil {
const DEFAULT_REJECT_MSG =
`${error.name}: ${(error.response?.data?.message ?? error.message)},
${error.response?.data?.code ?? error.code}`;
const TENABLE_HOST_URL = ServerModule.tenableHostUrl;
const TENABLE_HOST_URLS = ServerModule.tenableHostUrl;
Comment thread
DMedina6 marked this conversation as resolved.
Outdated
const TENABLE_CSP_NOT_SET =
'The Content Security Policy directive environment variable "TENABLE_HOST_URL" is not configured. See Help for additional instructions.';

Expand All @@ -159,8 +159,10 @@ export class TenableUtil {
if (this.isServer) {
if (error.response?.data?.code === 'INVALID_HOST_URL') { // Custom set in the backend
rejectMsg = (error.response?.data?.message ?? 'Tenable host URL to IP address resolution failed');
} else if (error.response?.data?.code === 'HOST_NOT_ALLOWED') { // Custom set in the backend
rejectMsg = (error.response?.data?.message ?? 'Tenable host URL is not in the configured allowlist');
} else if (error.response?.data?.message) {
rejectMsg = this.getCSPErrorMsg(this.hostConfig.host_url, TENABLE_HOST_URL)
rejectMsg = this.getCSPErrorMsg(this.hostConfig.host_url, TENABLE_HOST_URLS)
} else {
rejectMsg = DEFAULT_REJECT_MSG
}
Expand All @@ -173,7 +175,7 @@ export class TenableUtil {
'Unauthorized (missing or not accepted credentials): ' +
(error.response?.data?.message ?? error.message);
} else if (error.status == 404) {
if (this.isServer && !TENABLE_HOST_URL) {
if (this.isServer && !TENABLE_HOST_URLS.length) {
rejectMsg = TENABLE_CSP_NOT_SET;
} else {
rejectMsg = `Network Error -> ${DEFAULT_REJECT_MSG}`;
Expand Down Expand Up @@ -208,12 +210,12 @@ export class TenableUtil {
if (error.code == 'ERR_NETWORK') {
// Check if the tenable url was provided - Content Security Policy (CSP)
const corsReject = `Possible access blocked by CORS or connection refused by the host: ${error.config.baseURL}. See Help for additional instructions. Received Error: ${error.message}`;
if (TENABLE_HOST_URL) {
// If the URL is listed in the allows domains
if (TENABLE_HOST_URLS.length) {
// If the URL is listed in the allowed domains
// (.env variable TENABLE_HOST_URL) check if they match
if (!error.config.baseURL.includes(TENABLE_HOST_URL)) {
if (!TENABLE_HOST_URLS.some((url) => error.config.baseURL.includes(url))) {
if (error.config.baseURL) {
rejectMsg = this.getCSPErrorMsg(error.config.baseURL, TENABLE_HOST_URL)
rejectMsg = this.getCSPErrorMsg(error.config.baseURL, TENABLE_HOST_URLS)
} else {
// we assume that the connection was rejected, most likely is that the network path does not exist
rejectMsg = 'Connection refused by host, or broken network path'
Expand Down Expand Up @@ -375,11 +377,11 @@ export class TenableUtil {
* Generates an error message indicating a Content Security Policy (CSP) violation.
*
* @param baseURL - The hostname that triggered the CSP violation.
* @param tenableUrl - The hostname allowed by the CSP.
* @param tenableUrls - The hostnames allowed by the CSP.
* @returns A string describing the CSP violation, including the offending and allowed hostnames.
*/
getCSPErrorMsg(baseURL: string, tenableUrl: string): string {
return `Hostname: ${baseURL?.trim() || 'Unknown host'} violates the Content Security Policy (CSP). The host allowed by the CSP is: ${tenableUrl?.trim() || 'Host not set'}`;
getCSPErrorMsg(baseURL: string, tenableUrls: string[]): string {
return `Hostname: ${baseURL?.trim() || 'Unknown host'} violates the Content Security Policy (CSP). The host(s) allowed by the CSP: ${tenableUrls.length ? tenableUrls.join(', ') : 'Host not set'}`;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface IStartupSettings {
readonly ldap: boolean;
readonly registrationEnabled: boolean;
readonly localLoginEnabled: boolean;
readonly tenableHostUrl: string;
readonly tenableHostUrl: string[];
readonly forceTenableFrontend: boolean;
readonly splunkHostUrl: string;
}
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