Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions lib/actions/azure/azure_storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,15 @@ class AzureStorageAction extends Hub.Action {
});
}
azureClientFromRequest(request) {
const account = request.params.account;
// The account name is interpolated into the Blob storage host, so it is
// restricted to the Azure storage account charset to prevent host injection.
if (!account || !/^[a-z0-9]{3,24}$/.test(account)) {
throw "Invalid Azure storage account name.";
}
try {
const sharedKeyCredential = new storage_blob_1.StorageSharedKeyCredential(request.params.account, request.params.accessKey);
return new storage_blob_1.BlobServiceClient(`https://${request.params.account}.blob.core.windows.net`, sharedKeyCredential);
const sharedKeyCredential = new storage_blob_1.StorageSharedKeyCredential(account, request.params.accessKey);
return new storage_blob_1.BlobServiceClient(`https://${account}.blob.core.windows.net`, sharedKeyCredential);
}
catch (err) {
if (err && err.toString().includes("base64")) {
Expand Down
11 changes: 8 additions & 3 deletions lib/actions/braze/braze.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ var BrazeConfig;
BrazeConfig["BRAZE_ATTRIBUTE_REGEX"] = "(?<=braze\\[)(.*)(?=\\])";
BrazeConfig["EXPORT_DEFAULT_VALUE"] = "LOOKER_EXPORT";
BrazeConfig[BrazeConfig["MAX_EXPORT"] = 100000] = "MAX_EXPORT";
BrazeConfig["DEFAULT_DOMAIN_REGEX"] = "^https?://(.*)\\.braze\\.(com|eu)$";
})(BrazeConfig || (BrazeConfig = {}));
// Braze endpoints are always hosted under braze.com / braze.eu. The endpoint is
// validated against the parsed URL hostname rather than a substring match so
// that an address such as https://169.254.169.254/x.braze.com (whose real host
// is internal) cannot pass the check.
const BRAZE_ALLOWED_DOMAINS = ["braze.com", "braze.eu"];
function isEmpty(obj) {
return !obj || Object.keys(obj).length === 0;
}
Expand Down Expand Up @@ -73,8 +77,9 @@ class BrazeAction extends Hub.Action {
if (!endpoint.startsWith("http")) {
throw "Missing Protocol for endpoint.";
}
const bzDomainRegex = new RegExp(BrazeConfig.DEFAULT_DOMAIN_REGEX, "gi");
if (!(request.params.braze_api_endpoint.toLowerCase().match(bzDomainRegex))) {
const brazeHostname = new url_1.URL(endpoint).hostname.toLowerCase();
const isAllowedBrazeHost = BRAZE_ALLOWED_DOMAINS.some((domain) => brazeHostname === domain || brazeHostname.endsWith(`.${domain}`));
if (!isAllowedBrazeHost) {
throw "Bad Endpoint.";
}
if (!request.params.braze_api_key) {
Expand Down
10 changes: 8 additions & 2 deletions lib/actions/digitalocean/digitalocean_object_storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ class DigitalOceanObjectStorageAction extends amazon_s3_1.AmazonS3Action {
return form;
}
amazonS3ClientFromRequest(request) {
const region = request.params.region;
// The region is interpolated into the Spaces endpoint host, so it is
// restricted to the DigitalOcean region charset to prevent host injection.
if (!region || !/^[A-Za-z0-9-]+$/.test(region)) {
throw "Invalid DigitalOcean region.";
}
return new S3({
region: request.params.region,
endpoint: `https://${request.params.region}.digitaloceanspaces.com`,
region,
endpoint: `https://${region}.digitaloceanspaces.com`,
accessKeyId: request.params.access_key_id,
secretAccessKey: request.params.secret_access_key,
});
Expand Down
7 changes: 4 additions & 3 deletions lib/actions/jira/jira.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class JiraAction extends Hub.Action {
if (!request.attachment || !request.attachment.dataBuffer) {
throw "Couldn't get data from attachment";
}
const jira = this.jiraClientFromRequest(request);
const jira = await this.jiraClientFromRequest(request);
const issue = {
fields: {
project: {
Expand All @@ -66,7 +66,7 @@ class JiraAction extends Hub.Action {
async form(request) {
const form = new Hub.ActionForm();
try {
const jira = this.jiraClientFromRequest(request);
const jira = await this.jiraClientFromRequest(request);
const [projects, issueTypes] = await Promise.all([
jira.listProjects(),
jira.listIssueTypes(),
Expand Down Expand Up @@ -108,11 +108,12 @@ class JiraAction extends Hub.Action {
}
return form;
}
jiraClientFromRequest(request) {
async jiraClientFromRequest(request) {
const parsedUrl = new url_1.URL(request.params.address);
if (!parsedUrl.host) {
throw "Invalid JIRA server address.";
}
await Hub.assertPublicUrl(request.params.address);
return new jiraApi({
protocol: parsedUrl.protocol ? parsedUrl.protocol : "https",
host: parsedUrl.host,
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/marketo/marketo_transaction.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export declare class MarketoTransaction {
lookupField?: string;
handleRequest(request: Hub.ActionRequest): Promise<Hub.ActionResponse>;
processChunk(chunk: any[]): Promise<Result>;
marketoClientFromRequest(request: Hub.ActionRequest): any;
marketoClientFromRequest(request: Hub.ActionRequest): Promise<any>;
private getFieldMap;
private getLeadList;
private hasErrors;
Expand Down
5 changes: 3 additions & 2 deletions lib/actions/marketo/marketo_transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class MarketoTransaction {
if (!this.lookupField) {
throw "Missing Lookup Field.";
}
this.marketo = this.marketoClientFromRequest(request);
this.marketo = await this.marketoClientFromRequest(request);
const queue = new queue_1.Queue();
let rows = [];
const sendChunk = () => {
Expand Down Expand Up @@ -154,7 +154,8 @@ class MarketoTransaction {
}
return result;
}
marketoClientFromRequest(request) {
async marketoClientFromRequest(request) {
await Hub.assertPublicUrl(request.params.url);
return new MARKETO({
endpoint: `${request.params.url}/rest`,
identity: `${request.params.url}/identity`,
Expand Down
2 changes: 2 additions & 0 deletions lib/actions/salesforce/common/oauth_helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ exports.SalesforceOauthHelper = SalesforceOauthHelper;
/******** function to create jsforce connection used in formBuilder and sendData ********/
// login with oauth2 flow
const sfdcConnFromRequest = async (request, tokens, oauthCreds) => {
await Hub.assertPublicUrl(request.params.salesforce_domain);
const oauth2 = new jsforce.OAuth2({
clientId: oauthCreds.oauthClientId,
clientSecret: oauthCreds.oauthClientSecret,
Expand All @@ -149,6 +150,7 @@ const sfdcConnFromRequest = async (request, tokens, oauthCreds) => {
exports.sfdcConnFromRequest = sfdcConnFromRequest;
// login with username, password + security_token (deprecated - action is using oauth)
const salesforceLogin = async (request) => {
await Hub.assertPublicUrl(request.params.salesforce_domain);
const sfdcConn = new jsforce.Connection({
loginUrl: request.params.salesforce_domain,
});
Expand Down
148 changes: 93 additions & 55 deletions lib/hub/action_request.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Object.defineProperty(exports, "ActionDownloadSettings", { enumerable: true, get
Object.defineProperty(exports, "ActionFormat", { enumerable: true, get: function () { return integration_1.IntegrationSupportedFormats; } });
Object.defineProperty(exports, "ActionFormatting", { enumerable: true, get: function () { return integration_1.IntegrationSupportedFormattings; } });
Object.defineProperty(exports, "ActionVisualizationFormatting", { enumerable: true, get: function () { return integration_1.IntegrationSupportedVisualizationFormattings; } });
const ssrf_filter_1 = require("./ssrf_filter");
const MAX_STREAM_REDIRECTS = 10;
class ActionRequest {
constructor() {
this.formParams = {};
Expand Down Expand Up @@ -134,65 +136,101 @@ class ActionRequest {
// Here we can cork the stream and uncork if we receive a 200 OK response
// If we do not receive a 200 we do not want to allow data to pipe and ignore a non-200 response
stream.cork();
httpRequest
.get(url, { timeout })
.on("error", (err) => {
if (hasResolved && err.code === "ECONNRESET") {
winston.info(`[stream] ignoring ECONNRESET that occured after streaming finished`, this.logInfo);
// Redirects are followed manually so that every hop is validated by
// `ssrfSafeLookup`. The `request` library does not re-apply the `lookup`
// option to redirect targets, so a download url that 3xx-redirects to an
// internal host would otherwise bypass the guard on the initial url.
const performGet = (requestUrl, redirectsRemaining) => {
try {
// Literal addresses never trigger the `lookup` below, so validate
// the protocol and any address literal up front for every hop.
(0, ssrf_filter_1.assertAllowedRequestUrl)(requestUrl);
}
else {
winston.error(`[stream] request stream error`, {
...this.logInfo,
error: err.message,
stack: err.stack,
});
reject(err);
catch (guardErr) {
reject(guardErr);
return;
}
})
.on("response", (response) => {
if (response.statusCode === 200) {
// Stop buffering in memory and allow action to send data
stream.uncork();
}
else {
winston.warn(`[stream] There was an error received from Looker.` +
`ErrorCode: ${response.statusCode} ErrorMessage: ${response.statusMessage}`, this.logInfo);
if (!hasResolved) {
reject(`There was an error with Action Hub calling back to Looker, status code: ${response.statusCode}`);
const requestOptions = {
timeout,
followRedirect: false,
};
// `lookup` is honoured by the underlying http agent but is not present
// in the request library's type definitions.
const requestOptionsWithLookup = requestOptions;
requestOptionsWithLookup.lookup = ssrf_filter_1.ssrfSafeLookup;
const downloadRequest = httpRequest.get(requestUrl, requestOptions);
downloadRequest
.on("error", (err) => {
if (hasResolved && err.code === "ECONNRESET") {
winston.info(`[stream] ignoring ECONNRESET that occured after streaming finished`, this.logInfo);
}
else {
winston.error(`[stream] request stream error`, {
...this.logInfo,
error: err.message,
stack: err.stack,
});
reject(err);
}
})
.on("socket", (socket) => {
winston.info(`[stream] setting keepalive on socket`, this.logInfo);
socket.setKeepAlive(true);
})
.on("response", (response) => {
const statusCode = response.statusCode;
const location = response.headers.location;
if (statusCode >= 300 && statusCode < 400 && location) {
downloadRequest.abort();
if (redirectsRemaining <= 0) {
winston.warn(`[stream] exceeded the maximum number of download url redirects`, this.logInfo);
reject("Exceeded the maximum number of redirects while streaming the download url.");
return;
}
let nextUrl;
try {
nextUrl = new URL(location, requestUrl).toString();
}
catch (e) {
reject("Received an invalid redirect location while streaming the download url.");
return;
}
winston.info(`[stream] following redirect from download url`, this.logInfo);
performGet(nextUrl, redirectsRemaining - 1);
return;
}
winston.info(`[stream] got response from download url`, this.logInfo);
if (statusCode === 200) {
// Stop buffering in memory and allow action to send data
stream.uncork();
downloadRequest
.pipe(stream)
.on("error", (err) => {
winston.error(`[stream] PassThrough stream error`, {
...this.logInfo,
});
reject(err);
})
.on("finish", () => {
winston.info(`[stream] PassThrough stream finished`, this.logInfo);
resolve();
hasResolved = true;
})
.on("close", () => {
winston.info(`[stream] PassThrough stream closed`, this.logInfo);
});
}
else {
winston.warn(`[stream] There was an error received from Looker.` +
`ErrorCode: ${statusCode} ErrorMessage: ${response.statusMessage}`, this.logInfo);
downloadRequest.abort();
if (!hasResolved) {
reject(`There was an error with Action Hub calling back to Looker, status code: ${statusCode}`);
}
}
}
})
.on("finish", () => {
winston.info(`[stream] streaming via download url finished`, this.logInfo);
})
.on("socket", (socket) => {
winston.info(`[stream] setting keepalive on socket`, this.logInfo);
socket.setKeepAlive(true);
})
.on("abort", () => {
winston.info(`[stream] streaming via download url aborted`, this.logInfo);
})
.on("response", () => {
winston.info(`[stream] got response from download url`, this.logInfo);
})
.on("close", () => {
winston.info(`[stream] request stream closed`, this.logInfo);
})
.pipe(stream)
.on("error", (err) => {
winston.error(`[stream] PassThrough stream error`, {
...this.logInfo,
});
reject(err);
})
.on("finish", () => {
winston.info(`[stream] PassThrough stream finished`, this.logInfo);
resolve();
hasResolved = true;
})
.on("close", () => {
winston.info(`[stream] PassThrough stream closed`, this.logInfo);
});
};
performGet(url, MAX_STREAM_REDIRECTS);
}
else {
if (this.attachment && this.attachment.dataBuffer) {
Expand Down
1 change: 1 addition & 0 deletions lib/hub/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from "./oauth_action_v2";
export * from "./delegate_oauth_action";
export * from "./token_payload";
export * from "./sources";
export * from "./ssrf_filter";
export * from "./utils";
import { LookmlModelExploreField as FieldBase } from "../api_types/lookml_model_explore_field";
import { LookmlModelExploreFieldset as ExploreFieldset } from "../api_types/lookml_model_explore_fieldset";
Expand Down
1 change: 1 addition & 0 deletions lib/hub/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ __exportStar(require("./oauth_action_v2"), exports);
__exportStar(require("./delegate_oauth_action"), exports);
__exportStar(require("./token_payload"), exports);
__exportStar(require("./sources"), exports);
__exportStar(require("./ssrf_filter"), exports);
__exportStar(require("./utils"), exports);
const aes_transit_crypto_1 = require("../crypto/aes_transit_crypto");
Object.defineProperty(exports, "ActionCrypto", { enumerable: true, get: function () { return aes_transit_crypto_1.AESTransitCrypto; } });
Expand Down
26 changes: 26 additions & 0 deletions lib/hub/ssrf_filter.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as dns from "dns";
export declare const ALLOWED_PROTOCOLS: string[];
/** Returns true if `ip` is a literal address in restricted / non-public space. */
export declare function isRestrictedAddress(ip: string): boolean;
type LookupCallback = (err: NodeJS.ErrnoException | null, address: string | dns.LookupAddress[], family?: number) => void;
/**
* A `dns.lookup` compatible function that errors if the host resolves to a
* restricted address. Pass it as the `lookup` option of an outbound request so
* the address the socket actually connects to is validated.
*/
export declare function ssrfSafeLookup(hostname: string, options: dns.LookupOneOptions | dns.LookupAllOptions | LookupCallback, callback?: LookupCallback): void;
/**
* Synchronously validates the protocol and, when the host is an address
* literal, that it is not restricted. This does not perform DNS resolution, so
* it is safe to call before every request (including each redirect hop) even
* when the connection itself is guarded by `ssrfSafeLookup`. A custom `lookup`
* is only invoked for hostnames, so literal addresses must be checked here.
*/
export declare function assertAllowedRequestUrl(rawUrl: string): void;
/**
* Validates that `rawUrl` uses an allowed protocol and does not resolve to a
* restricted address. Throws a descriptive error otherwise. Intended for
* callers that build a client library which cannot take a custom lookup.
*/
export declare function assertPublicUrl(rawUrl: string): Promise<void>;
export {};
Loading