From 4efe17e8f112c7d3d3f1fea77b2a0ec45c4073dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 26 Sep 2019 17:17:05 +0100 Subject: [PATCH 1/3] feat(api): expose alertmanager headers in the API --- cmd/karma/alerts.go | 10 +++ internal/alertmanager/model_test.go | 4 +- internal/alertmanager/models.go | 2 +- internal/models/alertmanager.go | 11 +-- internal/uri/urls.go | 33 +++++++++ internal/uri/urls_test.go | 102 ++++++++++++++++++++++++---- 6 files changed, 141 insertions(+), 21 deletions(-) diff --git a/cmd/karma/alerts.go b/cmd/karma/alerts.go index 7e3f27482..7efec171f 100644 --- a/cmd/karma/alerts.go +++ b/cmd/karma/alerts.go @@ -13,6 +13,7 @@ import ( "github.com/prymitive/karma/internal/filters" "github.com/prymitive/karma/internal/models" "github.com/prymitive/karma/internal/slices" + "github.com/prymitive/karma/internal/uri" log "github.com/sirupsen/logrus" ) @@ -111,11 +112,20 @@ func getUpstreams() models.AlertmanagerAPISummary { Name: upstream.Name, URI: upstream.SanitizedURI(), PublicURI: upstream.PublicURI(), + Headers: map[string]string{}, Error: upstream.Error(), Version: upstream.Version(), Cluster: upstream.ClusterID(), ClusterMembers: members, } + if !upstream.ProxyRequests { + for k, v := range uri.HeadersForBasicAuth(u.PublicURI) { + u.Headers[k] = v + } + for k, v := range upstream.HTTPHeaders { + u.Headers[k] = v + } + } summary.Instances = append(summary.Instances, u) summary.Counters.Total++ diff --git a/internal/alertmanager/model_test.go b/internal/alertmanager/model_test.go index 3adfce608..89a6e879b 100644 --- a/internal/alertmanager/model_test.go +++ b/internal/alertmanager/model_test.go @@ -35,12 +35,12 @@ var uriTests = []uriTest{ { rawURI: "http://user:pass@alertmanager.example.com", proxy: false, - publicURI: "http://user:pass@alertmanager.example.com", + publicURI: "http://alertmanager.example.com", }, { rawURI: "https://user:pass@alertmanager.example.com/foo", proxy: false, - publicURI: "https://user:pass@alertmanager.example.com/foo", + publicURI: "https://alertmanager.example.com/foo", }, { rawURI: "http://user:pass@alertmanager.example.com", diff --git a/internal/alertmanager/models.go b/internal/alertmanager/models.go index 551dda3eb..9a4dcd6fb 100644 --- a/internal/alertmanager/models.go +++ b/internal/alertmanager/models.go @@ -211,7 +211,7 @@ func (am *Alertmanager) PublicURI() string { if am.ExternalURI != "" { return am.ExternalURI } - return am.URI + return uri.WithoutUserinfo(am.URI) } func (am *Alertmanager) pullAlerts(version string) error { diff --git a/internal/models/alertmanager.go b/internal/models/alertmanager.go index 930224716..2eb08980d 100644 --- a/internal/models/alertmanager.go +++ b/internal/models/alertmanager.go @@ -29,11 +29,12 @@ type AlertmanagerAPIStatus struct { URI string `json:"uri"` // this is URI client should use to talk to this Alertmanager, it might be // same as real or proxied URI - PublicURI string `json:"publicURI"` - Error string `json:"error"` - Version string `json:"version"` - Cluster string `json:"cluster"` - ClusterMembers []string `json:"clusterMembers"` + PublicURI string `json:"publicURI"` + Headers map[string]string `json:"headers"` + Error string `json:"error"` + Version string `json:"version"` + Cluster string `json:"cluster"` + ClusterMembers []string `json:"clusterMembers"` } // AlertmanagerAPICounters returns number of Alertmanager instances in each diff --git a/internal/uri/urls.go b/internal/uri/urls.go index 07c0e8035..13e77072d 100644 --- a/internal/uri/urls.go +++ b/internal/uri/urls.go @@ -1,6 +1,7 @@ package uri import ( + "encoding/base64" "net/url" "path" ) @@ -30,3 +31,35 @@ func SanitizeURI(s string) string { } return s } + +// HeadersForBasicAuth checks if the passed uri contains user & password +// (http://user:pass@example.com) and if so generates headers for Basic Auth +// based on +func HeadersForBasicAuth(s string) map[string]string { + headers := map[string]string{} + + u, err := url.Parse(s) + if err != nil { + return headers + } + + if u.User != nil { + if password, pwdSet := u.User.Password(); pwdSet { + auth := u.User.Username() + ":" + password + headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) + } + } + + return headers +} + +// WithoutUserinfo takes an URL and returns a copy of it with basic auth +// stripped +func WithoutUserinfo(s string) string { + u, err := url.Parse(s) + if err != nil { + return s + } + u.User = nil + return u.String() +} diff --git a/internal/uri/urls_test.go b/internal/uri/urls_test.go index d0e6d7337..998e2a735 100644 --- a/internal/uri/urls_test.go +++ b/internal/uri/urls_test.go @@ -7,35 +7,48 @@ import ( ) type joinURLTest struct { - base string - sub string - url string + base string + sub string + url string + isValid bool } var joinURLTests = []joinURLTest{ { - base: "http://localhost", - sub: "/sub", - url: "http://localhost/sub", + base: "http://localhost", + sub: "/sub", + url: "http://localhost/sub", + isValid: true, }, { - base: "http://localhost", - sub: "/sub/", - url: "http://localhost/sub", + base: "http://localhost", + sub: "/sub/", + url: "http://localhost/sub", + isValid: true, }, { - base: "http://am.example.com", - sub: "/api/v1/alerts", - url: "http://am.example.com/api/v1/alerts", + base: "http://am.example.com", + sub: "/api/v1/alerts", + url: "http://am.example.com/api/v1/alerts", + isValid: true, + }, + { + base: "%gh&%ij", + sub: "/a + b", + url: "", + isValid: false, }, } func TestJoinURL(t *testing.T) { for _, testCase := range joinURLTests { url, err := uri.JoinURL(testCase.base, testCase.sub) - if err != nil { + if err != nil && testCase.isValid { t.Errorf("joinURL(%v, %v) failed: %s", testCase.base, testCase.sub, err.Error()) } + if err == nil && !testCase.isValid { + t.Errorf("expected error for '%s' and '%s' but got '%s'", testCase.base, testCase.sub, url) + } if url != testCase.url { t.Errorf("Invalid joined url from '%s' + '%s', expected '%s', got '%s'", testCase.base, testCase.sub, testCase.url, url) } @@ -80,6 +93,10 @@ var sanitizeURITests = []sanitizeURITest{ raw: "https://user:pass@alertmanager.example.com/foo", sanitized: "https://user:xxx@alertmanager.example.com/foo", }, + { + raw: "%gh&%ij", + sanitized: "%gh&%ij", + }, } func TestSanitizedURI(t *testing.T) { @@ -91,3 +108,62 @@ func TestSanitizedURI(t *testing.T) { } } } + +func TestHeadersForBasicAuth(t *testing.T) { + type headersTest struct { + uri string + isSet bool + value string + } + testCases := []headersTest{ + { + uri: "http://localhost.com", + isSet: false, + }, + { + uri: "http://user@localhost.com", + isSet: false, + }, + { + uri: "http://user:pass@localhost.com", + isSet: true, + value: "Basic dXNlcjpwYXNz", + }, + { + uri: "%gh&%ij", + isSet: false, + }, + } + for _, test := range testCases { + headers := uri.HeadersForBasicAuth(test.uri) + value, isSet := headers["Authorization"] + if isSet != test.isSet { + t.Errorf("[%s] expected Authorization header: %v, was set: %v", test.uri, test.isSet, isSet) + } + if value != test.value { + t.Errorf("[%s] expected Authorization value: %s, value: %s", test.uri, test.value, value) + } + } +} + +func TestURIWithoutUserinfo(t *testing.T) { + type userinfoTest struct { + uri string + parsed string + } + testCases := []userinfoTest{ + {uri: "http://localhost", parsed: "http://localhost"}, + {uri: "http://localhost?foo=bar", parsed: "http://localhost?foo=bar"}, + {uri: "http://user@localhost", parsed: "http://localhost"}, + {uri: "http://user:pass@localhost", parsed: "http://localhost"}, + {uri: "http://user:pass@localhost?foo=bar#1", parsed: "http://localhost?foo=bar#1"}, + {uri: "%gh&%ij", parsed: "%gh&%ij"}, + } + + for _, test := range testCases { + parsed := uri.WithoutUserinfo(test.uri) + if parsed != test.parsed { + t.Errorf("'%s' got parsed as '%s', expected: '%s'", test.uri, parsed, test.parsed) + } + } +} From 80c30f1879bcd2be050f665709d55bec1e8758cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 26 Sep 2019 17:18:27 +0100 Subject: [PATCH 2/3] fix(ui): pass configured alertmanager headers when making requests from the browser Right now configured headers are only set on requests made from the backend to alertmanager API. With this change fetch() calls in the browser will use those headers if proxy mode is not enabled. --- ui/package.json | 1 + ui/src/Common/Fetch.js | 6 +++ ui/src/Common/Fetch.test.js | 40 +++++++++++++++++++ .../AlertGroup/Silence/DeleteSilence.js | 7 ++-- .../AlertGroup/Silence/DeleteSilence.test.js | 31 +++++++++++++- .../AlertGroup/Silence/index.test.js | 2 + .../MainModal/Configuration/SortLabelName.js | 8 ++-- ui/src/Components/NavBar/FilterInput/index.js | 5 ++- .../AlertManagerInput/index.test.js | 3 ++ .../SilenceMatch/LabelNameInput.js | 14 +++---- .../SilenceModal/SilenceMatch/MatchCounter.js | 3 +- .../SilenceModal/SilencePreview/index.js | 3 +- .../SilenceSubmit/SilenceSubmitProgress.js | 9 +++-- .../SilenceSubmitProgress.test.js | 6 ++- ui/src/Models/API.js | 1 + ui/src/Stores/AlertStore.js | 4 +- ui/src/__mocks__/Alerts.js | 3 ++ 17 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 ui/src/Common/Fetch.js create mode 100644 ui/src/Common/Fetch.test.js diff --git a/ui/package.json b/ui/package.json index a78b13e76..f64c53f1e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,7 @@ "favico.js": "0.3.10", "fontfaceobserver": "2.1.0", "lodash.debounce": "4.0.8", + "lodash.merge": "4.6.2", "lodash.throttle": "4.1.1", "lodash.uniqueid": "4.0.1", "mobx": "5.13.0", diff --git a/ui/src/Common/Fetch.js b/ui/src/Common/Fetch.js new file mode 100644 index 000000000..15a1e2e11 --- /dev/null +++ b/ui/src/Common/Fetch.js @@ -0,0 +1,6 @@ +import merge from "lodash.merge"; + +const FetchWithCredentials = async (uri, options) => + await fetch(uri, merge({}, { credentials: "include" }, options)); + +export { FetchWithCredentials }; diff --git a/ui/src/Common/Fetch.test.js b/ui/src/Common/Fetch.test.js new file mode 100644 index 000000000..14483d44c --- /dev/null +++ b/ui/src/Common/Fetch.test.js @@ -0,0 +1,40 @@ +import { FetchWithCredentials } from "./Fetch"; + +beforeEach(() => { + fetch.resetMocks(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("FetchWithCredentials", () => { + it("fetch passes '{credentials: include}' to all requests", async () => { + const request = FetchWithCredentials("http://example.com", {}); + await expect(request).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith("http://example.com", { + credentials: "include" + }); + }); + + it("custom keys are merged with defaults", async () => { + const request = FetchWithCredentials("http://example.com", { + foo: "bar" + }); + await expect(request).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith("http://example.com", { + credentials: "include", + foo: "bar" + }); + }); + + it("custom credentials are used when passed", async () => { + const request = FetchWithCredentials("http://example.com", { + credentials: "none" + }); + await expect(request).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledWith("http://example.com", { + credentials: "none" + }); + }); +}); diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js index 055dbbc58..d22aa16e8 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.js @@ -15,6 +15,7 @@ import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch"; import { APIAlertmanagerUpstream } from "Models/API"; import { AlertStore, FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; import { FormatQuery, QueryOperators, StaticLabels } from "Common/Query"; +import { FetchWithCredentials } from "Common/Fetch"; import { Modal } from "Components/Modal"; import { LabelSetList, @@ -136,7 +137,7 @@ const DeleteSilenceModalContent = observer( FormatQuery(StaticLabels.SilenceID, QueryOperators.Equal, silenceID) ]); - this.previewState.fetch = fetch(alertsURI, { credentials: "include" }) + this.previewState.fetch = FetchWithCredentials(alertsURI, {}) .then(result => result.json()) .then(result => { this.previewState.groupsToUniqueLabels(Object.values(result.groups)); @@ -165,9 +166,9 @@ const DeleteSilenceModalContent = observer( ? `${alertmanager.publicURI}/api/v2/silence/${silenceID}` : `${alertmanager.publicURI}/api/v1/silence/${silenceID}`; - this.deleteState.fetch = fetch(uri, { + this.deleteState.fetch = FetchWithCredentials(uri, { method: "DELETE", - credentials: "include" + headers: alertmanager.headers }) .then(result => { if (isOpenAPI) { diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js index 7704ff8b2..f74f47a93 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/DeleteSilence.test.js @@ -122,7 +122,7 @@ describe("", () => { expect(tree.find("ErrorMessage")).toHaveLength(1); }); - it("[v1] sends a DELETE request after clicking 'Confirm' button ", async () => { + it("[v1] sends a DELETE request after clicking 'Confirm' button", async () => { await VerifyResponse({ status: "success" }); expect(fetch.mock.calls[1][0]).toBe( "http://am.example.com/api/v1/silence/123456789" @@ -130,7 +130,7 @@ describe("", () => { expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" }); }); - it("[v2] sends a DELETE request after clicking 'Confirm' button ", async () => { + it("[v2] sends a DELETE request after clicking 'Confirm' button", async () => { alertmanager.version = "0.16.2"; await VerifyResponse({ status: "success" }); expect(fetch.mock.calls[1][0]).toBe( @@ -139,6 +139,33 @@ describe("", () => { expect(fetch.mock.calls[1][1]).toMatchObject({ method: "DELETE" }); }); + it("[v1] sends headers from alertmanager config", async () => { + alertmanager.headers = { Authorization: "Basic ***" }; + await VerifyResponse({ status: "success" }); + expect(fetch.mock.calls[1][0]).toBe( + "http://am.example.com/api/v1/silence/123456789" + ); + expect(fetch.mock.calls[1][1]).toMatchObject({ + credentials: "include", + method: "DELETE", + headers: { Authorization: "Basic ***" } + }); + }); + + it("[v1] sends headers from alertmanager config", async () => { + alertmanager.headers = { Authorization: "Basic ***" }; + alertmanager.version = "0.16.2"; + await VerifyResponse({ status: "success" }); + expect(fetch.mock.calls[1][0]).toBe( + "http://am.example.com/api/v2/silence/123456789" + ); + expect(fetch.mock.calls[1][1]).toMatchObject({ + credentials: "include", + method: "DELETE", + headers: { Authorization: "Basic ***" } + }); + }); + it("'Confirm' button is no-op after successful DELETE", async () => { const tree = await VerifyResponse({ status: "success" }); expect(fetch.mock.calls[1][0]).toBe( diff --git a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js index 1270712d6..51630994e 100644 --- a/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js +++ b/ui/src/Components/Grid/AlertGrid/AlertGroup/Silence/index.test.js @@ -67,6 +67,7 @@ beforeEach(() => { cluster: "default", uri: "file:///mock", publicURI: "http://example.com", + headers: {}, error: "", version: "0.15.0", clusterMembers: ["default"] @@ -203,6 +204,7 @@ describe("", () => { cluster: "default", uri: "file:///mock", publicURI: "http://example.com", + headers: {}, error: "", version: "0.15.0", clusterMembers: ["default"] diff --git a/ui/src/Components/MainModal/Configuration/SortLabelName.js b/ui/src/Components/MainModal/Configuration/SortLabelName.js index 02c44cdf4..096b3f6aa 100644 --- a/ui/src/Components/MainModal/Configuration/SortLabelName.js +++ b/ui/src/Components/MainModal/Configuration/SortLabelName.js @@ -7,6 +7,7 @@ import { observer } from "mobx-react"; import Creatable from "react-select/creatable"; import { StaticLabels } from "Common/Query"; +import { FetchWithCredentials } from "Common/Fetch"; import { FormatBackendURI } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; import { ReactSelectStyles } from "Components/MultiSelect"; @@ -33,9 +34,10 @@ const SortLabelName = observer( }); populateNameSuggestions = action(() => { - this.nameSuggestionsFetch = fetch(FormatBackendURI(`labelNames.json`), { - credentials: "include" - }) + this.nameSuggestionsFetch = FetchWithCredentials( + FormatBackendURI(`labelNames.json`), + {} + ) .then( result => result.json(), err => { diff --git a/ui/src/Components/NavBar/FilterInput/index.js b/ui/src/Components/NavBar/FilterInput/index.js index 7b285880e..0dc7b5329 100644 --- a/ui/src/Components/NavBar/FilterInput/index.js +++ b/ui/src/Components/NavBar/FilterInput/index.js @@ -15,6 +15,7 @@ import { faSearch } from "@fortawesome/free-solid-svg-icons/faSearch"; import { AlertStore, FormatBackendURI } from "Stores/AlertStore"; import { Settings } from "Stores/Settings"; import { IsMobile } from "Common/Device"; +import { FetchWithCredentials } from "Common/Fetch"; import { FilterInputLabel } from "Components/Labels/FilterInputLabel"; import { AutosuggestTheme } from "./Constants"; import { History } from "./History"; @@ -72,9 +73,9 @@ const FilterInput = observer( onSuggestionsFetchRequested = debounce( action(({ value }) => { if (value !== "") { - this.inputStore.suggestionsFetch = fetch( + this.inputStore.suggestionsFetch = FetchWithCredentials( FormatBackendURI(`autocomplete.json?term=${value}`), - { credentials: "include" } + {} ) .then( result => result.json(), diff --git a/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js index 1313dd7ec..cd8b4b442 100644 --- a/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js +++ b/ui/src/Components/SilenceModal/AlertManagerInput/index.test.js @@ -22,6 +22,7 @@ beforeEach(() => { name: "am1", uri: "http://am1.example.com", publicURI: "http://am1.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", @@ -31,6 +32,7 @@ beforeEach(() => { name: "am2", uri: "http://am2.example.com", publicURI: "http://am2.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", @@ -40,6 +42,7 @@ beforeEach(() => { name: "am3", uri: "http://am3.example.com", publicURI: "http://am3.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "am3", diff --git a/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js b/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js index 4a3176af8..8fc6c6a9e 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/LabelNameInput.js @@ -8,6 +8,7 @@ import { SilenceFormMatcher } from "Models/SilenceForm"; import { MultiSelect } from "Components/MultiSelect"; import { ValidationError } from "Components/MultiSelect/ValidationError"; import { FormatBackendURI } from "Stores/AlertStore"; +import { FetchWithCredentials } from "Common/Fetch"; const LabelNameInput = observer( class LabelNameInput extends MultiSelect { @@ -19,9 +20,10 @@ const LabelNameInput = observer( populateNameSuggestions = action(() => { const { matcher } = this.props; - this.nameSuggestionsFetch = fetch(FormatBackendURI(`labelNames.json`), { - credentials: "include" - }) + this.nameSuggestionsFetch = FetchWithCredentials( + FormatBackendURI(`labelNames.json`), + {} + ) .then( result => result.json(), err => { @@ -43,11 +45,9 @@ const LabelNameInput = observer( populateValueSuggestions = action(() => { const { matcher } = this.props; - this.valueSuggestionsFetch = fetch( + this.valueSuggestionsFetch = FetchWithCredentials( FormatBackendURI(`labelValues.json?name=${matcher.name}`), - { - credentials: "include" - } + {} ) .then( result => result.json(), diff --git a/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js index c68ef230d..8f9d19bc5 100644 --- a/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js +++ b/ui/src/Components/SilenceModal/SilenceMatch/MatchCounter.js @@ -14,6 +14,7 @@ import { FormatBackendURI, FormatAlertsQ } from "Stores/AlertStore"; import { SilenceFormStore } from "Stores/SilenceFormStore"; import { SilenceFormMatcher } from "Models/SilenceForm"; import { TooltipWrapper } from "Components/TooltipWrapper"; +import { FetchWithCredentials } from "Common/Fetch"; import { MatcherToFilter, AlertManagersToFilter } from "../Matchers"; const MatchCounter = observer( @@ -54,7 +55,7 @@ const MatchCounter = observer( const alertsURI = FormatBackendURI("alerts.json?") + FormatAlertsQ(filters); - this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" }) + this.matchedAlerts.fetch = FetchWithCredentials(alertsURI, {}) .then(result => { return result.json(); }) diff --git a/ui/src/Components/SilenceModal/SilencePreview/index.js b/ui/src/Components/SilenceModal/SilencePreview/index.js index 41d3c8d2d..2701169e2 100644 --- a/ui/src/Components/SilenceModal/SilencePreview/index.js +++ b/ui/src/Components/SilenceModal/SilencePreview/index.js @@ -15,6 +15,7 @@ import { LabelSetList, GroupListToUniqueLabelsList } from "Components/LabelSetList"; +import { FetchWithCredentials } from "Common/Fetch"; import { MatcherToFilter, AlertManagersToFilter } from "../Matchers"; const FetchError = ({ message }) => ( @@ -67,7 +68,7 @@ const SilencePreview = observer( const alertsURI = FormatBackendURI("alerts.json?") + FormatAlertsQ(filters); - this.matchedAlerts.fetch = fetch(alertsURI, { credentials: "include" }) + this.matchedAlerts.fetch = FetchWithCredentials(alertsURI, {}) .then(result => { return result.json(); }) diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js index 45c322ae7..d16ed4a6e 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.js @@ -13,6 +13,7 @@ import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclama import { APISilenceMatcher } from "Models/API"; import { AlertStore } from "Stores/AlertStore"; +import { FetchWithCredentials } from "Common/Fetch"; const SubmitState = Object.freeze({ InProgress: "InProgress", @@ -108,13 +109,13 @@ const SilenceSubmitProgress = observer( ? `${am.publicURI}/api/v2/silences` : `${am.publicURI}/api/v1/silences`; - this.submitState.fetch = fetch(uri, { + this.submitState.fetch = FetchWithCredentials(uri, { method: "POST", body: JSON.stringify(payload), headers: { - "Content-Type": "application/json" - }, - credentials: "include" + "Content-Type": "application/json", + ...am.headers + } }) .then(result => { if (isOpenAPI) { diff --git a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js index d04bab3cb..73d7852fb 100644 --- a/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js +++ b/ui/src/Components/SilenceModal/SilenceSubmit/SilenceSubmitProgress.test.js @@ -15,6 +15,7 @@ beforeEach(() => { name: "mockAlertmanager", uri: "file:///mock", publicURI: "http://example.com", + headers: { foo: "bar" }, error: "", version: "0.15.0", cluster: "mockAlertmanager", @@ -68,7 +69,7 @@ describe("", () => { const payload = fetch.mock.calls[0][1]; expect(payload).toMatchObject({ method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", foo: "bar" }, body: JSON.stringify({ matchers: [], startsAt: "now", @@ -93,6 +94,7 @@ describe("", () => { name: "am1", uri: "file:///mock", publicURI: "http://am1.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", @@ -102,6 +104,7 @@ describe("", () => { name: "am2", uri: "file:///mock", publicURI: "http://am2.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", @@ -147,6 +150,7 @@ describe("", () => { name: "am1", uri: "file:///mock", publicURI: "http://am1.example.com", + headers: {}, error: "", version: "0.15.0", cluster: "ha", diff --git a/ui/src/Models/API.js b/ui/src/Models/API.js index 46a452ef7..61710615f 100644 --- a/ui/src/Models/API.js +++ b/ui/src/Models/API.js @@ -70,6 +70,7 @@ const APIAlertmanagerUpstream = PropTypes.exact({ cluster: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, publicURI: PropTypes.string.isRequired, + headers: PropTypes.object.isRequired, error: PropTypes.string.isRequired, version: PropTypes.string.isRequired, clusterMembers: PropTypes.arrayOf(PropTypes.string).isRequired diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index e6025abb5..64ecc7935 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -6,6 +6,8 @@ import equal from "fast-deep-equal"; import qs from "qs"; +import { FetchWithCredentials } from "Common/Fetch"; + const QueryStringEncodeOptions = { encodeValuesOnly: true, // don't encode q[] indices: false // go-gin doesn't support parsing q[0]=foo&q[1]=bar @@ -247,7 +249,7 @@ class AlertStore { `alerts.json?sortOrder=${sortOrder}&sortLabel=${sortLabel}&sortReverse=${sortReverse}&` ) + FormatAPIFilterQuery(this.filters.values.map(f => f.raw)); - return fetch(alertsURI, { credentials: "include" }) + return FetchWithCredentials(alertsURI, {}) .then(result => { this.status.setProcessing(); return result.json(); diff --git a/ui/src/__mocks__/Alerts.js b/ui/src/__mocks__/Alerts.js index e0059eebf..edaf24c93 100644 --- a/ui/src/__mocks__/Alerts.js +++ b/ui/src/__mocks__/Alerts.js @@ -71,6 +71,9 @@ const MockAlertmanager = () => ({ cluster: "default", uri: "http://localhost", publicURI: "http://am.example.com", + headers: { + Authorization: "Basic foo bar" + }, error: "", version: "0.15.0", clusterMembers: ["default"] From 9eaa616d5dfb1f76ffdd0e46d5df08d9f9ff854b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Thu, 26 Sep 2019 20:24:20 +0100 Subject: [PATCH 3/3] fix(docs): update docs to mention basic auth handling --- docs/CONFIGURATION.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3e0844f8f..562e0b98c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -69,10 +69,10 @@ alertmanager: (`https://user:password@alertmanager.example.com`) and you don't want it to be visible to users then ensure `proxy: true` is also set in order to avoid leaking auth information to the browser. - Without proxy mode full URI needs to be passed to karma web UI code. - With proxy mode all requests will be routed via karma HTTP server and since - karma has full URI in the config it only needs Alertmanager name in that - request. + Note: if URI contains username and password and proxy option is NOT enabled + (see below), then the username & password information will be stripped from + the URI and `Authorization` header using Basic Auth will be set for all + in browser requests. To set a different URI for all browser requests (can be any valid URI) see `external_uri` option below. - `external_uri` - base URI of this Alertmanager server used for all browser