mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
Fixes #13742 Fixes openobserve/o2-enterprise#2394 ## The bug A content template link with a non-http scheme (`javascript:alert(1)`) made Slack reject the **entire** notification with `400 invalid_attachments`. The alert was lost outright and `triggers` recorded `notify_failed`. ## Root cause `safe_url` neutralises a hostile scheme by substituting `BLOCKED_URL` (`"#"`). That is **safe** but not **deliverable** — Slack, Teams, Discord and PagerDuty validate action URLs server-side and reject the whole payload when one is not an absolute `http(s)` URL. A security substitution was turning into total message loss. The two properties are different, and links need both. So the fix splits by medium: | renderer | link handling | |---|---| | Slack buttons, Teams actions, Discord `embed.url`, PagerDuty `href` | `dispatchable_url` → **drop** | | HTML email, email plaintext, SNS plaintext, Discord description | `safe_url` → substitute `#` | | Webhook envelope | unfiltered — versioned machine contract, documented | Dropping beats rewriting: the alert body is worth more than the button, and a button pointing somewhere misleading is worse than no button. `dispatchable_url` requires a **non-empty authority**, not just an allowlisted scheme — `http:javascript:alert(1)` and `https://` are valid *opaque* RFC 3986 http(s) URLs that Slack still rejects. It also rejects raw spaces and control characters, which variable substitution can introduce. ## Related issues fixed in the same area - **Empty `ZO_WEB_URL`** (not in the report): `alert_url` becomes a root-relative `/web/logs?…`, which `safe_url` passes through by design, and it lands in the always-appended "View in OpenObserve" button. Same `invalid_attachments`, on deployments that never authored a hostile template. - **PagerDuty** validates `links[].href` server-side too — same lost-alert class, converted. - **Save-time rejection** (`ContentSpec::validate`): a clear 400 naming the offending link, instead of a template whose link silently never works. Also wired into **preview** and **test-send**, which previously accepted a draft that couldn't be saved and echoed the raw hostile URL back to the UI. - **Slack image-fallback misdiagnosis**: removing the image proves the image was at fault, but only an image pointing at *our* `web_url` justifies tripping the process-wide chart suppression (1h, across every alert) and telling the operator to fix `ZO_WEB_URL`. A custom template's third-party image no longer does either. - **a11y**: `OInput` links its error message to the control via `aria-describedby`; the textarea branch also gained `aria-invalid`. ## o2-enterprise#2394 (fixed here, not in the enterprise repo) Clearing the Monaco body and saving toasted "Please fill required fields" with no indication of *where*. The schema already produced an error on the `body` path — a bare Monaco bridged in via `setFieldValue` just had no `OFormInput` error slot to render it. Now a red border + message driven by the same field state `OFormInput` reads. The aria wiring goes on the **shell**, not on `<QueryEditor>`: `CodeQueryEditor` sets `inheritAttrs: false` and binds `$attrs` to a non-focusable wrapper, so attributes passed to the component would be inert — false assurance for a screen-reader user. > The issue names the title field too. It's an `OFormInput` and already highlights correctly; I added a regression test pinning that rather than changing it. This fix is entirely in OSS (`web/`) since the template form isn't enterprise-gated, so there's no companion enterprise PR. ## Layering note Save-time validation is a **UX gate, not the security boundary**. Link URLs are templates — a scheme that only materialises after `{variable}` substitution can't be judged at save time. The render layer is what actually stops delivery, and it also covers every template saved before this validation existed. Both layers are needed; neither is sufficient. ## Testing - Every fix written test-first (verified RED) and **mutation-tested** — each new test fails when its fix is reverted in substance. - Golden files unchanged: legitimate URLs render byte-identically, so no over-blocking. - Rust: `alerts::notifications` 159 · `config` 3130 · `api-management` 424 — all passing. `cargo clippy --all-targets` and `cargo fmt --check` clean. - Frontend: full suite **41,597 passing** (run because `OInput` is shared library code). `vue-tsc`, `eslint`, and `npm run format:check` clean. - Two independent cold-start review passes; findings from both are folded in, including a P0 where the first version of `dispatchable_url` still let the original bug through. Pre-existing and unrelated: `alerts::destinations::tests::test_alert_destination_template_is_optional` fails locally on a DB connection reset — verified failing on unmodified `main` too.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 つのフィールドが存在する場合にのみリンクを表示",
|
||||
|
||||
@@ -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": "레코드에 하나 이상의 필드가 있는 경우에만 링크 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Показывать ссылку только если хотя бы одно поле присутствует в записи",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "仅当记录中至少存在一个字段时才显示链接",
|
||||
|
||||
@@ -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": "僅當記錄中存在至少一個欄位時顯示連結",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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
@@ -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: "/" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: "/" });
|
||||
|
||||
Reference in New Issue
Block a user