Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ui): pass configured alertmanager headers to in-browser fetch calls #974

Merged
merged 3 commits into from
Sep 26, 2019
Merged
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: 10 additions & 0 deletions cmd/karma/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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++
Expand Down
8 changes: 4 additions & 4 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions internal/alertmanager/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/alertmanager/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 6 additions & 5 deletions internal/models/alertmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions internal/uri/urls.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package uri

import (
"encoding/base64"
"net/url"
"path"
)
Expand Down Expand Up @@ -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()
}
102 changes: 89 additions & 13 deletions internal/uri/urls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
}
}
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions ui/src/Common/Fetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import merge from "lodash.merge";

const FetchWithCredentials = async (uri, options) =>
await fetch(uri, merge({}, { credentials: "include" }, options));

export { FetchWithCredentials };
40 changes: 40 additions & 0 deletions ui/src/Common/Fetch.test.js
Original file line number Diff line number Diff line change
@@ -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"
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down
Loading