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.
This commit is contained in:
Ashish Kolhe
2026-08-11 12:08:20 +00:00
committed by GitHub
parent 883fc97046
commit de0d37e23d
53 changed files with 2218 additions and 120 deletions
@@ -485,7 +485,11 @@ pub async fn preview_template(
match preview(&req) {
Ok(resp) => MetaHttpResponse::json(resp),
Err(e @ PreviewError::UnknownChannel(_)) | Err(e @ PreviewError::UnknownSeverity(_)) => {
Err(e @ PreviewError::UnknownChannel(_))
| Err(e @ PreviewError::UnknownSeverity(_))
// The draft fails the same validation `save` applies — the client sent
// content it could not have saved, so this is a 400 like the others.
| Err(e @ PreviewError::InvalidContent(_)) => {
MetaHttpResponse::bad_request(e.to_string())
}
// Unreachable today (the webhook fallback is total), but a render
+284
View File
@@ -100,10 +100,167 @@ pub struct ChartSpec {
pub enabled: bool,
}
/// Schemes an authored link URL may carry.
///
/// Mirrors the render layer's allowlist (`alerts::notifications::render::
/// safe_url`) on purpose, but note which layer actually enforces what:
///
/// * **This check is a UX gate, not the security boundary.** It rejects a hostile scheme the author
/// typed LITERALLY, so they get a clear error at save time instead of a template whose link is
/// silently neutralized on every send forever after.
/// * **The render layer is what actually stops delivery.** Link URLs are templates: a scheme that
/// only materializes after `{variable}` substitution cannot be judged here at all, and `safe_url`
/// / `dispatchable_url` are what catch it. They also cover every template saved BEFORE this
/// validation existed.
///
/// So a URL passing this function is NOT thereby trusted — see the render
/// module's docs for the per-medium drop/substitute rules.
const ALLOWED_LINK_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
/// Reject a link URL whose author-written scheme is not allowlisted.
///
/// An allowlist, not a blocklist, because a blocklist fails OPEN: `java\tscript:`,
/// `\0javascript:`, `java%73cript:` and `vbscript:` all slip past a naive
/// `starts_with("javascript:")` while a conformant client still dispatches
/// them. None of them *begin* with an allowed scheme, so an allowlist closes
/// all of them at once and fails closed on schemes nobody has thought of yet.
///
/// A link field holds a URL — so the value must actually LOOK like one. Three
/// shapes are accepted, and nothing else:
///
/// * **Absolute** — `http:`, `https:` or `mailto:` (the scheme allowlist).
/// * **Root-relative** (`/path`, `?q=1`, `#frag`) — inert, resolves against the deployment's own
/// host, and an author writing one is doing something legitimate (it renders fine in email).
/// * **Placeholder-led** (`{alert_url}`, `{alert_url}&tab=logs`, `{scheme}://host`) — link URLs are
/// TEMPLATES resolved by `substitute_raw` at send time, so there may be no author scheme to judge
/// yet. The render-time filter catches a hostile substituted value.
///
/// A bare word (`foo`, `javascript(0)`, `not a url at all`) or a bare host
/// (`runbook.example.com/x`) is REJECTED. None can execute anything — a
/// scheme-less string is just a relative path — but none is a working link
/// either, and silently accepting one means the author discovers the dead
/// link when an alert fires instead of when they save.
fn link_scheme_is_allowed(url: &str) -> bool {
let trimmed = url.trim();
if trimmed.is_empty() {
return false;
}
// Whitespace and control characters are never valid inside a URL.
if trimmed.chars().any(|c| c.is_whitespace() || c.is_control()) {
return false;
}
// A `:` only introduces a scheme when it precedes any path/query/fragment
// marker, per RFC 3986 — so `/a:b` is a scheme-less path, not a scheme.
let scheme_end = trimmed
.find([':', '/', '?', '#'])
.filter(|&i| trimmed.as_bytes()[i] == b':');
let Some(end) = scheme_end else {
// No scheme: accept a root-relative reference, or a template whose
// scheme only materializes after substitution.
return trimmed.starts_with(['/', '?', '#']) || is_variable_led(trimmed);
};
let scheme = &trimmed[..end];
// A scheme that is ENTIRELY a variable has nothing to judge yet —
// `{scheme}://host` is a legitimate template. Deliberately strict:
// `{x}javascript:...` also begins with `{`, but the rest of its scheme is
// the literal, hostile `javascript`, so it must be rejected.
if scheme.starts_with('{') && scheme.ends_with('}') && !scheme[1..].contains('{') {
return true;
}
// Schemes are ASCII case-insensitive per RFC 3986.
if !ALLOWED_LINK_SCHEMES
.iter()
.any(|allowed| scheme.eq_ignore_ascii_case(allowed))
{
return false;
}
// An allowlisted scheme is necessary but NOT sufficient — `http:` and
// `https://` carry one and are still not URLs. Delegate the actual
// question to a WHATWG-conformant parser rather than hand-rolling it.
//
// A `{variable}` inside the path/query is fine: it percent-encodes and
// still parses, so the substituted value is what ultimately matters.
let Ok(parsed) = ::url::Url::parse(trimmed) else {
return false;
};
if parsed.scheme().eq_ignore_ascii_case("mailto") {
// The parser accepts ANY opaque path after `mailto:`, including an
// empty one, so the mailbox shape is checked here: `local@domain`
// with a dot in the domain.
let addr = parsed.path();
let Some((local, domain)) = addr.split_once('@') else {
return false;
};
return !local.is_empty() && domain.contains('.') && !domain.starts_with('.');
}
// http(s): the parser already guarantees a non-empty host (an empty host
// is a parse FAILURE for a special scheme). Reject hosts that parse but
// can never resolve — `.`, `..`, `-` and friends.
match parsed.host_str() {
Some(host) => {
!host.is_empty()
&& host.chars().any(|c| c.is_ascii_alphanumeric())
&& !host.starts_with(['.', '-'])
&& !host.ends_with(['.', '-'])
}
None => false,
}
}
/// True when `url` begins with a `{variable}` and the remainder is plausible
/// URL tail (no whitespace — already checked — and not obvious prose).
///
/// `{alert_url}` and `{alert_url}&tab=logs` are legitimate templates whose
/// real value only exists at send time; `{x} not a url` is not.
fn is_variable_led(url: &str) -> bool {
let Some(rest) = url.strip_prefix('{') else {
return false;
};
let Some(close) = rest.find('}') else {
return false;
};
// A non-empty variable name, and nothing unparseable glued on after it.
!rest[..close].is_empty()
}
impl ContentSpec {
pub fn parse(body: &str) -> Result<ContentSpec, serde_json::Error> {
serde_json::from_str(body)
}
/// Validate authored content beyond what serde can express.
///
/// Returns a human-readable error naming the offending link, so the author
/// can fix the template instead of discovering at alert time that a link
/// was dropped (#13742).
pub fn validate(&self) -> Result<(), String> {
for link in &self.links {
if !link_scheme_is_allowed(&link.url) {
let label = if link.label.is_empty() {
"<unlabeled>"
} else {
link.label.as_str()
};
return Err(format!(
"link '{label}' has an unsupported URL scheme: {:?}. \
Links must use http, https or mailto (a relative path or \
a {{variable}} is also allowed).",
link.url
));
}
}
Ok(())
}
}
#[cfg(test)]
@@ -142,4 +299,131 @@ mod tests {
fn rejects_non_json() {
assert!(ContentSpec::parse("Slack payload {alert_name}").is_err());
}
/// #13742: a link URL carrying an active-content scheme is rejected AT
/// SAVE TIME with a clear error, rather than being stored and silently
/// neutralized (or, before the fix, killing the whole notification) on
/// every send forever after.
#[test]
fn rejects_link_urls_with_disallowed_schemes() {
for hostile in [
"javascript:alert(1)",
"JavaScript:alert(1)", // scheme match is case-insensitive
" javascript:alert(1)", // and ignores surrounding whitespace
"java\tscript:alert(1)", // control chars stripped by clients
"java\nscript:alert(1)",
"\u{0}javascript:alert(1)", // NUL is not Unicode whitespace
"java%73cript:alert(1)", // percent-encoded scheme
"vbscript:msgbox(1)",
"file:///etc/passwd",
"data:text/html,<script>1",
// A variable elsewhere in the URL does not excuse a hostile
// scheme written literally in scheme position.
"{x}javascript:alert(1)",
// A link field holds a URL. These are not URLs — they are typos or
// garbage, and accepting them means the author only finds out the
// link is dead when an alert fires.
"javascript(0)",
"not a url at all",
"foo",
"runbook.example.com/x", // bare host: no scheme, ambiguous — require one
// An allowlisted scheme with NO HOST. Per the WHATWG URL Standard
// an empty host is a parse FAILURE for a special scheme (http/
// https), not a warning — these are not URLs.
"http:",
"http://",
"https://",
"http:///",
"http://?q=1",
"http://#frag",
"https:// ",
// Hosts that parse but cannot resolve to anything real.
"https://.",
"http://..",
"https://-",
"http://.:80",
// `mailto:` needs a real mailbox. The URL parser accepts ANY
// opaque path after the scheme (even empty), so the address shape
// has to be checked separately or `mailto:` alone would pass.
"mailto:",
"mailto:foo", // no @
"mailto:@",
"mailto:a@", // no domain
"mailto:@b.com", // no local part
"mailto:a b@c.com", // space in address
// A variable-led template must still LOOK like a URL once the
// variable is peeled off — a bare `{x}` with junk attached is not.
"{x} not a url",
] {
let body = serde_json::json!({
"title": "t",
"body": "b",
"links": [{"label": "click", "url": hostile}],
})
.to_string();
let spec = ContentSpec::parse(&body).expect("parses as JSON");
let err = spec
.validate()
.expect_err(&format!("accepted hostile url: {hostile:?}"));
// The message must name the offending link so the author can fix
// it without guessing which of several links is at fault.
assert!(
err.contains("click"),
"error should name the link label, got: {err}"
);
}
}
/// Guard against over-rejection — legitimate authored URLs must save.
///
/// Link URLs are TEMPLATES: `substitute_raw` resolves `{var}` at send
/// time, so a URL whose scheme only exists after substitution (or which
/// is entirely a placeholder) has no author-written scheme to judge here
/// and must be allowed through. Render-time filtering still guards it.
#[test]
fn accepts_legitimate_and_templated_link_urls() {
for ok in [
"https://runbook.example/x",
"http://runbook.example/x",
"mailto:oncall@example.com",
"https://runbook.example/{stream_name}", // templated path
"{alert_url}", // whole URL is a variable
"{alert_url}&tab=logs", // ...and one with a suffix
"{scheme}://host/x", // variable IN scheme position
"/web/logs?stream=app", // relative: inert, valid in email
// Positive counterparts to the hostile uppercase/whitespace cases
// above: without these, dropping `eq_ignore_ascii_case` or `.trim()`
// would still pass every test while silently over-rejecting.
"HTTPS://runbook.example/x",
"MailTo:oncall@example.com",
" https://runbook.example/x ",
// Real-world absolute URLs must keep working.
"https://o2.example:8443/web/logs?a=1#f",
"http://localhost:5080/web/logs",
"https://user:pass@host.example/x", // userinfo is legal
"https://[::1]:8080/x", // IPv6 literal
"https://192.168.1.1/x", // IPv4 literal
] {
let body = serde_json::json!({
"title": "t",
"body": "b",
"links": [{"label": "click", "url": ok}],
})
.to_string();
let spec = ContentSpec::parse(&body).expect("parses as JSON");
assert!(
spec.validate().is_ok(),
"over-rejected legitimate url: {ok:?}"
);
}
}
/// A spec with no links at all validates — the shipped default content
/// template has `links: []`, so this must never start failing or every
/// node would fail to seed its templates at startup.
#[test]
fn accepts_spec_without_links() {
let spec = ContentSpec::parse(r#"{"title":"t","body":"b"}"#).unwrap();
assert!(spec.validate().is_ok());
}
}
+26 -11
View File
@@ -2663,7 +2663,7 @@ async fn send_http_notification(endpoint: &Endpoint, msg: String) -> Result<Stri
config::metrics::ALERT_CHART_EVENTS_TOTAL
.with_label_values(&["slack_image_pre_stripped"])
.inc();
msg = stripped;
msg = stripped.msg;
}
let resp = match build_req(msg.clone()).send().await {
@@ -2690,22 +2690,37 @@ async fn send_http_notification(endpoint: &Endpoint, msg: String) -> Result<Stri
&& resp_status == reqwest::StatusCode::BAD_REQUEST
&& (resp_body.contains("invalid_attachments") || resp_body.contains("invalid_blocks"))
&& let Some(stripped) = slack_render::strip_image_blocks(&msg)
&& let Ok(retry_resp) = build_req(stripped).send().await
&& let Ok(retry_resp) = build_req(stripped.msg).send().await
&& retry_resp.status().is_success()
{
slack_render::mark_images_undeliverable(now_secs);
config::metrics::ALERT_CHART_EVENTS_TOTAL
.with_label_values(&["slack_image_rejected"])
.inc();
log::warn!(
"[ALERT_CHART] Slack rejected the notification ({resp_body}) because its image \
proxy could not fetch the chart image; delivered without the image. Slack must \
be able to reach {} from the public internet — or set \
ZO_ALERT_CHART_ENABLED=false to stop embedding charts.",
config::get_config().common.web_url,
);
// Removing the image fixed THIS send — but that only says
// something about `ZO_WEB_URL`'s reachability when the image we
// removed was actually ours. A custom template embedding a
// third-party image Slack dislikes must not suppress charts
// process-wide for an hour, nor be reported as a `web_url`
// problem the operator would then go and "fix" in vain.
if stripped.had_web_url_image {
slack_render::mark_images_undeliverable(now_secs);
log::warn!(
"[ALERT_CHART] Slack rejected the notification ({resp_body}) because its \
image proxy could not fetch the chart image; delivered without the image. \
Slack must be able to reach {} from the public internet — or set \
ZO_ALERT_CHART_ENABLED=false to stop embedding charts.",
config::get_config().common.web_url,
);
} else {
log::warn!(
"[ALERT_CHART] Slack rejected the notification ({resp_body}); it was \
delivered after removing its image block(s). The image did not point at \
this deployment's web_url, so the chart-image suppression was NOT engaged \
— check the image URLs in the destination's template."
);
}
return Ok(format!(
"sent status: {} (chart image stripped: Slack could not fetch it)",
"sent status: {} (image stripped: Slack rejected it)",
reqwest::StatusCode::OK,
));
}
+37 -1
View File
@@ -87,6 +87,8 @@ pub enum PreviewError {
UnknownChannel(String),
UnknownSeverity(String),
RenderFailed(String),
/// Draft fails the same content validation `templates::save` applies.
InvalidContent(String),
}
impl std::fmt::Display for PreviewError {
@@ -98,6 +100,7 @@ impl std::fmt::Display for PreviewError {
"unknown severity: {s} (expected critical, warning, ok, no_data, or single_level)"
),
Self::RenderFailed(e) => write!(f, "preview render failed: {e}"),
Self::InvalidContent(e) => write!(f, "{e}"),
}
}
}
@@ -200,6 +203,15 @@ pub fn synthetic_context(level: Option<AlertLevel>) -> NotificationContext {
pub fn preview(req: &PreviewRequest) -> Result<PreviewResponse, PreviewError> {
let format = parse_channel(&req.channel)?;
let level = parse_severity(&req.severity)?;
// Hold the draft to the same bar `templates::save` applies. Previewing a
// spec that cannot be saved would report it as fine and defer the error
// to the save click — and `preview_model.links` echoes the raw authored
// URL back to the UI, so it must not carry a hostile scheme either.
req.definition
.validate()
.map_err(PreviewError::InvalidContent)?;
let ctx = synthetic_context(level);
let content = resolve_content(&req.definition, &ctx, format.channel_family());
@@ -271,10 +283,34 @@ fn sample_spec() -> ContentSpec {
#[cfg(test)]
mod tests {
use config::meta::alerts::content_spec::{ContentField, SeverityFilter};
use config::meta::alerts::content_spec::{ContentField, ContentLink, SeverityFilter};
use super::*;
/// A draft whose link scheme `save` would reject must be rejected here
/// too. Previewing it cleanly and only failing on save teaches the author
/// the template is fine when it is not (#13742).
#[test]
fn preview_rejects_a_draft_the_save_path_would_reject() {
let mut spec = sample_spec();
spec.links.push(ContentLink {
label: "click".into(),
url: "javascript:alert(1)".into(),
show_when: None,
});
let req = PreviewRequest {
definition: spec,
channel: "slack".into(),
severity: Some("critical".into()),
sample: None,
};
let err = preview(&req).expect_err("hostile link previewed cleanly");
assert!(
err.to_string().contains("click"),
"error should name the offending link, got: {err}"
);
}
#[test]
fn preview_uses_production_renderer() {
let req = PreviewRequest {
@@ -26,7 +26,8 @@
use serde_json::{Value, json};
use super::{
DEFAULT_LINK_LABEL, clamp, markdown::escape_discord_markdown, safe_url, severity_color,
DEFAULT_LINK_LABEL, clamp, dispatchable_url, markdown::escape_discord_markdown, safe_url,
severity_color,
};
use crate::alerts::notifications::{NotificationContext, resolve::RenderedContent};
@@ -95,7 +96,16 @@ pub fn render_discord(c: &RenderedContent, ctx: &NotificationContext) -> Value {
// link, not a button list. Any further author-added links have no embed
// slot to render into and are appended to the description as markdown
// links instead.
let embed_url = c.links.first().map(|(_, url)| safe_url(url).to_string());
// Discord validates `embed.url` and rejects the message when it is not an
// absolute http(s) URL, so an undispatchable first link yields NO embed
// url rather than an invalid one (#13742's failure class). The markdown
// links appended to the description below are inert TEXT, so they keep
// `safe_url`'s neutralize-in-place behaviour instead.
let embed_url = c
.links
.first()
.and_then(|(_, url)| dispatchable_url(url))
.map(str::to_string);
let extra_links = if c.links.is_empty() {
&c.links[..]
} else {
+322 -8
View File
@@ -33,14 +33,35 @@
//! | HTML email | HTML entity-encoded; raw `<script>` never emitted |
//! | Webhook / all JSON | `serde_json` serialization |
//!
//! One defense IS global: [`safe_url`] restricts link URLs to an ALLOWLIST of
//! schemes (`http`, `https`, `mailto`, plus scheme-less relative URLs) in every
//! renderer that emits a link. Link URLs are author-supplied templates that
//! undergo variable substitution, so they are not trusted constants. An
//! Link URLs get a scheme ALLOWLIST ([`safe_url`]: `http`, `https`, `mailto`,
//! plus scheme-less relative URLs). Link URLs are author-supplied templates
//! that undergo variable substitution, so they are not trusted constants. An
//! allowlist rather than a blocklist because it fails closed: control
//! characters, NUL prefixes and percent-encoding all defeat a naive
//! `javascript:`/`data:` blocklist while clients still dispatch the URL.
//!
//! Being SAFE is not the same as being DELIVERABLE, and URLs need both.
//! `safe_url` neutralizes a hostile scheme by substituting [`BLOCKED_URL`]
//! ("#") — valid and inert in HTML email, but Slack, Teams, Discord and
//! PagerDuty validate action URLs server-side and reject the ENTIRE payload
//! when one is not an absolute `http(s)` URL. Substituting there cost the
//! alert its delivery (`400 invalid_attachments`, #13742). So those renderers
//! pass links through [`dispatchable_url`] and DROP what they cannot send,
//! while the forgiving media keep substituting.
//!
//! This is NOT uniform across renderers — choose by medium, and check this
//! table before adding a renderer or a new URL sink:
//!
//! | 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 — a versioned machine contract; see [`webhook`] |
//!
//! The webhook envelope is the one deliberate exemption: it is parsed by other
//! systems, so rewriting a URL to `#` would corrupt the data it promises to
//! carry. Its consumers must treat `links[].url` as untrusted.
//!
//! Pure functions — no I/O, no async.
pub mod discord;
@@ -213,6 +234,75 @@ pub(crate) fn safe_url(url: &str) -> &str {
}
}
/// Schemes a transport will accept as an ACTION target (button, card action).
const DISPATCHABLE_URL_SCHEMES: [&str; 2] = ["http", "https"];
/// Restrict a link URL to one that a strict transport will actually deliver.
///
/// [`safe_url`] answers "is this URL inert?"; this answers the different
/// question "will the transport accept it?" — and the two are NOT the same.
/// Slack, Teams and Discord validate every action URL server-side and require
/// an ABSOLUTE `http(s)` URL. Anything else makes them reject the ENTIRE
/// payload, so a link that cannot be dispatched must be DROPPED by the caller,
/// never emitted.
///
/// Returns `None` for every URL that is safe but undeliverable:
///
/// * [`BLOCKED_URL`] — what `safe_url` substitutes for a hostile scheme. Emitting it as a button
/// URL is what lost the alert in #13742.
/// * empty / whitespace-only — an unset `ZO_WEB_URL` produces exactly this.
/// * relative (`/path`, `?q=1`, `#frag`) — legitimate in email, meaningless to Slack, which has no
/// base URL to resolve against. An empty `ZO_WEB_URL` reaches here too.
/// * `mailto:` — allowlisted by `safe_url` and correct in an email body, but not a valid button
/// target.
/// * scheme-only / opaque / authority-less http(s) (`http:`, `https://`,
/// `http:javascript:alert(1)`) — an allowlisted scheme alone is NOT enough; Slack needs a host.
/// * anything containing a raw space or control character, which substitution can introduce.
///
/// Callers that render into a forgiving medium (HTML email, plain text) must
/// keep using [`safe_url`]: there, `href="#"` is valid, inert, and preserves
/// the visible signal that a link was neutralized.
pub(crate) fn dispatchable_url(url: &str) -> Option<&str> {
let trimmed = safe_url(url).trim();
// A raw space or control character breaks the transport even when the
// scheme and host are fine, and substitution can introduce either.
if trimmed
.chars()
.any(|c| c == ' ' || c.is_control() || c.is_whitespace())
{
return None;
}
// `safe_url` passes relative URLs through by design; they cannot be
// resolved by a transport that has no base URL, so they stop here.
let scheme_end = trimmed
.find([':', '/', '?', '#'])
.filter(|&i| trimmed.as_bytes()[i] == b':')?;
// Schemes are ASCII case-insensitive per RFC 3986.
let scheme = &trimmed[..scheme_end];
if !DISPATCHABLE_URL_SCHEMES
.iter()
.any(|allowed| scheme.eq_ignore_ascii_case(allowed))
{
return None;
}
// An allowlisted scheme is NOT sufficient. `http:javascript:alert(1)` and
// `https://` are both valid RFC 3986 http(s) URLs — the first is OPAQUE
// (no authority), the second has an empty one — and Slack rejects both
// with the very `400 invalid_attachments` this function exists to
// prevent. Require a non-empty authority.
let authority = trimmed[scheme_end + 1..].strip_prefix("//")?;
let authority_end = authority.find(['/', '?', '#']).unwrap_or(authority.len());
if authority[..authority_end].is_empty() {
return None;
}
Some(trimmed)
}
/// Render `content` for `format`.
pub fn render(
format: ChannelFormat,
@@ -834,6 +924,16 @@ mod tests {
/// filtering in one renderer only would invite the reader to assume it is
/// global when it is not. Every renderer that emits a link is asserted
/// here so the guarantee cannot silently become partial again.
///
/// Two different NEUTRALIZATIONS, by medium (#13742):
///
/// * Strict transports (Slack, Teams, Discord) validate action URLs and reject the whole
/// payload if one is invalid, so the hostile link is DROPPED — emitting the `BLOCKED_URL`
/// placeholder there cost the alert its delivery.
/// * Forgiving media (HTML email, plain text) SUBSTITUTE the placeholder: `href="#"` is valid
/// and inert, and keeping the row preserves the visible signal that a link was neutralized.
///
/// Either way the hostile URL never reaches the recipient.
#[test]
fn all_renderers_drop_hostile_url_schemes() {
for hostile in [
@@ -851,26 +951,59 @@ mod tests {
"file:///etc/passwd",
] {
let mut c = hostile_content();
c.links = vec![("evil".into(), hostile.into())];
// A legitimate link alongside the hostile one: without it the
// "dropped" assertions below could pass vacuously on a renderer
// that emitted no actions at all for an unrelated reason.
c.links = vec![
("evil".into(), hostile.into()),
("Runbook".into(), "https://rb.example/x".into()),
];
let survivor = "https://rb.example/x";
// Strict transports: the hostile link is gone, the good one stays.
let slack = slack::render_slack(&c, &fixture_ctx());
let elements = &slack["attachments"][1]["blocks"][0]["elements"];
assert_eq!(
slack["attachments"][1]["blocks"][0]["elements"][0]["url"], BLOCKED_URL,
elements.as_array().map(Vec::len),
Some(1),
"slack leaked {hostile}"
);
assert_eq!(elements[0]["url"], survivor, "slack dropped the wrong link");
let card = teams::render_teams_adaptive_card(&c, &fixture_ctx());
let actions = &card["attachments"][0]["content"]["actions"];
assert_eq!(
card["attachments"][0]["content"]["actions"][0]["url"], BLOCKED_URL,
actions.as_array().map(Vec::len),
Some(1),
"adaptive card leaked {hostile}"
);
assert_eq!(
actions[0]["url"], survivor,
"adaptive card dropped the wrong link"
);
let msg = teams::render_teams_message_card(&c);
let potential = &msg["potentialAction"];
assert_eq!(
msg["potentialAction"][0]["targets"][0]["uri"], BLOCKED_URL,
potential.as_array().map(Vec::len),
Some(1),
"message card leaked {hostile}"
);
assert_eq!(
potential[0]["targets"][0]["uri"], survivor,
"message card dropped the wrong link"
);
// PagerDuty validates `links[].href` too.
let pd = pagerduty::render_pagerduty(&c, &fixture_ctx());
let pd_links = pd["links"].as_array().expect("pagerduty links");
assert_eq!(pd_links.len(), 1, "pagerduty leaked {hostile}");
assert_eq!(
pd_links[0]["href"], survivor,
"pagerduty dropped the wrong link"
);
// Forgiving media: the row survives with the placeholder.
let (_, html, text) = email::render_email(&c, &fixture_ctx());
// Assert the placeholder positively: a `!contains` alone would
// pass vacuously if escaping merely reshaped the hostile string.
@@ -882,6 +1015,14 @@ mod tests {
text.contains(&format!("evil: {BLOCKED_URL}")),
"email text did not block {hostile}"
);
// SNS fans out to email/SMS clients that linkify bare URLs, so
// its plaintext gets the same substitution as email's.
let (_, sns_text) = sns::render_sns(&c);
assert!(
sns_text.contains(&format!("evil: {BLOCKED_URL}")),
"sns did not block {hostile}: {sns_text}"
);
}
}
@@ -938,6 +1079,179 @@ mod tests {
}
}
/// A URL that is SAFE is not automatically DISPATCHABLE.
///
/// Regression test for #13742: `safe_url` correctly rewrote
/// `javascript:alert(1)` to `BLOCKED_URL` ("#"), the renderer put "#" in a
/// Slack button's `url`, and Slack rejected the WHOLE message with
/// `400 invalid_attachments` — the alert was lost outright. Slack, Teams
/// and Discord validate action URLs server-side and require an absolute
/// `http(s)` URL, so every value that is inert-but-relative must be
/// reported as undispatchable so the renderer can DROP the link rather
/// than emit a payload the transport will reject.
#[test]
fn dispatchable_url_rejects_safe_but_undeliverable_urls() {
for undeliverable in [
BLOCKED_URL, // what safe_url substitutes for a hostile scheme
"", // unset ZO_WEB_URL yields an empty alert_url
" ", // ...and a whitespace-only one
"/web/logs?a=1", // empty ZO_WEB_URL leaves a root-relative path
"?query=1", // query-only relative URL
"#anchor", // fragment-only relative URL
"mailto:a@b.com", // safe + useful in email, invalid as a button URL
// An allowlisted scheme is NOT sufficient: Slack needs an
// authority too. These are OPAQUE http(s) URLs — valid per
// RFC 3986, rejected by Slack, and the same lost-alert bug as
// #13742 if they are ever emitted.
"http:javascript:alert(1)",
"https:alert(1)",
"http:",
"https://",
"https:///path", // authority present but empty
// Raw control characters and spaces break the transport even
// when the scheme and host are fine.
"https://x/ y",
"https://x\ny",
"https://x\ty",
"http://ex.com\u{0}",
] {
assert_eq!(
dispatchable_url(undeliverable),
None,
"would be sent to a strict transport: {undeliverable:?}"
);
}
// Absolute http(s) URLs remain dispatchable, hostile path notwithstanding.
for ok in [
"https://o2.example/short/abc",
"http://o2.example/web/logs",
" https://o2.example/x ", // trimmed, not rejected
"HTTPS://o2.example/x", // scheme match is case-insensitive
] {
assert_eq!(
dispatchable_url(ok),
Some(ok.trim()),
"over-blocked: {ok:?}"
);
}
}
/// #13742: a hostile link URL must never cost the alert its delivery.
///
/// The blocked link is DROPPED from the action list; the message itself —
/// title, body, fields and the legitimate "View in OpenObserve" link —
/// still renders and still sends.
#[test]
fn hostile_link_is_dropped_not_emitted_as_blocked_url() {
let mut c = hostile_content();
c.links = vec![
("click".into(), "javascript:alert(1)".into()),
("Runbook".into(), "https://rb.example/x".into()),
("".into(), "https://o2.example/short/abc".into()),
];
let slack = slack::render_slack(&c, &fixture_ctx());
let elements = slack["attachments"][1]["blocks"][0]["elements"]
.as_array()
.expect("slack actions block");
// The hostile link is gone; the two legitimate ones survive.
assert_eq!(elements.len(), 2, "slack elements: {elements:#?}");
for el in elements {
let url = el["url"].as_str().expect("button url");
assert!(
url.starts_with("https://"),
"slack emitted a non-dispatchable button url: {url:?}"
);
}
let card = teams::render_teams_adaptive_card(&c, &fixture_ctx());
let actions = card["attachments"][0]["content"]["actions"]
.as_array()
.expect("adaptive card actions");
assert_eq!(actions.len(), 2, "adaptive card actions: {actions:#?}");
for a in actions {
let url = a["url"].as_str().expect("action url");
assert!(
url.starts_with("https://"),
"adaptive card emitted a non-dispatchable action url: {url:?}"
);
}
let msg = teams::render_teams_message_card(&c);
let potential = msg["potentialAction"]
.as_array()
.expect("message card actions");
assert_eq!(potential.len(), 2, "message card actions: {potential:#?}");
for a in potential {
let uri = a["targets"][0]["uri"].as_str().expect("target uri");
assert!(
uri.starts_with("https://"),
"message card emitted a non-dispatchable uri: {uri:?}"
);
}
}
/// #13742, config-driven path: an EMPTY `ZO_WEB_URL` makes `alert_url` a
/// root-relative path, which reaches the same strict transports through
/// the always-appended "View in OpenObserve" link — breaking deployments
/// that never authored a hostile template. The action block must not be
/// emitted with an undispatchable URL, and when no link survives, no
/// empty `actions` block may be emitted either (Slack rejects that too).
#[test]
fn relative_alert_url_does_not_produce_an_invalid_action_block() {
let mut ctx = fixture_ctx();
ctx.alert_url = "/web/logs?stream=app&org_identifier=default".into();
let mut c = hostile_content();
c.links = vec![("".into(), ctx.alert_url.clone())];
let slack = slack::render_slack(&c, &ctx);
// Only the content attachment remains: no buttons, no chart.
for att in slack["attachments"].as_array().expect("attachments") {
for block in att["blocks"].as_array().expect("blocks") {
assert_ne!(
block["type"], "actions",
"slack emitted an actions block built from a relative url: {slack:#?}"
);
}
}
let card = teams::render_teams_adaptive_card(&c, &ctx);
assert!(
card["attachments"][0]["content"]["actions"]
.as_array()
.is_none_or(|a| a.is_empty()),
"adaptive card kept a relative action url: {card:#?}"
);
let msg = teams::render_teams_message_card(&c);
assert!(
msg["potentialAction"]
.as_array()
.is_none_or(|a| a.is_empty()),
"message card kept a relative action uri: {msg:#?}"
);
}
/// Email is the deliberate counter-case: `href="#"` is valid, inert HTML,
/// so email must keep BLOCKING (substituting) rather than DROPPING —
/// the reader still sees the link's label and that it was neutralized.
#[test]
fn email_still_substitutes_blocked_url_rather_than_dropping() {
let mut c = hostile_content();
c.links = vec![("evil".into(), "javascript:alert(1)".into())];
let (_, html, text) = email::render_email(&c, &fixture_ctx());
assert!(
html.contains(&format!(r#"href="{BLOCKED_URL}""#)),
"email html should neutralize, not drop"
);
assert!(
text.contains(&format!("evil: {BLOCKED_URL}")),
"email text should neutralize, not drop"
);
}
/// `row_lines` takes precedence over `rows` in EVERY renderer, not just
/// Slack (which `row_lines_take_precedence_over_rows` already pins).
#[test]
@@ -51,16 +51,20 @@ pub fn render_pagerduty(c: &RenderedContent, ctx: &NotificationContext) -> Value
}
custom_details.insert("body".to_string(), Value::String(c.body_markdown.clone()));
// PagerDuty validates `links[].href` server-side and 400s the whole event
// on a malformed one — the same lost-alert failure as #13742 on Slack, so
// an undispatchable link is dropped rather than emitted.
let links: Vec<Value> = c
.links
.iter()
.filter_map(|(label, url)| super::dispatchable_url(url).map(|url| (label, url)))
.map(|(label, url)| {
let text = if label.is_empty() {
super::DEFAULT_LINK_LABEL
} else {
label.as_str()
};
json!({"href": super::safe_url(url), "text": text})
json!({"href": url, "text": text})
})
.collect();
+152 -27
View File
@@ -18,9 +18,9 @@
use serde_json::{Value, json};
use super::{
DEFAULT_LINK_LABEL, clamp,
DEFAULT_LINK_LABEL, clamp, dispatchable_url,
markdown::{escape_mrkdwn, guard_leading_blockquote, markdown_to_mrkdwn},
safe_url, severity_color,
severity_color,
};
use crate::alerts::notifications::{NotificationContext, resolve::RenderedContent};
@@ -39,16 +39,13 @@ pub fn render_slack(c: &RenderedContent, ctx: &NotificationContext) -> Value {
// Datadog-style clickable title: a bold mrkdwn link section that opens
// the alert (user-picked over the plain header in a live A/B on a real
// channel). mrkdwn link syntax breaks on `|` (terminates the label) and
// `>` (terminates the link), so a title containing either — and a
// missing/blocked alert URL — falls back to the plain `header` block.
// The label is escape_mrkdwn'd (entity-encoding `&`/`<`/`>`); the URL is
// scheme-checked via `safe_url`.
let title_url = safe_url(&ctx.alert_url);
if !title_url.is_empty()
&& !title_url.contains(['|', '>', '<'])
&& !c.title.contains('|')
&& title_url != super::BLOCKED_URL
{
// `>` (terminates the link), so a title containing either — and an
// undispatchable alert URL — falls back to the plain `header` block.
// The label is escape_mrkdwn'd (entity-encoding `&`/`<`/`>`); the URL must
// be an absolute http(s) one (`dispatchable_url`), which also rules out the
// blocked placeholder, an empty `ZO_WEB_URL` and relative paths.
let title_url = dispatchable_url(&ctx.alert_url).unwrap_or_default();
if !title_url.is_empty() && !title_url.contains(['|', '>', '<']) && !c.title.contains('|') {
blocks.push(json!({
"type": "section",
"text": {"type": "mrkdwn", "text": format!(
@@ -133,15 +130,26 @@ pub fn render_slack(c: &RenderedContent, ctx: &NotificationContext) -> Value {
// user-picked "full stripe" layout).
let mut tail_blocks: Vec<Value> = Vec::new();
if !c.links.is_empty() {
// Slack validates every button `url` server-side and rejects the WHOLE
// message with `400 invalid_attachments` if one is not an absolute
// http(s) URL — losing the alert entirely (#13742). A link that cannot be
// dispatched is therefore DROPPED here rather than emitted: the alert is
// worth more than the button. This filter runs BEFORE the 5-element cap
// so a dropped link frees its slot for a deliverable one.
let deliverable: Vec<(&String, &str)> = c
.links
.iter()
.filter_map(|(label, url)| dispatchable_url(url).map(|url| (label, url)))
.collect();
if !deliverable.is_empty() {
// The appended "View in OpenObserve" link (empty label, always last —
// resolve.rs) is the notification's primary action: it must survive
// the 5-element cap, so author links only fill the remaining slots.
let default_count = c.links.iter().filter(|(l, _)| l.is_empty()).count();
let default_count = deliverable.iter().filter(|(l, _)| l.is_empty()).count();
let author_cap = MAX_ACTION_ELEMENTS.saturating_sub(default_count.min(MAX_ACTION_ELEMENTS));
let mut authored_kept = 0usize;
let elements: Vec<Value> = c
.links
let elements: Vec<Value> = deliverable
.iter()
.filter(|(label, _)| {
if label.is_empty() {
@@ -162,7 +170,7 @@ pub fn render_slack(c: &RenderedContent, ctx: &NotificationContext) -> Value {
let mut button = json!({
"type": "button",
"text": {"type": "plain_text", "text": clamp(label, 75)},
"url": safe_url(url),
"url": url,
});
// The default "View in OpenObserve" link is the notification's
// primary action — Slack's "primary" button style renders it
@@ -240,17 +248,76 @@ pub fn images_undeliverable(now_secs: u64) -> bool {
at != 0 && now_secs.saturating_sub(at) < IMAGES_RETRY_SECS
}
/// Outcome of stripping a payload's image blocks.
pub struct StrippedPayload {
/// The payload with every `image` block removed.
pub msg: String,
/// Whether any stripped image pointed at OUR `web_url`.
///
/// Only such an image is evidence about *this deployment's* reachability
/// from Slack's proxy. A custom template embedding a third-party image
/// that Slack rejects says nothing about `ZO_WEB_URL`, so it must not
/// trip the process-wide suppression flag or claim that diagnosis in the
/// log — see `mark_images_undeliverable`.
pub had_web_url_image: bool,
}
/// True when `url` sits under `base` — matching at a component boundary, not
/// as a bare prefix.
///
/// `starts_with` alone would treat `https://o2.example.attacker.test/x` as
/// living under `https://o2.example`, mis-attributing someone else's image to
/// this deployment. The character after the base must therefore end the
/// authority (`/`, `?`, `#`) or the URL must be exactly the base.
///
/// Scheme and host are ASCII case-insensitive (RFC 3986), so the comparison is
/// too — otherwise `ZO_WEB_URL=https://O2.example` would never match the chart
/// URL and image suppression would silently never engage.
fn url_has_base(url: &str, base: &str) -> bool {
let base = base.trim_end_matches('/');
// `get` rather than slicing: a multi-byte character straddling
// `base.len()` would panic on a raw index.
let Some(head) = url.get(..base.len()) else {
return false;
};
// Only the scheme+authority is case-insensitive; the path is not. `base`
// is a bare origin in practice, so comparing its whole length is correct.
if !head.eq_ignore_ascii_case(base) {
return false;
}
let rest = &url[base.len()..];
rest.is_empty() || rest.starts_with(['/', '?', '#'])
}
/// Remove every `image` block from a Slack payload — top-level `blocks` and
/// each attachment's `blocks` — dropping any attachment left with no blocks
/// (an empty-`blocks` attachment renders a bare color stripe fragment).
/// Returns `None` when the payload isn't JSON or has no image block, i.e.
/// when stripping cannot change the outcome.
pub fn strip_image_blocks(msg: &str) -> Option<String> {
pub fn strip_image_blocks(msg: &str) -> Option<StrippedPayload> {
strip_image_blocks_with_base(msg, &config::get_config().common.web_url)
}
/// [`strip_image_blocks`] with the `web_url` injected, so tests need no global
/// config. `web_url_base` empty ⇒ no image can be attributed to us.
pub fn strip_image_blocks_with_base(msg: &str, web_url_base: &str) -> Option<StrippedPayload> {
let mut v: Value = serde_json::from_str(msg).ok()?;
let mut removed = false;
let mut had_web_url_image = false;
let mut strip = |blocks: &mut Vec<Value>| {
let before = blocks.len();
blocks.retain(|b| b.get("type").and_then(Value::as_str) != Some("image"));
blocks.retain(|b| {
let is_image = b.get("type").and_then(Value::as_str) == Some("image");
if is_image
&& !web_url_base.is_empty()
&& b.get("image_url")
.and_then(Value::as_str)
.is_some_and(|u| url_has_base(u, web_url_base))
{
had_web_url_image = true;
}
!is_image
});
removed |= blocks.len() != before;
};
if let Some(blocks) = v.get_mut("blocks").and_then(Value::as_array_mut) {
@@ -269,20 +336,29 @@ pub fn strip_image_blocks(msg: &str) -> Option<String> {
.is_none_or(|b| !b.is_empty())
});
}
removed.then(|| v.to_string())
removed.then(|| StrippedPayload {
msg: v.to_string(),
had_web_url_image,
})
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: &str = "https://o2.example";
fn strip(msg: &str) -> Option<StrippedPayload> {
strip_image_blocks_with_base(msg, BASE)
}
#[test]
fn strip_removes_image_blocks_but_keeps_attachments_with_content() {
let msg = r##"{"attachments":[
{"color":"#c00","blocks":[{"type":"section"}]},
{"color":"#c00","blocks":[{"type":"actions"},{"type":"image","image_url":"http://x/c.png","alt_text":"t"}]}
]}"##;
let out = strip_image_blocks(msg).unwrap();
let out = strip(msg).unwrap().msg;
let v: Value = serde_json::from_str(&out).unwrap();
let attachments = v["attachments"].as_array().unwrap();
assert_eq!(attachments.len(), 2, "actions attachment must survive");
@@ -296,7 +372,7 @@ mod tests {
{"color":"#c00","blocks":[{"type":"section"}]},
{"color":"#c00","blocks":[{"type":"image","image_url":"http://x/c.png","alt_text":"t"}]}
]}"##;
let v: Value = serde_json::from_str(&strip_image_blocks(msg).unwrap()).unwrap();
let v: Value = serde_json::from_str(&strip(msg).unwrap().msg).unwrap();
assert_eq!(v["attachments"].as_array().unwrap().len(), 1);
}
@@ -304,17 +380,66 @@ mod tests {
fn strip_handles_top_level_blocks_from_custom_templates() {
let msg =
r#"{"blocks":[{"type":"section"},{"type":"image","image_url":"u","alt_text":"t"}]}"#;
let v: Value = serde_json::from_str(&strip_image_blocks(msg).unwrap()).unwrap();
let v: Value = serde_json::from_str(&strip(msg).unwrap().msg).unwrap();
assert_eq!(v["blocks"].as_array().unwrap().len(), 1);
}
#[test]
fn strip_returns_none_when_nothing_to_do() {
assert!(
strip_image_blocks(r#"{"attachments":[{"blocks":[{"type":"section"}]}]}"#).is_none()
assert!(strip(r#"{"attachments":[{"blocks":[{"type":"section"}]}]}"#).is_none());
assert!(strip("not json").is_none());
assert!(strip(r#"{"text":"plain"}"#).is_none());
}
/// The process-wide chart suppression may only engage on evidence about
/// THIS deployment: an image we generated, pointing at our `web_url`.
///
/// Attributing a third-party image's rejection to `ZO_WEB_URL` would both
/// suppress legitimate charts for an hour across every alert and send the
/// operator to "fix" a `web_url` that was never broken.
#[test]
fn only_our_own_image_url_is_evidence_about_web_url_reachability() {
// Our chart render URL: attributable.
let ours = format!(
r#"{{"attachments":[{{"blocks":[{{"type":"image","image_url":"{BASE}/api/v2/default/alerts/charts/render?d=x","alt_text":"c"}}]}}]}}"#
);
assert!(strip_image_blocks("not json").is_none());
assert!(strip_image_blocks(r#"{"text":"plain"}"#).is_none());
assert!(strip(&ours).unwrap().had_web_url_image);
// A custom template's third-party image: NOT attributable.
let theirs = r#"{"attachments":[{"blocks":[{"type":"image","image_url":"https://cdn.example/logo.png","alt_text":"c"}]}]}"#;
assert!(!strip(theirs).unwrap().had_web_url_image);
// A lookalike host must not be mistaken for ours.
let lookalike = r#"{"attachments":[{"blocks":[{"type":"image","image_url":"https://o2.example.attacker.test/x.png","alt_text":"c"}]}]}"#;
assert!(
!strip(lookalike).unwrap().had_web_url_image,
"prefix match must not be fooled by a lookalike host"
);
// Mixed payload: one of ours among others still counts as evidence.
let mixed = format!(
r#"{{"blocks":[{{"type":"image","image_url":"https://cdn.example/a.png","alt_text":"a"}},{{"type":"image","image_url":"{BASE}/api/v2/x","alt_text":"b"}}]}}"#
);
assert!(strip(&mixed).unwrap().had_web_url_image);
// An unset web_url can attribute nothing to us.
let unset = strip_image_blocks_with_base(&ours, "").unwrap();
assert!(!unset.had_web_url_image);
// Scheme and host are case-insensitive (RFC 3986): a `ZO_WEB_URL`
// written with different casing must still match, or suppression
// would silently never engage on that deployment.
let cased = strip_image_blocks_with_base(&ours, "HTTPS://O2.example").unwrap();
assert!(cased.had_web_url_image, "host comparison must ignore case");
// A trailing slash on the configured base must not break matching.
let slashed = strip_image_blocks_with_base(&ours, &format!("{BASE}/")).unwrap();
assert!(slashed.had_web_url_image);
// A multi-byte character where the base boundary falls must not panic.
let multibyte =
r#"{"blocks":[{"type":"image","image_url":"https://o2.exampleé/x","alt_text":"c"}]}"#;
assert!(!strip(multibyte).unwrap().had_web_url_image);
}
#[test]
@@ -48,8 +48,13 @@ pub fn render_sns(c: &RenderedContent) -> (String, String) {
c.links
.iter()
.map(|(label, url)| {
// Same scheme filter as email's plaintext part: an SNS
// subscription fans out to email/SMS clients that
// linkify bare URLs, so a hostile scheme must not
// survive into the text a human taps.
let url = super::safe_url(url);
if label.is_empty() {
url.clone()
url.to_string()
} else {
format!("{label}: {url}")
}
@@ -20,9 +20,9 @@ use config::meta::alerts::level::AlertLevel;
use serde_json::{Value, json};
use super::{
DEFAULT_LINK_LABEL, clamp,
DEFAULT_LINK_LABEL, clamp, dispatchable_url,
markdown::{escape_html, markdown_to_plaintext},
safe_url, severity_color,
severity_color,
};
use crate::alerts::notifications::{NotificationContext, resolve::RenderedContent};
@@ -116,15 +116,19 @@ pub fn render_teams_adaptive_card(c: &RenderedContent, ctx: &NotificationContext
}));
}
// `Action.OpenUrl` requires an absolute http(s) URL: a relative one or the
// blocked placeholder makes Teams reject the card (the same failure class
// as #13742 on Slack), so an undispatchable link is dropped, not emitted.
let actions: Vec<Value> = c
.links
.iter()
.filter_map(|(label, url)| dispatchable_url(url).map(|url| (label, url)))
.map(|(label, url)| {
let is_default_link = label.is_empty();
let mut action = json!({
"type": "Action.OpenUrl",
"title": if is_default_link { DEFAULT_LINK_LABEL.to_string() } else { card_text(label) },
"url": safe_url(url),
"url": url,
});
// Mirrors the Slack "primary"-style fix: the default
// "View in OpenObserve" action is the card's primary CTA and
@@ -207,14 +211,18 @@ pub fn render_teams_message_card(c: &RenderedContent) -> Value {
);
}
// `OpenUri` targets must be absolute http(s) URIs — the O365 connector
// rejects anything else, so undispatchable links are dropped (see the
// Adaptive Card renderer above and #13742).
let actions: Vec<Value> = c
.links
.iter()
.filter_map(|(label, url)| dispatchable_url(url).map(|url| (label, url)))
.map(|(label, url)| {
json!({
"@type": "OpenUri",
"name": if label.is_empty() { DEFAULT_LINK_LABEL.to_string() } else { escape_html(label) },
"targets": [{"os": "default", "uri": safe_url(url)}],
"targets": [{"os": "default", "uri": url}],
})
})
.collect();
@@ -23,6 +23,23 @@
//! guarantees a parseable document. Entity-encoding them would corrupt the
//! data for the machine consumer.
//!
//! # Contract: `links[].url` is passed through UNFILTERED — deliberate
//!
//! Every human-facing renderer either substitutes a hostile URL scheme
//! ([`safe_url`], for email/SNS) or drops the link outright
//! ([`dispatchable_url`], for Slack/Teams/Discord/PagerDuty). This envelope
//! does NEITHER, for the same reason it does not entity-encode: rewriting a
//! URL to `#` would hand the consumer corrupted data where it expects the
//! authored value, and this is a versioned contract other systems parse.
//!
//! **The consumer owns presentation and MUST treat `links[].url` as
//! untrusted.** Save-time validation (`ContentSpec::validate`) rejects a
//! hostile scheme written literally into a template, but a URL assembled by
//! variable substitution at send time can still carry one here.
//!
//! [`safe_url`]: super::safe_url
//! [`dispatchable_url`]: super::dispatchable_url
//!
//! # Contract: an empty `links[].label` means "no label authored"
//!
//! `resolve` appends the alert URL as a link with an EMPTY label. That empty
@@ -61,6 +61,8 @@ pub enum TestSendError {
},
RenderFailed(String),
DispatchFailed(String),
/// Draft fails the same content validation `templates::save` applies.
InvalidContent(String),
}
impl std::fmt::Display for TestSendError {
@@ -73,6 +75,7 @@ impl std::fmt::Display for TestSendError {
),
Self::RenderFailed(e) => write!(f, "test-send render failed: {e}"),
Self::DispatchFailed(e) => write!(f, "test-send dispatch failed: {e}"),
Self::InvalidContent(e) => write!(f, "{e}"),
}
}
}
@@ -161,6 +164,10 @@ fn build_test_message_with_title(
dest_type: &DestinationType,
spec: &ContentSpec,
) -> Result<(String, RenderedMessage), TestSendError> {
// Hold the draft to the same bar `templates::save` applies, so a
// test-send cannot "succeed" on a template that cannot be saved.
spec.validate().map_err(TestSendError::InvalidContent)?;
let format = derive_channel_format(dest_type);
let ctx: NotificationContext = synthetic_context(None);
let mut content = resolve_content(spec, &ctx, format.channel_family());
@@ -227,6 +234,25 @@ mod tests {
}
}
/// A draft whose link scheme `save` would reject must be rejected here
/// too, or a test-send "succeeds" on a template that cannot be saved.
#[test]
fn test_send_rejects_a_draft_the_save_path_would_reject() {
let mut spec = sample_spec();
spec.links
.push(config::meta::alerts::content_spec::ContentLink {
label: "click".into(),
url: "javascript:alert(1)".into(),
show_when: None,
});
let err = build_test_message(&slack_dest(), &spec)
.expect_err("hostile link built a test message cleanly");
assert!(
err.to_string().contains("click"),
"error should name the offending link, got: {err}"
);
}
#[test]
fn test_send_title_is_test_marked() {
let rendered = build_test_message(&slack_dest(), &sample_spec()).unwrap();
+5 -1
View File
@@ -109,8 +109,12 @@ pub async fn save(
template.kind = resolve_kind(requested_kind, existing_kind, create);
if template.kind == TemplateKind::Content {
ContentSpec::parse(&template.body)
let spec = ContentSpec::parse(&template.body)
.map_err(|e| TemplateError::InvalidContentSpec(e.to_string()))?;
// Reject an unsupported link scheme HERE rather than neutralizing it
// on every send: the author gets a fixable error instead of a
// template whose link silently never works (#13742).
spec.validate().map_err(TemplateError::InvalidContentSpec)?;
} else if let TemplateType::Email { title } = &template.template_type
&& title.is_empty()
{
+151 -1
View File
@@ -75,8 +75,9 @@ const appTabsStub = {
emits: ["update:activeTab"],
};
async function mountComp(props: Record<string, any> = {}) {
async function mountComp(props: Record<string, any> = {}, opts: Record<string, any> = {}) {
const wrapper = mount(AddTemplate, {
...opts,
props: {
template: null,
...props,
@@ -753,3 +754,152 @@ describe("AddTemplate - existing custom template survives a mode-tab round trip"
expect(getForm(w).state.values.body).toBe(LEGACY_BODY);
});
});
// o2-enterprise#2394: clearing the body and saving toasted "Please fill
// required fields" but left the Monaco editor visually unchanged — the user
// was told something was wrong with no indication of WHERE. The schema
// already produces an error on the `body` path; it simply had nowhere to
// render, because a bare Monaco is bridged in via setFieldValue and is not
// an OFormInput with an error slot.
describe("AddTemplate - required-field highlighting on failed save", () => {
it("marks the body editor invalid and shows its message when body is empty", async () => {
const w = await mountComp();
await selectCustomMode(w);
const form = getForm(w);
form.setFieldValue("name", "my-template");
form.setFieldValue("body", "");
await submit(w);
// The save must not have gone through.
expect(form.state.isValid).toBe(false);
// The editor must carry a visible error affordance...
const shell = w.find('[data-test="add-template-body-editor-shell"]');
expect(shell.exists()).toBe(true);
expect(shell.attributes("data-error")).toBe("true");
// ...and a message the user can actually read.
const err = w.find('[data-test="add-template-body-error"]');
expect(err.exists()).toBe(true);
expect(err.text().length).toBeGreaterThan(0);
});
it("clears the body error once a body is typed back in", async () => {
const w = await mountComp();
await selectCustomMode(w);
const form = getForm(w);
form.setFieldValue("name", "my-template");
form.setFieldValue("body", "");
await submit(w);
expect(w.find('[data-test="add-template-body-error"]').exists()).toBe(true);
form.setFieldValue("body", '{"text":"hi"}');
await flushPromises();
expect(w.find('[data-test="add-template-body-error"]').exists()).toBe(false);
expect(w.find('[data-test="add-template-body-editor-shell"]').attributes("data-error")).toBe(
"false",
);
});
it("shows no body error before the first save attempt", async () => {
const w = await mountComp();
await selectCustomMode(w);
expect(w.find('[data-test="add-template-body-error"]').exists()).toBe(false);
});
// The aria wiring must live on the SHELL, not on <QueryEditor>:
// CodeQueryEditor has `inheritAttrs: false` and binds $attrs to a
// non-focusable wrapper, so attributes passed to the component are inert
// and would be false assurance for a screen-reader user.
it("exposes the error to assistive tech on the shell, not the inert editor", async () => {
const w = await mountComp();
await selectCustomMode(w);
const form = getForm(w);
form.setFieldValue("name", "my-template");
form.setFieldValue("body", "");
await submit(w);
const shell = w.find('[data-test="add-template-body-editor-shell"]');
expect(shell.attributes("aria-invalid")).toBe("true");
// ...and the described-by must resolve to a node that actually exists.
const describedBy = shell.attributes("aria-describedby");
expect(describedBy).toBeTruthy();
const target = w.find(`#${describedBy}`);
expect(target.exists()).toBe(true);
expect(target.attributes("role")).toBe("alert");
});
});
// The issue also names the title field. Email templates require a subject;
// unlike body it IS an OFormInput, so it should already highlight — pin that
// so the two required fields behave consistently.
describe("AddTemplate - email title highlighting", () => {
it("marks the email title input invalid when it is empty on save", async () => {
const w = await mountComp();
await selectCustomMode(w);
const form = getForm(w);
form.setFieldValue("type", "email");
form.setFieldValue("name", "my-template");
form.setFieldValue("body", "hello");
form.setFieldValue("title", "");
await submit(w);
expect(form.state.isValid).toBe(false);
const err = w.find('[data-test="add-template-email-title-input-error"]');
expect(err.exists()).toBe(true);
expect(err.text().length).toBeGreaterThan(0);
});
});
// A rejected save must SHOW the user what is wrong. On this form the offending
// field is often scrolled out of view (the body editor is tall), so the toast
// alone left them hunting. The first invalid field is scrolled into view.
describe("AddTemplate - scrolls the first error into view on failed save", () => {
it("scrolls to an invalid field when save is rejected", async () => {
const scrollSpy = vi.fn();
(Element.prototype as any).scrollIntoView = scrollSpy;
// `attachTo: document.body` is REQUIRED: scrollToFirstError queries the
// real document, and a detached wrapper would make it find nothing —
// the test would fail while the app works.
const w = await mountComp({}, { attachTo: document.body });
await selectCustomMode(w);
// Leave `name` empty so the schema rejects the submit.
const form = getForm(w);
form.setFieldValue("name", "");
form.setFieldValue("body", "");
// Click the real Save button: the scroll lives in handleSave, so calling
// form.handleSubmit() directly would bypass exactly what we are testing.
// Invoke the button's own handler: `trigger("click")` on the OButton
// wrapper does not reliably reach the inner <button>, and the behaviour
// under test is handleSave's, not Vue's event plumbing.
await (w.vm as any).handleSave();
await flushPromises();
await nextTick();
expect(form.state.isValid).toBe(false);
expect(scrollSpy).toHaveBeenCalled();
});
it("does not scroll when the form is valid", async () => {
const scrollSpy = vi.fn();
(Element.prototype as any).scrollIntoView = scrollSpy;
const w = await mountComp({}, { attachTo: document.body });
await selectCustomMode(w);
const form = getForm(w);
form.setFieldValue("name", "valid-name");
form.setFieldValue("body", '{"text":"hi"}');
await (w.vm as any).handleSave();
await flushPromises();
await nextTick();
expect(scrollSpy).not.toHaveBeenCalled();
});
});
+89 -9
View File
@@ -126,15 +126,51 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
without this the editor keeps its mount-time language and paints
a markdown body with JSON errors (pre-migration got the remount
for free from two v-if/v-else editors). -->
<QueryEditor
:key="bodyLanguage"
data-test="template-body-editor"
editor-id="template-body-editor"
class="rounded-default border-card-glass-border mb-3 min-h-77.5! w-full resize-y overflow-auto border"
:language="bodyLanguage"
:query="body"
@update:query="onBodyChange"
/>
<!-- The editor is a bare Monaco bridged into the form via
setFieldValue, so it has no OFormInput error slot of its
own. Mirror one here: a red border plus a message, driven
by the `body` field's own validation state, so a rejected
save points AT the offending field (o2-enterprise#2394). -->
<!-- `aria-*` goes on this shell, NOT on <QueryEditor>:
CodeQueryEditor sets `inheritAttrs: false` and binds
`$attrs` to a non-focusable wrapper, while Monaco builds
its real textarea in a different child — so attributes
passed to the component would be inert. A `group` with an
accessible name and a described-by is what actually
survives; the message below also carries `role="alert"`,
so it is announced the moment a rejected save renders it. -->
<div
data-test="add-template-body-editor-shell"
role="group"
:aria-label="t('alert_templates.body')"
:aria-invalid="bodyError ? 'true' : undefined"
:aria-describedby="bodyError ? 'add-template-body-error' : undefined"
:data-error="String(!!bodyError)"
:class="[
'rounded-default mb-1 w-full overflow-hidden border',
bodyError ? 'border-input-border-error' : 'border-card-glass-border',
]"
>
<QueryEditor
:key="bodyLanguage"
data-test="template-body-editor"
editor-id="template-body-editor"
class="min-h-77.5! w-full resize-y overflow-auto"
:language="bodyLanguage"
:query="body"
@update:query="onBodyChange"
/>
</div>
<div
v-if="bodyError"
id="add-template-body-error"
data-test="add-template-body-error"
class="text-input-error-text mb-3 text-xs"
role="alert"
>
{{ bodyError }}
</div>
<div v-else class="mb-3"></div>
</div>
</template>
</div>
@@ -258,6 +294,8 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import { firstFieldError } from "@/lib/forms/Form/fieldError";
import { scrollToFirstError } from "@/lib/forms/Form/scrollToFirstError";
import type { TemplateData } from "@/ts/interfaces/index";
import { useRouter } from "vue-router";
import AppTabs from "@/components/common/AppTabs.vue";
@@ -281,6 +319,8 @@ import {
import ContentTemplateForm from "./template-content/ContentTemplateForm.vue";
import {
emptyContentSpec,
linkUrlBadScheme,
NOT_A_URL,
starterContentSpec,
parseContentSpec,
serializeContentSpec,
@@ -326,6 +366,16 @@ const templateType = form.useStore((s: any) => s.values.type as "http" | "email"
const body = form.useStore((s: any) => (s.values.body as string) ?? "");
const bodyLanguage = computed(() => (templateType.value === "email" ? "markdown" : "json"));
// The body is a bare Monaco, not an OFormInput, so nothing renders its
// validation error. Read the SAME field state OFormInput reads
// (`meta.errors`) and drive a hand-rolled error affordance from it, so an
// empty body highlights the editor instead of only toasting
// (o2-enterprise#2394). Validation timing is submit-then-change, so this
// stays empty until the first save attempt and clears as soon as it is fixed.
const bodyError = form.useStore((s: any) =>
firstFieldError(s.fieldMeta?.body?.errors as readonly unknown[] | undefined),
);
// app-tabs is a UI toggle whose value is the schema discriminator (not an
// <input>) → bridge it into the form (sanctioned Rule-② bridge).
const onTypeChange = (value: unknown) => {
@@ -488,6 +538,10 @@ const handleSave = async () => {
message: t("common.fillRequiredFields"),
timeout: 1500,
});
// The offending field is often scrolled off-screen on this form (the body
// editor is tall), so the toast alone left the user hunting for what was
// wrong. Bring the first invalid field into view and focus it.
await scrollToFirstError();
}
};
@@ -623,6 +677,32 @@ async function saveTemplate(value: AddTemplateForm) {
// here) so the check never applies to it.
if (!isContentMode && value.type !== "email" && !isTemplateBodyValid(value.body)) return;
// Content mode: don't dispatch a save the API will reject with a 400 whose
// message is far from the offending field. The inline error under the link
// input already says what is wrong (#13742); this stops the round trip and
// points at the first bad link.
if (isContentMode) {
const badLink = contentSpec.value.links
.map((l) => ({ link: l, scheme: linkUrlBadScheme(l.url) }))
.find((c) => c.scheme);
if (badLink?.scheme) {
toast({
variant: "error",
message: t("alerts.validation.linkUrlInvalid", {
label: badLink.link.label || t("alert_templates.linkUrl"),
reason:
badLink.scheme === NOT_A_URL
? t("alerts.validation.linkUrlNotAUrl")
: t("alerts.validation.linkUrlUnsupportedScheme", {
scheme: badLink.scheme,
}),
}),
});
await scrollToFirstError();
return;
}
}
const dismiss = toast({
variant: "loading",
message: t("common.pleaseWait"),
@@ -17,7 +17,7 @@ import { describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import ContentFieldsEditor from "./ContentFieldsEditor.vue";
import type { ContentField } from "./contentSpec";
import type { ContentField, ContentLink } from "./contentSpec";
function mountEditor(rows: ContentField[]) {
return mount(ContentFieldsEditor, {
@@ -107,4 +107,52 @@ describe("ContentFieldsEditor", () => {
const next = w.emitted("update:rows")!.at(-1)![0] as ContentField[];
expect(next[0].show_when).toBeNull();
});
// #13742: a hostile link URL is rejected by the API on save. Surfacing it
// inline means the author fixes the right field instead of reading a
// whole-form 400.
describe("link URL validation (valueKey=url)", () => {
function mountLinks(rows: ContentLink[]) {
return mount(ContentFieldsEditor, {
props: {
rows,
valueKey: "url",
labelLabel: "Label",
valueLabel: "URL",
addLabel: "Add link",
dataTestPrefix: "test-links",
"onUpdate:rows": vi.fn(),
},
global: { plugins: [i18n] },
});
}
it("shows an inline error on a link with an unsupported scheme", () => {
const w = mountLinks([{ label: "click", url: "javascript:alert(1)" }]);
const err = w.find('[data-test="test-links-row-0-value-input-error"]');
expect(err.exists()).toBe(true);
expect(err.text()).toContain("javascript");
});
it("shows no error for legitimate, templated or relative URLs", () => {
const w = mountLinks([
{ label: "a", url: "https://runbook.example/x" },
{ label: "b", url: "{alert_url}" },
{ label: "c", url: "/web/logs" },
{ label: "d", url: "" },
]);
for (const i of [0, 1, 2, 3]) {
expect(
w.find(`[data-test="test-links-row-${i}-value-input-error"]`).exists(),
`row ${i} should have no error`,
).toBe(false);
}
});
it("never flags a field value, which has no scheme semantics", () => {
// The same component with valueKey="value" must stay unvalidated.
const w = mountEditor([{ label: "x", value: "javascript:alert(1)" }]);
expect(w.find('[data-test="test-fields-row-0-value-input-error"]').exists()).toBe(false);
});
});
});
@@ -65,6 +65,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
valueKey === 'value' ? (row as ContentField).value : (row as ContentLink).url
"
:placeholder="valuePlaceholder"
:error="!!valueErrorFor(row)"
:error-message="valueErrorFor(row) ?? undefined"
:data-test="`${dataTestPrefix}-row-${index}-value-input`"
@update:model-value="(v) => updateRow(index, valueKey, String(v ?? ''))"
/>
@@ -128,7 +130,7 @@ import { useI18nTyped, type I18nText } from "@/types/i18n";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import type { ContentField, ContentLink } from "./contentSpec";
import { linkUrlBadScheme, NOT_A_URL, type ContentField, type ContentLink } from "./contentSpec";
type RowKind = ContentField | ContentLink;
@@ -150,6 +152,19 @@ const emit = defineEmits<{
const { t } = useI18nTyped();
/**
* Inline URL validation applies only to the links instance — a field's `value`
* is arbitrary text with no scheme semantics. The validator returns the bad
* SCHEME, not a message, so the user-facing copy is translated here.
*/
function valueErrorFor(row: RowKind): I18nText | undefined {
if (props.valueKey !== "url") return undefined;
const scheme = linkUrlBadScheme((row as ContentLink).url ?? "");
if (!scheme) return undefined;
if (scheme === NOT_A_URL) return t("alerts.validation.linkUrlNotAUrl");
return t("alerts.validation.linkUrlUnsupportedScheme", { scheme });
}
// Values are the wire strings the backend's AlertLevel serializes to; the
// labels a user reads come from i18n, never the wire vocabulary.
const severityOptions = computed(() => [
@@ -16,6 +16,8 @@
import { describe, expect, it } from "vitest";
import {
emptyContentSpec,
linkUrlBadScheme,
NOT_A_URL,
starterContentSpec,
hasOptionalContent,
parseContentSpec,
@@ -216,4 +218,93 @@ describe("contentSpec", () => {
expect(hasOptionalContent(starterContentSpec())).toBe(true);
});
});
// Mirrors the backend allowlist in
// src/config/src/meta/alerts/content_spec.rs — these cases are kept in sync
// deliberately so the inline error and the API's 400 agree. The backend
// remains the enforcing layer; this only moves the message next to the field.
describe("linkUrlBadScheme", () => {
it("rejects active-content schemes, including blocklist bypasses", () => {
for (const hostile of [
"javascript:alert(1)",
"JavaScript:alert(1)",
" javascript:alert(1)",
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"\0javascript:alert(1)",
"java%73cript:alert(1)",
"vbscript:msgbox(1)",
"file:///etc/passwd",
"data:text/html,<script>1",
// A variable elsewhere does not excuse a literal hostile scheme.
"{x}javascript:alert(1)",
// Trailing whitespace must not smuggle a scheme past the check.
"javascript:alert(1) ",
]) {
expect(linkUrlBadScheme(hostile), `accepted: ${hostile}`).toBeTruthy();
}
});
// A link field holds a URL. These cannot execute anything (no scheme), but
// they are not links either — accepting them means the author finds out
// the link is dead when an alert fires.
it("rejects values that are not URLs at all", () => {
for (const junk of [
"javascript(0)",
"not a url at all",
"foo",
"runbook.example.com/x", // bare host: ambiguous, require a scheme
// An allowlisted scheme with NO HOST. Per the WHATWG URL Standard an
// empty host is a parse FAILURE for a special scheme (http/https).
"http:",
"http://",
"https://",
"http:///",
"http://?q=1",
"http://#frag",
// Hosts that parse but cannot resolve to anything real.
"https://.",
"http://..",
"https://-",
// `mailto:` needs a real mailbox; the parser accepts any opaque path.
"mailto:",
"mailto:foo",
"mailto:@",
"mailto:a@",
"mailto:@b.com",
"{x} not a url",
]) {
expect(linkUrlBadScheme(junk), `accepted: ${junk}`).toBe(NOT_A_URL);
}
});
it("accepts legitimate, templated and relative URLs", () => {
for (const ok of [
"https://runbook.example/x",
"http://runbook.example/x",
"mailto:oncall@example.com",
"https://runbook.example/{stream_name}",
"{alert_url}",
"{alert_url}&tab=logs",
"{scheme}://host/x", // variable IN scheme position
"/web/logs?stream=app",
"HTTPS://runbook.example/x", // schemes are case-insensitive
"MAILTO:oncall@example.com",
" https://runbook.example/x ", // surrounding whitespace is trimmed
"https://o2.example:8443/web/logs?a=1#f",
"http://localhost:5080/web/logs",
"https://user:pass@host.example/x", // userinfo is legal
"https://[::1]:8080/x", // IPv6 literal
"https://192.168.1.1/x", // IPv4 literal
// U+0085 (NEL) is Unicode White_Space, so Rust's `.trim()` strips it
// and the backend accepts this. JS `.trim()` does NOT strip NEL, so
// without an explicit class here the UI would flag a URL the API
// saves happily — the frontend must not be stricter than the server.
`${String.fromCharCode(0x85)}https://runbook.example/x`,
"", // an empty row is not an error — it is simply not filled in yet
]) {
expect(linkUrlBadScheme(ok), `rejected: ${ok}`).toBeNull();
}
});
});
});
@@ -171,6 +171,104 @@ export function hasOptionalContent(spec: ContentSpec): boolean {
);
}
/**
* Schemes a link URL may carry, mirroring the backend allowlist in
* `src/config/src/meta/alerts/content_spec.rs`.
*/
const ALLOWED_LINK_SCHEMES = ["http", "https", "mailto"];
/**
* Sentinel returned by {@link linkUrlBadScheme} when the value is not a URL at
* all (a bare word like `foo`), as opposed to a URL carrying a disallowed
* scheme. The caller picks a different message for each.
*/
export const NOT_A_URL = "\u0000not-a-url";
/**
* The offending scheme when a link URL is unacceptable, or null when it is
* fine. Returns the SCHEME rather than a message so the caller can translate:
* user-facing copy is `I18nText`, and baking English in here would leak it
* into every non-English org.
*
* This is a UX affordance, NOT the security boundary — the backend rejects the
* same URLs on save (HTTP 400) and the renderers drop undeliverable ones at
* send time. Its job is to put the message next to the offending field instead
* of surfacing a whole-form API error.
*
* An allowlist rather than a blocklist, matching the backend: `java\tscript:`,
* `\0javascript:`, `java%73cript:` and `vbscript:` all slip past a naive
* `startsWith("javascript:")` while a browser still dispatches them.
*
* Deliberately permitted, because the backend permits them too:
* - empty — an unfilled row is incomplete, not invalid
* - `{alert_url}` — link URLs are templates; the scheme may only exist after variable substitution
* - `/path`, `?q=1`, `#frag` — relative URLs are inert and valid in email
*/
export function linkUrlBadScheme(url: string): string | null {
// Rust's `str::trim` strips every Unicode `White_Space` char; JS `.trim()`
// strips `WhiteSpace` + `LineTerminator`, which OMITS U+0085 (NEL). Without
// the explicit NEL class a URL carrying one would be flagged here and
// accepted by the backend. Keep in step with `link_scheme_is_allowed`.
const trimmed = url.replace(/^[\s\u0085]+|[\s\u0085]+$/g, "");
// Not filled in yet - incomplete, not invalid.
if (trimmed === "") return null; // Whitespace and control characters are never valid inside a URL. Tested by
// codepoint rather than a regex: a control-character class in a literal
// trips `no-control-regex`, and these are exactly what must be rejected.
for (const ch of trimmed) {
const c = ch.codePointAt(0)!;
if (c <= 0x20 || c === 0x7f) return NOT_A_URL;
}
// A ":" only introduces a scheme when it precedes any path/query/fragment
// marker (RFC 3986), so "/a:b" is a scheme-less path, not a scheme.
const boundary = trimmed.search(/[:/?#]/);
if (boundary === -1 || trimmed[boundary] !== ":") {
// No scheme: accept a root-relative reference, or a template whose scheme
// only materializes after substitution. A bare word ("foo",
// "javascript(0)") is a typo, not a link - see the Rust twin.
if (/^[/?#]/.test(trimmed)) return null;
return /^\{[^}]+\}/.test(trimmed) ? null : NOT_A_URL;
}
const rawScheme = trimmed.slice(0, boundary);
// A scheme that is ENTIRELY a variable has nothing to judge yet, but
// "{x}javascript:" must still be rejected - see the Rust twin.
if (rawScheme.startsWith("{") && rawScheme.endsWith("}") && !rawScheme.slice(1).includes("{")) {
return null;
}
const scheme = rawScheme.toLowerCase();
if (!ALLOWED_LINK_SCHEMES.includes(scheme)) return scheme;
// An allowlisted scheme is necessary but NOT sufficient: `http:` and
// `https://` carry one and are still not URLs (an empty host is a parse
// FAILURE for a special scheme per the WHATWG URL Standard). Delegate to
// the platform's conformant parser instead of hand-rolling it.
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return NOT_A_URL;
}
if (scheme === "mailto") {
// The parser accepts ANY opaque path after `mailto:`, including an empty
// one, so the mailbox shape is checked here.
const at = parsed.pathname.indexOf("@");
if (at <= 0) return NOT_A_URL;
const domain = parsed.pathname.slice(at + 1);
return domain.includes(".") && !domain.startsWith(".") ? null : NOT_A_URL;
}
// http(s): reject hosts that parse but can never resolve (".", "..", "-").
const host = parsed.hostname;
const hostOk =
host.length > 0 && /[a-z0-9]/i.test(host) && !/^[.-]/.test(host) && !/[.-]$/.test(host);
return hostOk ? null : NOT_A_URL;
}
export function serializeContentSpec(spec: ContentSpec): string {
// Trim only the body's leading/trailing whitespace on save — not on every
// keystroke or blur, so the live editor (blank lines, toolbar cursor
@@ -0,0 +1,50 @@
// 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 { describe, expect, it } from "vitest";
import { crossLinkUrlIsValid } from "./CrossLinkDialog.schema";
// A cross-link URL is handed to window.open for EVERY viewer of the stream,
// and previously the only rule was `min(1)`.
describe("crossLinkUrlIsValid", () => {
it("accepts http(s) templates with ${field} placeholders", () => {
for (const ok of [
"https://example.com/trace/${field.__value}",
"https://example.com/x?from=${start_time}&to=${end_time}",
"http://localhost:5080/web/logs",
"https://example.com",
]) {
expect(crossLinkUrlIsValid(ok), `rejected: ${ok}`).toBe(true);
}
});
it("rejects hostile schemes", () => {
for (const bad of [
"javascript:alert(1)",
"javascript:alert(document.cookie)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"vbscript:msgbox(1)",
]) {
expect(crossLinkUrlIsValid(bad), `accepted: ${bad}`).toBe(false);
}
});
it("rejects values that are not URLs", () => {
for (const bad of ["foo", "not a url", "http:", "http://", "https://."]) {
expect(crossLinkUrlIsValid(bad), `accepted: ${bad}`).toBe(false);
}
});
});
@@ -8,10 +8,28 @@
import { z } from "zod";
import { isSafeNavigableUrl } from "@/utils/safeUrl";
/**
* A cross-link URL is a TEMPLATE containing `${field.__value}` placeholders,
* so it is validated with the placeholders stripped to a harmless token — the
* shape around them still has to be a real http(s) URL.
*
* Previously the only rule was `min(1)`, so `javascript:alert(1)` saved
* cleanly and was handed to `window.open` for every viewer of the stream.
*/
export function crossLinkUrlIsValid(url: string): boolean {
const withoutPlaceholders = url.replace(/\$\{[^}]*\}/g, "x");
return isSafeNavigableUrl(withoutPlaceholders);
}
export const makeCrossLinkDialogSchema = (t: (_key: string) => string) =>
z.object({
name: z.string().min(1, t("crossLinks.nameRequired")),
url: z.string().min(1, t("crossLinks.urlRequired")),
url: z
.string()
.min(1, t("crossLinks.urlRequired"))
.refine(crossLinkUrlIsValid, { message: t("crossLinks.urlInvalid") }),
fields: z.array(z.object({ name: z.string() })).default([]),
// Chip-builder scratch input that stages a field name before it's committed
// as a chip. NOT part of the emit payload; form-owned + optional.
@@ -0,0 +1,50 @@
// 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 { describe, expect, it } from "vitest";
import { drilldownUrlIsValid } from "./DrilldownPopUp.schema";
describe("drilldownUrlIsValid", () => {
it("accepts http(s) templates with ${variable} placeholders", () => {
for (const ok of [
"https://example.com/d/${value}",
"https://example.com/x?a=${row.field}",
"http://localhost:5080/web/logs",
]) {
expect(drilldownUrlIsValid(ok), `rejected: ${ok}`).toBe(true);
}
});
// The previous regex named these schemes as ALLOWED. `window.open` is called
// on this value, so data:/file: are navigable targets, not inert text.
it("rejects schemes the old protocol regex explicitly allowed", () => {
for (const bad of [
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
"file:///etc/passwd",
"telnet://host",
"ws://host/x",
"wss://host/x",
"ftp://host/x",
]) {
expect(drilldownUrlIsValid(bad), `accepted: ${bad}`).toBe(false);
}
});
it("rejects hostile schemes and non-URLs", () => {
for (const bad of ["javascript:alert(1)", "foo", "http://", "https://-"]) {
expect(drilldownUrlIsValid(bad), `accepted: ${bad}`).toBe(false);
}
});
});
@@ -20,8 +20,22 @@
import { z } from "zod";
// Protocol-only URL check.
const URL_PROTOCOL_REGEX = /^(http|https|ftp|file|mailto|telnet|data|ws|wss):\/\//;
import { isSafeNavigableUrl } from "@/utils/safeUrl";
/**
* A drilldown URL is a TEMPLATE containing `${variable}` placeholders, so it
* is validated with the placeholders stripped to a harmless token — the shape
* around them still has to be a real http(s) URL.
*
* This replaces a scheme-prefix regex that allowed `ftp`, `file`, `telnet`,
* `data`, `ws` and `wss` by name. `data:` and `file:` are navigable targets
* (`usePanelDrilldown` calls `window.open` on this value), and the regex also
* passed degenerate values like `http://` that are not URLs at all.
*/
export function drilldownUrlIsValid(url: string): boolean {
const withoutPlaceholders = url.replace(/\$\{[^}]*\}/g, "x");
return isSafeNavigableUrl(withoutPlaceholders);
}
// Array row — kept loose (no per-row rules).
export const drilldownVariableRowSchema = z.object({
@@ -73,7 +87,7 @@ export const makeDrilldownPopUpSchema = (t: (_key: string) => string) =>
path: ["data", "url"],
message: t("dashboard.urlRequired"),
});
} else if (!URL_PROTOCOL_REGEX.test(url)) {
} else if (!drilldownUrlIsValid(url)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["data", "url"],
@@ -20,6 +20,7 @@ import { getUTCTimestampFromZonedTimestamp } from "@/utils/dashboard/dateTimeUti
import { normalizeVariableSyntax } from "@/utils/dashboard/variables/variablesUtils";
import searchService from "@/services/search";
import { isCrossLinkingEnabledForStream } from "@/utils/crossLinking";
import { isSafeNavigableUrl } from "@/utils/safeUrl";
export function usePanelDrilldown({
panelSchema,
@@ -932,10 +933,17 @@ export function usePanelDrilldown({
// if drilldown by url
if (drilldownData.type == "byUrl") {
try {
// Guard the RESOLVED url. The form schema rejects a hostile one at
// save time, but that is client-side only: a direct dashboard PUT
// stores anything, and dashboards saved before the schema existed
// are still in the DB. A variable VALUE can also carry a scheme.
const resolved = replacePlaceholders(drilldownData.data.url, drilldownVariables);
if (!isSafeNavigableUrl(resolved)) return;
// open url
return window.open(
replacePlaceholders(drilldownData.data.url, drilldownVariables),
resolved,
drilldownData.targetBlank ? "_blank" : "_self",
"noopener,noreferrer",
);
} catch {
/* ignore: best-effort window.open */
@@ -0,0 +1,107 @@
// 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 { beforeEach, describe, expect, it, vi } from "vitest";
import { scrollToFirstError } from "./scrollToFirstError";
describe("scrollToFirstError", () => {
beforeEach(() => {
document.body.innerHTML = "";
// A single prototype stub is shared by every element, so "not called"
// could never distinguish them — give each element its own spy.
(Element.prototype as any).scrollIntoView = function () {};
});
function field(id: string, invalid: boolean) {
const el = document.createElement("input");
el.id = id;
if (invalid) el.setAttribute("aria-invalid", "true");
el.scrollIntoView = vi.fn();
document.body.appendChild(el);
return el;
}
it("scrolls the FIRST invalid control into view, not a later one", async () => {
field("ok", false);
const first = field("bad-1", true);
const second = field("bad-2", true);
const scrolled = await scrollToFirstError();
expect(scrolled).toBe(true);
expect(first.scrollIntoView).toHaveBeenCalled();
expect(second.scrollIntoView).not.toHaveBeenCalled();
});
it("focuses the field so the user can start typing the fix", async () => {
const el = field("bad", true);
const focus = vi.spyOn(el, "focus");
await scrollToFirstError();
expect(focus).toHaveBeenCalled();
});
it("returns false and does nothing when no field is invalid", async () => {
const el = field("ok", false);
expect(await scrollToFirstError()).toBe(false);
expect(el.scrollIntoView).not.toHaveBeenCalled();
});
it('ignores aria-invalid="false" — only true marks an error', async () => {
const el = field("ok", false);
el.setAttribute("aria-invalid", "false");
expect(await scrollToFirstError()).toBe(false);
expect(el.scrollIntoView).not.toHaveBeenCalled();
});
it("falls back to a non-focusable error container when no control is invalid", async () => {
// The Monaco body editor is not a focusable control; its error state lives
// on a wrapper (o2-enterprise#2394), which must still be scrolled to.
const shell = document.createElement("div");
shell.setAttribute("data-error", "true");
shell.scrollIntoView = vi.fn();
document.body.appendChild(shell);
expect(await scrollToFirstError()).toBe(true);
expect(shell.scrollIntoView).toHaveBeenCalled();
});
it("prefers a real invalid control over a data-error container", async () => {
const shell = document.createElement("div");
shell.setAttribute("data-error", "true");
shell.scrollIntoView = vi.fn();
document.body.appendChild(shell);
const input = field("bad", true);
await scrollToFirstError();
expect(input.scrollIntoView).toHaveBeenCalled();
expect(shell.scrollIntoView).not.toHaveBeenCalled();
});
it("searches only within the given root when one is passed", async () => {
const outside = field("outside-bad", true);
const root = document.createElement("div");
const inside = document.createElement("input");
inside.setAttribute("aria-invalid", "true");
inside.scrollIntoView = vi.fn();
root.appendChild(inside);
document.body.appendChild(root);
await scrollToFirstError(root);
expect(inside.scrollIntoView).toHaveBeenCalled();
expect(outside.scrollIntoView).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,57 @@
// 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 { nextTick } from "vue";
/**
* Bring the first invalid field into view after a rejected submit.
*
* On a long form the failing field is often scrolled off-screen, so the user
* gets "Please fill required fields" with nothing visibly wrong — they cannot
* tell WHICH field, or even that the page has more of it above. Scrolling the
* offender into view and focusing it turns a dead-end toast into an actionable
* one.
*
* Selection order:
*
* 1. `[aria-invalid="true"]` — every `OInput`-based control sets this, so it covers all standard
* fields and is framework-agnostic (no per-form field registry to keep in sync).
* 2. `[data-error="true"]` — a non-focusable wrapper for controls that are not real form elements,
* e.g. the Monaco body editor (o2-enterprise#2394), which has no focusable node of its own to
* mark.
*
* Awaits `nextTick` first: validation state is applied during the same tick as
* the submit, so querying before the DOM updates would find nothing.
*
* @param root Limit the search to this subtree. Defaults to the whole document.
* @returns whether a field was found and scrolled to.
*/
export async function scrollToFirstError(root?: ParentNode): Promise<boolean> {
await nextTick();
const scope = root ?? document;
const target =
scope.querySelector<HTMLElement>('[aria-invalid="true"]') ??
scope.querySelector<HTMLElement>('[data-error="true"]');
if (!target) return false;
target.scrollIntoView({ behavior: "smooth", block: "center" });
// Focus so the user can start typing the fix immediately. Guarded: the
// fallback target is a wrapper div, which is not focusable.
target.focus?.({ preventScroll: true });
return true;
}
+37
View File
@@ -45,6 +45,43 @@ describe("OInput", () => {
expect(wrapper.text()).toContain("Required field");
});
it("points aria-describedby at the error message so it is announced", () => {
wrapper = mount(OInput, { props: { error: true, errorMessage: "Required field" } });
const input = wrapper.find("input");
const describedBy = input.attributes("aria-describedby");
expect(describedBy).toBeTruthy();
expect(input.attributes("aria-invalid")).toBe("true");
// The referenced node must actually exist and carry the message —
// a dangling aria-describedby announces nothing.
const target = wrapper.find(`#${describedBy}`);
expect(target.exists()).toBe(true);
expect(target.text()).toContain("Required field");
});
it("sets no aria-describedby when there is no error to describe", () => {
wrapper = mount(OInput, { props: { errorMessage: "Required field" } });
expect(wrapper.find("input").attributes("aria-describedby")).toBeUndefined();
});
it("sets no dangling aria-describedby when the message is rendered elsewhere", () => {
// `error` with no `errorMessage` yields " " — OFormInput's #error slot owns
// the text, so this component renders no message node to point at.
wrapper = mount(OInput, { props: { error: true } });
expect(wrapper.find("input").attributes("aria-describedby")).toBeUndefined();
expect(wrapper.find("input").attributes("aria-invalid")).toBe("true");
});
it("describes the textarea variant too", () => {
wrapper = mount(OInput, {
props: { type: "textarea", error: true, errorMessage: "Too long" },
});
const ta = wrapper.find("textarea");
expect(ta.attributes("aria-invalid")).toBe("true");
const describedBy = ta.attributes("aria-describedby");
expect(describedBy).toBeTruthy();
expect(wrapper.find(`#${describedBy}`).text()).toContain("Too long");
});
it("shows helpText when provided", () => {
wrapper = mount(OInput, { props: { helpText: "Enter your name" } });
expect(wrapper.text()).toContain("Enter your name");
+14
View File
@@ -70,6 +70,16 @@ const effectiveError = computed(() => {
});
const hasError = computed(() => !!effectiveError.value);
// `aria-invalid` alone tells a screen reader THAT the field is wrong but never
// WHAT is wrong. Point the control at the message element so the reason is
// announced with the field. Only set when a message is actually rendered —
// `effectiveError` is " " when the text lives in OFormInput's #error slot, and
// a dangling `aria-describedby` would reference a node that does not exist.
const errorId = computed(() => `${inputId.value}-error`);
const describedBy = computed(() =>
effectiveError.value && effectiveError.value.trim() ? errorId.value : undefined,
);
const isTextarea = computed(() => props.type === "textarea");
// ── Width ──────────────────────────────────────────────────────────────────
@@ -298,6 +308,8 @@ const wrapperClasses = computed(() => [
:disabled="disabled"
:readonly="readonly"
:aria-required="required || undefined"
:aria-invalid="hasError || undefined"
:aria-describedby="describedBy"
:autofocus="autofocus"
:maxlength="maxlength"
:rows="autogrow ? 1 : rows"
@@ -341,6 +353,7 @@ const wrapperClasses = computed(() => [
:autocomplete="autocomplete"
:tabindex="inputTabindex"
:aria-invalid="hasError || undefined"
:aria-describedby="describedBy"
:class="[
'min-w-0 flex-1 rounded-[inherit] bg-transparent outline-none',
'text-input-text placeholder:text-input-placeholder',
@@ -413,6 +426,7 @@ const wrapperClasses = computed(() => [
>
<span
v-if="effectiveError && effectiveError.trim()"
:id="errorId"
:data-test="parentDataTest ? `${parentDataTest}-error` : undefined"
:data-test-error-text="effectiveError"
class="text-input-error-text text-xs leading-none"
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Ungültige VRL-Funktion",
"invalidSqlQuery": "Ungültige SQL-Abfrage",
"errorFetchingStreams": "Fehler beim Abrufen der Streams",
"sqlValidationError": "SQL-Validierungsfehler: {error}"
"sqlValidationError": "SQL-Validierungsfehler: {error}",
"linkUrlUnsupportedScheme": "Nicht unterstütztes URL-Schema \"{scheme}\". Verwenden Sie http, https oder mailto — ein relativer Pfad oder eine {'{'}Variable{'}'} funktioniert ebenfalls.",
"linkUrlInvalid": "Link \"{label}\": {reason}",
"linkUrlNotAUrl": "Geben Sie eine gültige URL ein — z. B. https://example.com/runbook, einen Pfad wie /web/logs oder eine {'{'}Variable{'}'}."
},
"header": "Warnmeldungen",
"subtitle": "Alarmregeln und Benachrichtigungskanäle",
@@ -8659,6 +8662,7 @@
"urlTemplate": "URL-Vorlage",
"urlRequired": "URL ist erforderlich",
"urlPlaceholder": "z. B. https://example.com/trace/{'${field.__value}'}?von={'${start_time}'}&bis={'${end_time}'}",
"urlInvalid": "Geben Sie eine gültige http(s)-URL ein",
"urlHint": "Verwenden Sie {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Felder",
"fieldsHint": "Link nur anzeigen, wenn mindestens ein Feld im Datensatz vorhanden ist",
+5 -1
View File
@@ -5334,7 +5334,10 @@
"invalidSqlQuery": "Invalid SQL Query",
"errorFetchingStreams": "Error fetching streams",
"sqlValidationError": "SQL validation error: {error}",
"nameUnsupportedChars": "Alert names cannot contain spaces or : # ? ' \" % & /"
"nameUnsupportedChars": "Alert names cannot contain spaces or : # ? ' \" % & /",
"linkUrlUnsupportedScheme": "Unsupported URL scheme \"{scheme}\". Use http, https or mailto — a relative path or a {'{'}variable{'}'} also works.",
"linkUrlInvalid": "Link \"{label}\": {reason}",
"linkUrlNotAUrl": "Enter a valid URL — for example https://example.com/runbook, a path like /web/logs, or a {'{'}variable{'}'}."
},
"header": "Alerts",
"subtitle": "Alert rules and notification channels",
@@ -8701,6 +8704,7 @@
"urlTemplate": "URL Template",
"urlRequired": "URL is required",
"urlPlaceholder": "e.g., https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Enter a valid http(s) URL",
"urlHint": "Use {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Fields",
"fieldsHint": "Show link only when at least one field is present in the record",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Función VRL no válida",
"invalidSqlQuery": "Consulta SQL no válida",
"errorFetchingStreams": "Error al obtener los flujos",
"sqlValidationError": "Error de validación SQL: {error}"
"sqlValidationError": "Error de validación SQL: {error}",
"linkUrlUnsupportedScheme": "Esquema de URL no admitido \"{scheme}\". Use http, https o mailto: una ruta relativa o una {'{'}variable{'}'} también funciona.",
"linkUrlInvalid": "Enlace \"{label}\": {reason}",
"linkUrlNotAUrl": "Introduzca una URL válida: por ejemplo https://example.com/runbook, una ruta como /web/logs o una {'{'}variable{'}'}."
},
"header": "Alertas",
"subtitle": "Reglas de alertas y canales de notificación",
@@ -8659,6 +8662,7 @@
"urlTemplate": "Plantilla de URL",
"urlRequired": "La URL es obligatoria",
"urlPlaceholder": "p. ej., https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Introduzca una URL http(s) válida",
"urlHint": "Usa {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Campos",
"fieldsHint": "Mostrar enlace solo cuando hay al menos un campo en el registro",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Fonction VRL invalide",
"invalidSqlQuery": "Requête SQL invalide",
"errorFetchingStreams": "Erreur lors de la récupération des flux",
"sqlValidationError": "Erreur de validation SQL : {error}"
"sqlValidationError": "Erreur de validation SQL : {error}",
"linkUrlUnsupportedScheme": "Schéma d'URL non pris en charge \"{scheme}\". Utilisez http, https ou mailto — un chemin relatif ou une {'{'}variable{'}'} fonctionne aussi.",
"linkUrlInvalid": "Lien \"{label}\" : {reason}",
"linkUrlNotAUrl": "Saisissez une URL valide — par exemple https://example.com/runbook, un chemin comme /web/logs ou une {'{'}variable{'}'}."
},
"header": "Alertes",
"subtitle": "Règles d'alerte et canaux de notification",
@@ -8659,6 +8662,7 @@
"urlTemplate": "Modèle d'URL",
"urlRequired": "L'URL est obligatoire",
"urlPlaceholder": "par ex., https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Saisissez une URL http(s) valide",
"urlHint": "Utilisez {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Champs",
"fieldsHint": "Afficher le lien uniquement lorsqu'au moins un champ est présent dans l'enregistrement",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Funzione VRL non valida",
"invalidSqlQuery": "Query SQL non valida",
"errorFetchingStreams": "Errore nel recupero degli stream",
"sqlValidationError": "Errore di validazione SQL: {error}"
"sqlValidationError": "Errore di validazione SQL: {error}",
"linkUrlUnsupportedScheme": "Schema URL non supportato \"{scheme}\". Usa http, https o mailto: funziona anche un percorso relativo o una {'{'}variabile{'}'}.",
"linkUrlInvalid": "Link \"{label}\": {reason}",
"linkUrlNotAUrl": "Inserisci un URL valido — ad esempio https://example.com/runbook, un percorso come /web/logs o una {'{'}variabile{'}'}."
},
"header": "Avvisi",
"subtitle": "Regole di avviso e canali di notifica",
@@ -8659,6 +8662,7 @@
"urlTemplate": "Template URL",
"urlRequired": "L'URL è obbligatorio",
"urlPlaceholder": "ad es., https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Inserisci un URL http(s) valido",
"urlHint": "Usa {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Campi",
"fieldsHint": "Mostra il link solo quando nel record è presente almeno un campo",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "無効なVRL関数です",
"invalidSqlQuery": "無効なSQLクエリです",
"errorFetchingStreams": "ストリームの取得中にエラーが発生しました",
"sqlValidationError": "SQL検証エラー: {error}"
"sqlValidationError": "SQL検証エラー: {error}",
"linkUrlUnsupportedScheme": "サポートされていない URL スキーム \"{scheme}\" です。http、https、mailto を使用してください。相対パスや {'{'}変数{'}'} も使用できます。",
"linkUrlInvalid": "リンク \"{label}\": {reason}",
"linkUrlNotAUrl": "有効な URL を入力してください。例: https://example.com/runbook、/web/logs のようなパス、または {'{'}変数{'}'}。"
},
"header": "アラート",
"subtitle": "アラートルールと通知チャネル",
@@ -8659,6 +8662,7 @@
"urlTemplate": "URL テンプレート",
"urlRequired": "URL が必要です",
"urlPlaceholder": "例: https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "有効な http(s) URL を入力してください",
"urlHint": "{'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'} を使用",
"fields": "フィールド",
"fieldsHint": "レコードに少なくとも 1 つのフィールドが存在する場合にのみリンクを表示",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "잘못된 VRL 함수",
"invalidSqlQuery": "잘못된 SQL 쿼리",
"errorFetchingStreams": "스트림 가져오기 오류",
"sqlValidationError": "SQL 유효성 검사 오류: {error}"
"sqlValidationError": "SQL 유효성 검사 오류: {error}",
"linkUrlUnsupportedScheme": "지원되지 않는 URL 스킴 \"{scheme}\"입니다. http, https 또는 mailto를 사용하세요. 상대 경로나 {'{'}변수{'}'}도 사용할 수 있습니다.",
"linkUrlInvalid": "링크 \"{label}\": {reason}",
"linkUrlNotAUrl": "유효한 URL을 입력하세요. 예: https://example.com/runbook, /web/logs 같은 경로 또는 {'{'}변수{'}'}."
},
"header": "알림",
"subtitle": "경고 규칙 및 알림 채널",
@@ -8659,6 +8662,7 @@
"urlTemplate": "URL 템플릿",
"urlRequired": "URL이 필요합니다",
"urlPlaceholder": "예: https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "유효한 http(s) URL을 입력하세요",
"urlHint": "{'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'} 사용",
"fields": "필드",
"fieldsHint": "레코드에 하나 이상의 필드가 있는 경우에만 링크 표시",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Ongeldige VRL-functie",
"invalidSqlQuery": "Ongeldige SQL-query",
"errorFetchingStreams": "Fout bij ophalen van streams",
"sqlValidationError": "SQL-validatiefout: {error}"
"sqlValidationError": "SQL-validatiefout: {error}",
"linkUrlUnsupportedScheme": "Niet-ondersteund URL-schema \"{scheme}\". Gebruik http, https of mailto — een relatief pad of een {'{'}variabele{'}'} werkt ook.",
"linkUrlInvalid": "Link \"{label}\": {reason}",
"linkUrlNotAUrl": "Voer een geldige URL in — bijvoorbeeld https://example.com/runbook, een pad zoals /web/logs of een {'{'}variabele{'}'}."
},
"header": "Alarmen",
"subtitle": "Waarschuwingsregels en meldingskanalen",
@@ -8659,6 +8662,7 @@
"urlTemplate": "URL-sjabloon",
"urlRequired": "URL is vereist",
"urlPlaceholder": "bijv. https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Voer een geldige http(s)-URL in",
"urlHint": "Gebruik {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Velden",
"fieldsHint": "Link alleen tonen als er minstens één veld in de record aanwezig is",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Nieprawidłowa funkcja VRL",
"invalidSqlQuery": "Nieprawidłowe zapytanie SQL",
"errorFetchingStreams": "Błąd podczas pobierania strumieni",
"sqlValidationError": "Błąd walidacji SQL: {error}"
"sqlValidationError": "Błąd walidacji SQL: {error}",
"linkUrlUnsupportedScheme": "Nieobsługiwany schemat URL \"{scheme}\". Użyj http, https lub mailto — działa też ścieżka względna lub {'{'}zmienna{'}'}.",
"linkUrlInvalid": "Link \"{label}\": {reason}",
"linkUrlNotAUrl": "Podaj prawidłowy adres URL — na przykład https://example.com/runbook, ścieżkę taką jak /web/logs lub {'{'}zmienną{'}'}."
},
"header": "Alerty",
"subtitle": "Reguły alertów i kanały powiadomień",
@@ -8659,6 +8662,7 @@
"urlTemplate": "Szablon URL",
"urlRequired": "URL jest wymagany",
"urlPlaceholder": "np. https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Podaj prawidłowy adres URL http(s)",
"urlHint": "Użyj {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Pola",
"fieldsHint": "Pokaż link tylko gdy co najmniej jedno pole jest obecne w rekordzie",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Função VRL inválida",
"invalidSqlQuery": "Consulta SQL inválida",
"errorFetchingStreams": "Erro ao buscar streams",
"sqlValidationError": "Erro de validação SQL: {error}"
"sqlValidationError": "Erro de validação SQL: {error}",
"linkUrlUnsupportedScheme": "Esquema de URL não suportado \"{scheme}\". Use http, https ou mailto — um caminho relativo ou uma {'{'}variável{'}'} também funciona.",
"linkUrlInvalid": "Ligação \"{label}\": {reason}",
"linkUrlNotAUrl": "Introduza um URL válido — por exemplo https://example.com/runbook, um caminho como /web/logs ou uma {'{'}variável{'}'}."
},
"header": "Alertas",
"subtitle": "Regras de alerta e canais de notificação",
@@ -8659,6 +8662,7 @@
"urlTemplate": "Modelo de URL",
"urlRequired": "O URL é obrigatório",
"urlPlaceholder": "ex., https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Introduza um URL http(s) válido",
"urlHint": "Use {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Campos",
"fieldsHint": "Mostrar link somente quando pelo menos um campo estiver presente no registro",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Некорректная функция VRL",
"invalidSqlQuery": "Некорректный SQL-запрос",
"errorFetchingStreams": "Ошибка получения потоков",
"sqlValidationError": "Ошибка проверки SQL: {error}"
"sqlValidationError": "Ошибка проверки SQL: {error}",
"linkUrlUnsupportedScheme": "Неподдерживаемая схема URL \"{scheme}\". Используйте http, https или mailto — также подойдёт относительный путь или {'{'}переменная{'}'}.",
"linkUrlInvalid": "Ссылка \"{label}\": {reason}",
"linkUrlNotAUrl": "Введите корректный URL — например https://example.com/runbook, путь вида /web/logs или {'{'}переменную{'}'}."
},
"header": "Оповещения",
"subtitle": "Правила оповещений и каналы уведомлений",
@@ -8659,6 +8662,7 @@
"urlTemplate": "Шаблон URL",
"urlRequired": "URL обязателен",
"urlPlaceholder": "например, https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Введите корректный http(s) URL",
"urlHint": "Используйте {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Поля",
"fieldsHint": "Показывать ссылку только если хотя бы одно поле присутствует в записи",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Geçersiz VRL İşlevi",
"invalidSqlQuery": "Geçersiz SQL Sorgusu",
"errorFetchingStreams": "Akışlar alınırken hata",
"sqlValidationError": "SQL doğrulama hatası: {error}"
"sqlValidationError": "SQL doğrulama hatası: {error}",
"linkUrlUnsupportedScheme": "Desteklenmeyen URL şeması \"{scheme}\". http, https veya mailto kullanın — göreli bir yol ya da {'{'}değişken{'}'} de çalışır.",
"linkUrlInvalid": "Bağlantı \"{label}\": {reason}",
"linkUrlNotAUrl": "Geçerli bir URL girin — örneğin https://example.com/runbook, /web/logs gibi bir yol veya bir {'{'}değişken{'}'}."
},
"header": "Uyarılar",
"subtitle": "Uyarı kuralları ve bildirim kanalları",
@@ -8659,6 +8662,7 @@
"urlTemplate": "URL Şablonu",
"urlRequired": "URL gerekli",
"urlPlaceholder": "örn., https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Geçerli bir http(s) URL girin",
"urlHint": "{'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'} kullanın",
"fields": "Tarlalar",
"fieldsHint": "Bağlantıyı yalnızca kayıtta en az bir alan varsa göster",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "Hàm VRL không hợp lệ",
"invalidSqlQuery": "Truy vấn SQL không hợp lệ",
"errorFetchingStreams": "Lỗi khi tải danh sách luồng",
"sqlValidationError": "Lỗi xác thực SQL: {error}"
"sqlValidationError": "Lỗi xác thực SQL: {error}",
"linkUrlUnsupportedScheme": "Lược đồ URL không được hỗ trợ \"{scheme}\". Hãy dùng http, https hoặc mailto — đường dẫn tương đối hoặc {'{'}biến{'}'} cũng được.",
"linkUrlInvalid": "Liên kết \"{label}\": {reason}",
"linkUrlNotAUrl": "Nhập một URL hợp lệ — ví dụ https://example.com/runbook, đường dẫn như /web/logs, hoặc một {'{'}biến{'}'}."
},
"header": "Cảnh báo",
"subtitle": "Quy tắc cảnh báo và kênh thông báo",
@@ -8659,6 +8662,7 @@
"urlTemplate": "Mẫu URL",
"urlRequired": "Yêu cầu nhập URL",
"urlPlaceholder": "ví dụ: https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "Nhập một URL http(s) hợp lệ",
"urlHint": "Sử dụng {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "Trường",
"fieldsHint": "Chỉ hiển thị liên kết khi có ít nhất một trường trong bản ghi",
+5 -1
View File
@@ -5306,7 +5306,10 @@
"invalidVrlFunction": "VRL 函数无效",
"invalidSqlQuery": "SQL 查询无效",
"errorFetchingStreams": "获取流时出错",
"sqlValidationError": "SQL 验证错误:{error}"
"sqlValidationError": "SQL 验证错误:{error}",
"linkUrlUnsupportedScheme": "不支持的 URL 协议 \"{scheme}\"。请使用 http、https 或 mailto — 相对路径或 {'{'}变量{'}'} 也可以。",
"linkUrlInvalid": "链接 \"{label}\":{reason}",
"linkUrlNotAUrl": "请输入有效的 URL — 例如 https://example.com/runbook、类似 /web/logs 的路径,或 {'{'}变量{'}'}。"
},
"header": "告警",
"subtitle": "警报规则和通知渠道",
@@ -8659,6 +8662,7 @@
"urlTemplate": "网址模板",
"urlRequired": "网址为必填项",
"urlPlaceholder": "例如,https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "请输入有效的 http(s) URL",
"urlHint": "使用 {'${field.__name}'}, {'${field.__value}'}, {'${start_time}'}, {'${end_time}'}, {'${query}'}, {'${query_encoded}'}",
"fields": "字段",
"fieldsHint": "仅当记录中至少存在一个字段时才显示链接",
+5 -1
View File
@@ -5301,7 +5301,10 @@
"invalidVrlFunction": "無效的 VRL 函數",
"invalidSqlQuery": "無效的 SQL 查詢",
"errorFetchingStreams": "取得串流時發生錯誤",
"sqlValidationError": "SQL 驗證錯誤:{error}"
"sqlValidationError": "SQL 驗證錯誤:{error}",
"linkUrlUnsupportedScheme": "不支援的 URL 協定 \"{scheme}\"。請使用 http、https 或 mailto — 相對路徑或 {'{'}變數{'}'} 也可以。",
"linkUrlInvalid": "連結 \"{label}\":{reason}",
"linkUrlNotAUrl": "請輸入有效的 URL — 例如 https://example.com/runbook、類似 /web/logs 的路徑,或 {'{'}變數{'}'}。"
},
"header": "警示",
"subtitle": "警報規則與通知管道",
@@ -8654,6 +8657,7 @@
"urlTemplate": "URL 範本",
"urlRequired": "URL 為必填",
"urlPlaceholder": "例如 https://example.com/trace/{'${field.__value}'}?from={'${start_time}'}&to={'${end_time}'}",
"urlInvalid": "請輸入有效的 http(s) URL",
"urlHint": "使用 {'${field.__name}'}、{'${field.__value}'}、{'${start_time}'}、{'${end_time}'}、{'${query}'}、{'${query_encoded}'}",
"fields": "欄位",
"fieldsHint": "僅當記錄中存在至少一個欄位時顯示連結",
+27 -1
View File
@@ -951,7 +951,33 @@ describe("DetailTable Component", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
wrapper.vm.openCrossLink("https://example.com/trace/123");
expect(openSpy).toHaveBeenCalledWith("https://example.com/trace/123", "_blank");
// `noopener,noreferrer` stops the opened tab reaching back via
// window.opener and strips the referrer.
expect(openSpy).toHaveBeenCalledWith(
"https://example.com/trace/123",
"_blank",
"noopener,noreferrer",
);
openSpy.mockRestore();
});
// The RESOLVED url is guarded, not just the saved template: links stored
// before save-time validation existed are still in the DB, and a field
// VALUE substituted into a template can carry a hostile scheme.
it("does not open a cross-link whose resolved url is unsafe", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
for (const hostile of [
"javascript:alert(document.cookie)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"http://",
"not a url",
]) {
wrapper.vm.openCrossLink(hostile);
}
expect(openSpy).not.toHaveBeenCalled();
openSpy.mockRestore();
});
+4 -1
View File
@@ -471,6 +471,7 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import { isSafeNavigableUrl } from "@/utils/safeUrl";
const defaultValue: any = () => {
return {
data: {},
@@ -922,7 +923,9 @@ export default defineComponent({
};
const openCrossLink = (url: string) => {
window.open(url, "_blank");
// Guard the RESOLVED url — see JsonPreview.vue's twin for the reasoning.
if (!isSafeNavigableUrl(url)) return;
window.open(url, "_blank", "noopener,noreferrer");
};
const viewTrace = () => {
+27 -1
View File
@@ -1130,7 +1130,33 @@ describe("JsonPreview Component", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
wrapper.vm.openCrossLink("https://example.com/trace/123");
expect(openSpy).toHaveBeenCalledWith("https://example.com/trace/123", "_blank");
// `noopener,noreferrer` stops the opened tab reaching back via
// window.opener and strips the referrer.
expect(openSpy).toHaveBeenCalledWith(
"https://example.com/trace/123",
"_blank",
"noopener,noreferrer",
);
openSpy.mockRestore();
});
// See the DetailTable twin: the RESOLVED url is guarded, because links
// saved before validation existed are still in the DB and a substituted
// field VALUE can carry a hostile scheme.
it("does not open a cross-link whose resolved url is unsafe", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
for (const hostile of [
"javascript:alert(document.cookie)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"http://",
"not a url",
]) {
wrapper.vm.openCrossLink(hostile);
}
expect(openSpy).not.toHaveBeenCalled();
openSpy.mockRestore();
});
+9 -1
View File
@@ -280,6 +280,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue";
import { copyToClipboard } from "@/utils/clipboard";
import { timestampToTimezoneDate } from "@/utils/timezone";
import { toast } from "@/lib/feedback/Toast/useToast";
import { isSafeNavigableUrl } from "@/utils/safeUrl";
export default {
name: "JsonPreview",
@@ -485,7 +486,14 @@ export default {
const crossLinkDropdownVisible = ref(false);
const openCrossLink = (url: string) => {
window.open(url, "_blank");
// Guard the RESOLVED url, not just the saved template: links stored
// before save-time validation existed are still in the DB, and a field
// VALUE substituted into the template can carry a hostile scheme too.
if (!isSafeNavigableUrl(url)) {
crossLinkDropdownVisible.value = false;
return;
}
window.open(url, "_blank", "noopener,noreferrer");
crossLinkDropdownVisible.value = false;
};
let multiStreamFields: any = ref([]);
+25 -16
View File
@@ -38,6 +38,9 @@ describe("Common Utils", () => {
Object.defineProperty(window, "location", {
value: {
href: "",
// `origin` is what the same-origin redirect check compares against;
// a real window.location always has it.
origin: "http://localhost:3000",
replace: vi.fn(),
},
writable: true,
@@ -175,12 +178,26 @@ describe("Common Utils", () => {
mockZincutils.getPath = vi.fn().mockReturnValue("/default-path");
});
it("should redirect to external URL when redirectURI contains http", () => {
const externalUrl = "https://external-site.com/redirect";
// SECURITY: `redirectURI` comes from the attacker-controllable
// `?short_url=` query param. It previously only had to CONTAIN "http",
// so an off-origin URL sent the user to a phishing site right after they
// authenticated. Off-origin must now fall back to the home path.
it("refuses an off-origin redirect and goes to the default path instead", () => {
const replaceSpy = vi.spyOn(window.location, "replace");
redirectUser(externalUrl);
redirectUser("https://external-site.com/redirect");
expect(window.location.href).toBe(externalUrl);
expect(replaceSpy).toHaveBeenCalledWith("/default-path");
expect(window.location.href).not.toContain("external-site.com");
});
it("refuses protocol-relative and backslash off-origin tricks", () => {
for (const hostile of ["//evil.com/", "/\\evil.com", "https://localhost.evil.com/"]) {
const replaceSpy = vi.spyOn(window.location, "replace");
redirectUser(hostile);
expect(replaceSpy, `accepted: ${hostile}`).toHaveBeenCalledWith("/default-path");
replaceSpy.mockRestore();
}
});
it("should use replace for internal redirects", () => {
@@ -209,20 +226,12 @@ describe("Common Utils", () => {
expect(replaceSpy).toHaveBeenCalledWith("/default-path");
});
it("should handle https URLs", () => {
const httpsUrl = "https://secure.example.com/auth";
it("still allows an absolute URL on the SAME origin", () => {
const sameOrigin = "http://localhost:3000/auth/callback";
redirectUser(httpsUrl);
redirectUser(sameOrigin);
expect(window.location.href).toBe(httpsUrl);
});
it("should handle http URLs", () => {
const httpUrl = "http://example.com/redirect";
redirectUser(httpUrl);
expect(window.location.href).toBe(httpUrl);
expect(window.location.href).toBe(sameOrigin);
});
it("should handle relative paths", () => {
+14 -3
View File
@@ -16,6 +16,7 @@
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 }]);
@@ -71,11 +72,21 @@ export const getDefaultOrganization = async (userInfo: any, org_identifier: any)
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 (redirectURI.includes("http")) {
window.location.href = redirectURI;
} else {
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: "/" });
+125
View File
@@ -0,0 +1,125 @@
// 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 { describe, expect, it } from "vitest";
import { isSafeNavigableUrl, isSameOriginRedirect } from "./safeUrl";
describe("isSafeNavigableUrl", () => {
it("accepts absolute http(s) URLs", () => {
for (const ok of [
"https://example.com",
"http://example.com/path?q=1#f",
"https://example.com:8443/x",
"https://user:pass@example.com/x",
"https://192.168.1.1/x",
"https://[::1]:8080/x",
"HTTPS://EXAMPLE.COM/X",
]) {
expect(isSafeNavigableUrl(ok), `rejected: ${ok}`).toBe(true);
}
});
it("rejects active-content schemes, including blocklist bypasses", () => {
for (const bad of [
"javascript:alert(1)",
"JavaScript:alert(1)",
" javascript:alert(1)",
"java\tscript:alert(1)",
"java\nscript:alert(1)",
"\u0000javascript:alert(1)",
"vbscript:msgbox(1)",
// Explicitly allowed by the old drilldown regex — both are unsafe.
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
"file:///etc/passwd",
"telnet://host",
"ws://host",
]) {
expect(isSafeNavigableUrl(bad), `accepted: ${bad}`).toBe(false);
}
});
it("rejects an allowlisted scheme with no host (a WHATWG parse failure)", () => {
for (const bad of ["http:", "http://", "https://", "http:///", "http://?q=1", "http://#f"]) {
expect(isSafeNavigableUrl(bad), `accepted: ${bad}`).toBe(false);
}
});
it("rejects hosts that parse but cannot resolve", () => {
for (const bad of ["https://.", "http://..", "https://-", "http://.:80"]) {
expect(isSafeNavigableUrl(bad), `accepted: ${bad}`).toBe(false);
}
});
it("rejects values that are not URLs at all", () => {
for (const bad of ["", " ", "foo", "javascript(0)", "not a url at all"]) {
expect(isSafeNavigableUrl(bad), `accepted: ${bad}`).toBe(false);
}
});
it("optionally allows mailto: for link targets", () => {
expect(isSafeNavigableUrl("mailto:on@call.com")).toBe(false);
expect(isSafeNavigableUrl("mailto:on@call.com", { allowMailto: true })).toBe(true);
// Still needs a real mailbox.
expect(isSafeNavigableUrl("mailto:", { allowMailto: true })).toBe(false);
expect(isSafeNavigableUrl("mailto:foo", { allowMailto: true })).toBe(false);
});
it("optionally allows a {variable} template that resolves at render time", () => {
expect(isSafeNavigableUrl("{alert_url}")).toBe(false);
expect(isSafeNavigableUrl("{alert_url}", { allowTemplateVars: true })).toBe(true);
expect(isSafeNavigableUrl("https://x.example/{field}", { allowTemplateVars: true })).toBe(true);
// A variable elsewhere does not excuse a literal hostile scheme.
expect(isSafeNavigableUrl("{x}javascript:alert(1)", { allowTemplateVars: true })).toBe(false);
});
});
describe("isSameOriginRedirect", () => {
const ORIGIN = "https://app.example.com";
it("accepts a relative path", () => {
for (const ok of ["/web/logs", "/", "/a?b=1#c"]) {
expect(isSameOriginRedirect(ok, ORIGIN), `rejected: ${ok}`).toBe(true);
}
});
it("accepts an absolute URL on the SAME origin", () => {
expect(isSameOriginRedirect(`${ORIGIN}/web/logs`, ORIGIN)).toBe(true);
});
it("rejects an absolute URL on a DIFFERENT origin", () => {
// The old check was `redirectURI.includes("http")`, which let every one
// of these through and redirected the user off-site after login.
for (const bad of [
"https://evil.com/",
"http://evil.com/",
"https://app.example.com.evil.com/", // lookalike host
"//evil.com/", // protocol-relative
"https://app.example.com:8443/x", // different port = different origin
]) {
expect(isSameOriginRedirect(bad, ORIGIN), `accepted: ${bad}`).toBe(false);
}
});
it("rejects hostile schemes and backslash tricks", () => {
for (const bad of ["javascript:alert(1)", "data:text/html,x", "\\\\evil.com", "/\\evil.com"]) {
expect(isSameOriginRedirect(bad, ORIGIN), `accepted: ${bad}`).toBe(false);
}
});
it("rejects empty/nullish input", () => {
expect(isSameOriginRedirect("", ORIGIN)).toBe(false);
expect(isSameOriginRedirect(null, ORIGIN)).toBe(false);
});
});
+133
View File
@@ -0,0 +1,133 @@
// 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/>.
//
// One place to answer "is this user-supplied string a URL we may navigate to?"
//
// This check had been written from scratch four times (`safeHttpUrl` in
// ingestion/setupCard/subs.ts, `getHttpUrl` in TableRenderer.vue, and twice in
// the synthetics schemas), and the two places that DIDN'T have it — dashboard
// drilldowns and stream cross-links — are exactly where unsafe values got in.
// Prefer this helper over a fifth hand-rolled regex.
/** Schemes that may ever be navigated to. NOT data:, file:, ws:, telnet:. */
const NAVIGABLE_SCHEMES = ["http:", "https:"];
export interface SafeUrlOptions {
/** Also accept `mailto:` with a real `local@domain` mailbox. */
allowMailto?: boolean;
/**
* Also accept a value whose scheme only materializes after `{variable}`
* substitution at render time (alert templates, drilldowns, cross-links).
* A literal hostile scheme is still rejected: `{x}javascript:` fails.
*/
allowTemplateVars?: boolean;
}
/**
* True when `value` is a URL that is safe to navigate to (`window.open`,
* `location.href`, an `:href` binding).
*
* Why a parser and not a regex: a scheme allowlist alone is not enough —
* `http:` and `https://` carry an allowlisted scheme and are still not URLs
* (per the WHATWG URL Standard an empty host is a *parse failure* for a
* special scheme). And a parser alone is not enough either — `javascript:`
* parses perfectly well. Both checks are required, in that order.
*/
export function isSafeNavigableUrl(value: unknown, opts: SafeUrlOptions = {}): boolean {
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (trimmed === "") return false;
// Control characters and internal whitespace are never valid in a URL, and
// are how `java\tscript:` style bypasses are smuggled past naive checks.
for (const ch of trimmed) {
const c = ch.codePointAt(0)!;
if (c <= 0x20 || c === 0x7f) return false;
}
if (opts.allowTemplateVars) {
// A variable in SCHEME POSITION defers judgement to substitution time.
// Deliberately strict: `{x}javascript:alert(1)` also starts with `{`, but
// the rest of its scheme is the literal, hostile `javascript`, so the
// variable must be the WHOLE scheme (or the whole value).
const boundary = trimmed.search(/[:/?#]/);
const schemeIsVariable =
boundary === -1
? /^\{[^{}]+\}/.test(trimmed) // no scheme at all: `{alert_url}&tab=x`
: trimmed[boundary] === ":" && /^\{[^{}]+\}$/.test(trimmed.slice(0, boundary));
if (schemeIsVariable) return true;
}
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return false;
}
const scheme = parsed.protocol.toLowerCase();
if (scheme === "mailto:") {
if (!opts.allowMailto) return false;
const at = parsed.pathname.indexOf("@");
if (at <= 0) return false;
const domain = parsed.pathname.slice(at + 1);
return domain.includes(".") && !domain.startsWith(".");
}
if (!NAVIGABLE_SCHEMES.includes(scheme)) return false;
// `new URL()` guarantees a non-empty host for a special scheme, but a host
// may still be junk that can never resolve (".", "..", "-").
const host = parsed.hostname;
return host.length > 0 && /[a-z0-9]/i.test(host) && !/^[.-]/.test(host) && !/[.-]$/.test(host);
}
/**
* True when `value` is safe to redirect to after login — a relative path, or
* an absolute URL on `origin` itself.
*
* The previous check was `redirectURI.includes("http")`, a substring test that
* accepted `https://evil.com/` and sent the user off-site immediately after
* authenticating (the classic post-auth phishing setup).
*/
export function isSameOriginRedirect(
value: string | null | undefined,
origin: string = window.location.origin,
): boolean {
if (!value) return false;
const trimmed = value.trim();
if (trimmed === "") return false;
for (const ch of trimmed) {
const c = ch.codePointAt(0)!;
if (c <= 0x20 || c === 0x7f) return false;
}
// A backslash is normalized to `/` by browsers, so `/\evil.com` and
// `\\evil.com` are protocol-relative in disguise.
if (trimmed.includes("\\")) return false;
// Protocol-relative (`//evil.com`) points off-origin despite looking relative.
if (trimmed.startsWith("//")) return false;
// Plain relative path: same origin by construction.
if (trimmed.startsWith("/")) return true;
try {
return new URL(trimmed, origin).origin === new URL(origin).origin;
} catch {
return false;
}
}
+8 -4
View File
@@ -435,20 +435,24 @@ describe("Login.vue", () => {
expect(pushSpy).toHaveBeenCalledWith({ path: "/dashboard" });
});
it("should redirect to external URL when redirectURI contains http", async () => {
// Mock azure_marketplace_token as null so it checks redirectURI
// SECURITY: `redirectURI` originates from the attacker-controllable
// `?short_url=` query param, so an OFF-ORIGIN value here is a post-auth
// open redirect (phishing). It must be refused and routed home instead.
it("refuses an off-origin redirect and routes to the home path", async () => {
mockSessionStorage.getItem.mockImplementation((key: string) => {
if (key === "azure_marketplace_token") return null;
if (key === "redirectURI") return "https://external.com";
return null;
});
const pushSpy = vi.spyOn(router, "push");
wrapper.vm.redirectUser();
expect(mockSessionStorage.getItem).toHaveBeenCalledWith("azure_marketplace_token");
expect(mockSessionStorage.getItem).toHaveBeenCalledWith("redirectURI");
expect(mockSessionStorage.removeItem).toHaveBeenCalledWith("redirectURI");
expect(window.location.href).toBe("https://external.com");
expect(window.location.href).not.toBe("https://external.com");
expect(pushSpy).toHaveBeenCalledWith({ path: "/" });
});
it("should redirect to home when no redirectURI", async () => {
+8 -4
View File
@@ -58,6 +58,7 @@ import {
import { useReo } from "@/services/reodotdev_analytics";
import { toast } from "@/lib/feedback/Toast/useToast";
import { useI18nTyped } from "@/types/i18n";
import { isSameOriginRedirect } from "@/utils/safeUrl";
export default defineComponent({
name: "LoginPage",
@@ -233,11 +234,14 @@ export default defineComponent({
const redirectURI = window.sessionStorage.getItem("redirectURI");
window.sessionStorage.removeItem("redirectURI");
if (redirectURI != null && redirectURI != "") {
if (redirectURI.includes("http")) {
window.location.href = redirectURI;
} else {
// Same-origin only: `redirectURI` originates from the `?short_url=`
// query param, so an off-origin value here is a post-auth open redirect
// (see utils/common.ts redirectUser for the twin of this check).
if (redirectURI != null && redirectURI != "" && isSameOriginRedirect(redirectURI)) {
if (redirectURI.startsWith("/")) {
router.push({ path: redirectURI });
} else {
window.location.href = redirectURI;
}
} else {
router.push({ path: "/" });