mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
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.
117 lines
4.1 KiB
TypeScript
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 "";
|
|
}
|
|
};
|