Files
openobserve/web/src/utils/common.ts
T
Ashish Kolhe de0d37e23d fix(alerts): stop losing Slack alerts on undeliverable link URLs (#13742) (#13753)
Fixes #13742
Fixes openobserve/o2-enterprise#2394

## The bug

A content template link with a non-http scheme (`javascript:alert(1)`)
made Slack reject the **entire** notification with `400
invalid_attachments`. The alert was lost outright and `triggers`
recorded `notify_failed`.

## Root cause

`safe_url` neutralises a hostile scheme by substituting `BLOCKED_URL`
(`"#"`). That is **safe** but not **deliverable** — Slack, Teams,
Discord and PagerDuty validate action URLs server-side and reject the
whole payload when one is not an absolute `http(s)` URL. A security
substitution was turning into total message loss.

The two properties are different, and links need both. So the fix splits
by medium:

| renderer | link handling |
|---|---|
| Slack buttons, Teams actions, Discord `embed.url`, PagerDuty `href` |
`dispatchable_url` → **drop** |
| HTML email, email plaintext, SNS plaintext, Discord description |
`safe_url` → substitute `#` |
| Webhook envelope | unfiltered — versioned machine contract, documented
|

Dropping beats rewriting: the alert body is worth more than the button,
and a button pointing somewhere misleading is worse than no button.

`dispatchable_url` requires a **non-empty authority**, not just an
allowlisted scheme — `http:javascript:alert(1)` and `https://` are valid
*opaque* RFC 3986 http(s) URLs that Slack still rejects. It also rejects
raw spaces and control characters, which variable substitution can
introduce.

## Related issues fixed in the same area

- **Empty `ZO_WEB_URL`** (not in the report): `alert_url` becomes a
root-relative `/web/logs?…`, which `safe_url` passes through by design,
and it lands in the always-appended "View in OpenObserve" button. Same
`invalid_attachments`, on deployments that never authored a hostile
template.
- **PagerDuty** validates `links[].href` server-side too — same
lost-alert class, converted.
- **Save-time rejection** (`ContentSpec::validate`): a clear 400 naming
the offending link, instead of a template whose link silently never
works. Also wired into **preview** and **test-send**, which previously
accepted a draft that couldn't be saved and echoed the raw hostile URL
back to the UI.
- **Slack image-fallback misdiagnosis**: removing the image proves the
image was at fault, but only an image pointing at *our* `web_url`
justifies tripping the process-wide chart suppression (1h, across every
alert) and telling the operator to fix `ZO_WEB_URL`. A custom template's
third-party image no longer does either.
- **a11y**: `OInput` links its error message to the control via
`aria-describedby`; the textarea branch also gained `aria-invalid`.

## o2-enterprise#2394 (fixed here, not in the enterprise repo)

Clearing the Monaco body and saving toasted "Please fill required
fields" with no indication of *where*. The schema already produced an
error on the `body` path — a bare Monaco bridged in via `setFieldValue`
just had no `OFormInput` error slot to render it. Now a red border +
message driven by the same field state `OFormInput` reads.

The aria wiring goes on the **shell**, not on `<QueryEditor>`:
`CodeQueryEditor` sets `inheritAttrs: false` and binds `$attrs` to a
non-focusable wrapper, so attributes passed to the component would be
inert — false assurance for a screen-reader user.

> The issue names the title field too. It's an `OFormInput` and already
highlights correctly; I added a regression test pinning that rather than
changing it. This fix is entirely in OSS (`web/`) since the template
form isn't enterprise-gated, so there's no companion enterprise PR.

## Layering note

Save-time validation is a **UX gate, not the security boundary**. Link
URLs are templates — a scheme that only materialises after `{variable}`
substitution can't be judged at save time. The render layer is what
actually stops delivery, and it also covers every template saved before
this validation existed. Both layers are needed; neither is sufficient.

## Testing

- Every fix written test-first (verified RED) and **mutation-tested** —
each new test fails when its fix is reverted in substance.
- Golden files unchanged: legitimate URLs render byte-identically, so no
over-blocking.
- Rust: `alerts::notifications` 159 · `config` 3130 · `api-management`
424 — all passing. `cargo clippy --all-targets` and `cargo fmt --check`
clean.
- Frontend: full suite **41,597 passing** (run because `OInput` is
shared library code). `vue-tsc`, `eslint`, and `npm run format:check`
clean.
- Two independent cold-start review passes; findings from both are
folded in, including a P0 where the first version of `dispatchable_url`
still let the original bug through.

Pre-existing and unrelated:
`alerts::destinations::tests::test_alert_destination_template_is_optional`
fails locally on a DB connection reset — verified failing on unmodified
`main` too.
2026-08-11 12:08:20 +00:00

117 lines
4.1 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ref } from "vue";
import organizationsService from "../services/organizations";
import { useLocalOrganization, getPath } from "./zincutils";
import { isSameOriginRedirect } from "@/utils/safeUrl";
const selectedOrg = ref("");
const orgOptions = ref([{ label: Number, value: String }]);
export const getDefaultOrganization = async (userInfo: any, org_identifier: any) => {
await organizationsService
.os_list(0, 100000, "id", false, "", org_identifier)
.then((res: any) => {
const localOrg: any = useLocalOrganization();
if (localOrg.value != null && localOrg.value.user_email !== userInfo.email) {
localOrg.value = null;
useLocalOrganization("");
}
orgOptions.value = res.data.data.map(
(data: {
id: any;
name: any;
org_type: any;
identifier: any;
user_obj: any;
ingest_threshold: number;
search_threshold: number;
note: string;
}) => {
const optiondata: any = {
label: data.name,
id: data.id,
type: data.org_type,
identifier: data.identifier,
user_email: userInfo.email,
ingest_threshold: data.ingest_threshold,
search_threshold: data.search_threshold,
note: data.note,
};
if (
((selectedOrg.value == "" || selectedOrg.value == undefined) &&
data.org_type == "default" &&
userInfo.email == data.user_obj.email) ||
res.data.data.length == 1
) {
selectedOrg.value = localOrg.value ? localOrg.value : optiondata;
useLocalOrganization(selectedOrg.value);
// $store.dispatch("setSelectedOrganization", selectedOrg.value);
}
return optiondata;
},
);
return res.data.data;
});
};
export const redirectUser = (redirectURI: string | null) => {
const path = getPath();
// `redirectURI` comes from the `?short_url=` query param (router/index.ts),
// so it is attacker-controllable. The previous check was
// `redirectURI.includes("http")` — a substring test that happily matched
// `https://evil.com/` and sent the user off-site immediately AFTER they
// authenticated. Anything not on this origin falls back to the home path.
if (redirectURI != null && redirectURI != "") {
if (!isSameOriginRedirect(redirectURI)) {
// Off-origin: refuse it and go home instead.
window.location.replace(path);
} else if (redirectURI.startsWith("/")) {
// Relative path — keep using `replace` so the login page does not stay
// in history (unchanged from the original behaviour).
window.location.replace(redirectURI);
} else {
window.location.href = redirectURI;
}
} else {
// $router.push({ path: "/" });
window.location.replace(path);
}
};
export const logsErrorMessage = (code: number) => {
const messages: any = {
10001: "ServerInternalError",
20001: "SearchSQLNotValid",
20002: "SearchStreamNotFound",
20003: "FullTextSearchFieldNotFound",
20004: "SearchFieldNotFound",
20005: "SearchFunctionNotDefined",
20006: "SearchParquetFileNotFound",
20007: "SearchFieldHasNoCompatibleDataType",
20008: "SearchSQLExecuteError",
20009: "SearchSQLCancelled",
};
if (messages[code] != undefined) {
return "message." + messages[code];
} else {
return "";
}
};