Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions crates/slides-media/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,14 +386,37 @@ fn validate_svg(text: &str) -> Result<()> {
if local_str.eq_ignore_ascii_case("script") {
return Err(Error::UnsafeSvg("contains a <script> element".into()));
}
for attr in e.attributes().flatten() {
if local_str.eq_ignore_ascii_case("style") {
return Err(Error::UnsafeSvg("contains a <style> element".into()));
}
for attr in e.attributes() {
let attr = match attr {
Ok(a) => a,
Err(_) => {
return Err(Error::UnsafeSvg(
"attribute with unparseable entity".into(),
));
}
};
let key = String::from_utf8_lossy(attr.key.as_ref());
let lower = key.to_ascii_lowercase();
if lower.starts_with("on") && lower.len() > 2 {
return Err(Error::UnsafeSvg(format!("event-handler attribute '{key}'")));
}
if lower == "style" {
return Err(Error::UnsafeSvg(
"inline style attribute not allowed".into(),
));
}
if matches!(lower.as_str(), "href" | "xlink:href" | "src") {
let value = attr.unescape_value().unwrap_or_default();
let value = match attr.unescape_value() {
Ok(v) => v.into_owned(),
Err(_) => {
return Err(Error::UnsafeSvg(
"URL attribute with unparseable entity".into(),
));
}
};
if is_unsafe_url(&value) {
return Err(Error::UnsafeSvg(format!("external reference '{value}'")));
}
Expand All @@ -419,6 +442,11 @@ fn is_unsafe_url(value: &str) -> bool {
if trimmed.starts_with('#') {
return false;
}
// Reject protocol-relative URLs (e.g. //evil.example/x) which resolve to
// https://evil.example/x in a browser context.
if trimmed.starts_with("//") {
return true;
}
let lowered = trimmed.to_ascii_lowercase();
// Reject known dangerous script schemes explicitly...
const DANGEROUS_SCHEMES: &[&str] = &[
Expand Down Expand Up @@ -631,6 +659,27 @@ mod tests {
assert!(matches!(err, Error::UnsafeSvg(_)));
}

#[test]
fn rejects_svg_with_style_element() {
let svg = b"<svg><style>@import url(http://evil.example/x)</style></svg>";
let err = ingest(svg, &IngestOptions::default()).unwrap_err();
assert!(matches!(err, Error::UnsafeSvg(_)));
}

#[test]
fn rejects_svg_with_inline_style_attribute() {
let svg = b"<svg><rect style=\"fill:url(http://evil.example/x)\"/></svg>";
let err = ingest(svg, &IngestOptions::default()).unwrap_err();
assert!(matches!(err, Error::UnsafeSvg(_)));
}

#[test]
fn rejects_svg_with_protocol_relative_url() {
let svg = b"<svg><image href=\"//evil.example/track.png\"/></svg>";
let err = ingest(svg, &IngestOptions::default()).unwrap_err();
assert!(matches!(err, Error::UnsafeSvg(_)));
}

#[test]
fn rejects_svg_with_onload_attribute() {
let svg = b"<svg onload=\"alert(1)\"></svg>";
Expand Down
7 changes: 6 additions & 1 deletion crates/slides-pptx/src/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1915,7 +1915,12 @@ pub(crate) fn rel_attribute(e: &BytesStart<'_>, name: &str) -> Option<String> {
}

pub(crate) fn parse_attr_f64(e: &BytesStart<'_>, name: &str) -> Option<f64> {
attr_by_local_name(e, name)?.parse().ok()
let v: f64 = attr_by_local_name(e, name)?.parse().ok()?;
if v.is_finite() {
Some(v)
} else {
None
}
}

fn parse_bool_attr(e: &BytesStart<'_>, name: &str) -> Option<bool> {
Expand Down
11 changes: 9 additions & 2 deletions crates/slides-pptx/src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,20 @@ pub fn write_content_types(types: &ContentTypes) -> Result<Vec<u8>> {
));
writer.write_event(Event::Start(types_start))?;

for (ext, ct) in &types.defaults {
// Sort keys for deterministic output (HashMap iteration order is
// randomized per process, which would violate the byte-for-byte
// preservation guarantee for [Content_Types].xml).
let mut defaults: Vec<_> = types.defaults.iter().collect();
defaults.sort_by(|a, b| a.0.cmp(b.0));
for (ext, ct) in &defaults {
let mut elem = BytesStart::new("Default");
elem.push_attribute(("Extension", ext.as_str()));
elem.push_attribute(("ContentType", ct.as_str()));
writer.write_event(Event::Empty(elem))?;
}
for (part, ct) in &types.overrides {
let mut overrides: Vec<_> = types.overrides.iter().collect();
overrides.sort_by(|a, b| a.0.cmp(b.0));
for (part, ct) in &overrides {
let mut elem = BytesStart::new("Override");
elem.push_attribute(("PartName", part.as_str()));
elem.push_attribute(("ContentType", ct.as_str()));
Expand Down
22 changes: 20 additions & 2 deletions crates/slides-render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,12 +272,30 @@ pub fn render_slide(
RenderedSlide { svg, hash }
}

use std::sync::OnceLock;

/// Cached base64-encoded font data, computed once and reused across all
/// renders and aliases (avoids re-encoding ~2.3MB of fonts per export).
fn font_base64(data: &'static [u8]) -> &'static str {
static INTER_B64: OnceLock<String> = OnceLock::new();
static SERIF_B64: OnceLock<String> = OnceLock::new();
static MONO_B64: OnceLock<String> = OnceLock::new();
let cell = if std::ptr::eq(data, INTER_REGULAR) {
&INTER_B64
} else if std::ptr::eq(data, SOURCE_SERIF_REGULAR) {
&SERIF_B64
} else {
&MONO_B64
};
cell.get_or_init(|| base64::engine::general_purpose::STANDARD.encode(data))
}

/// Builds a single `@font-face` rule binding the CSS font-family `name` to the
/// base64-encoded font `data`. The same font data can be bound under several
/// names (aliases) so the theme's existing `font-family` attributes resolve to
/// the bundled fonts without changing the render logic.
fn font_face_css(name: &str, data: &[u8]) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(data);
fn font_face_css(name: &str, data: &'static [u8]) -> String {
let b64 = font_base64(data);
format!(
"@font-face {{ font-family: '{name}'; src: url(data:{mime};base64,{b64}) format('truetype'); }}",
mime = FONT_TTF_MIME
Expand Down
Loading