diff --git a/CHANGELOG.md b/CHANGELOG.md index 095f88bef..dd2bcc71d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- WebAssembly rejects fractional, nonfinite and out-of-range element IDs, + sheet coordinates, rendering limits and view indices before conversion. + - Apple CSV decoding rejects multi-character and multibyte delimiters. Roots without a text-root interface are exposed as `Element`, not `TextRoot`. diff --git a/wasm/src/odr_wasm.hpp b/wasm/src/odr_wasm.hpp index e98bfbefb..5dad4a4d8 100644 --- a/wasm/src/odr_wasm.hpp +++ b/wasm/src/odr_wasm.hpp @@ -8,8 +8,13 @@ #include +#include +#include +#include #include +#include #include +#include #include #include @@ -19,6 +24,20 @@ namespace odr::wasm { using Handle = std::uint32_t; +/// Rejects fractional, out-of-range and imprecise JavaScript integers. +template T checked_integer(const double value) { + constexpr double exact = 9007199254740991.0; // 2^53 - 1 + const double minimum = + std::max(static_cast(std::numeric_limits::lowest()), -exact); + const double maximum = + std::min(static_cast(std::numeric_limits::max()), exact); + if (!std::isfinite(value) || std::trunc(value) != value || value < minimum || + value > maximum) { + throw std::invalid_argument("number is not a representable integer"); + } + return static_cast(value); +} + /// One open document. Owns everything reachable from it, because the pieces do /// not own each other: `HtmlView` holds a bare pointer into its service, so the /// service has to outlive the views. diff --git a/wasm/src/wasm_document.cpp b/wasm/src/wasm_document.cpp index 585a9bb10..6914401e6 100644 --- a/wasm/src/wasm_document.cpp +++ b/wasm/src/wasm_document.cpp @@ -78,7 +78,7 @@ emscripten::val recalculate(const Handle handle) { /// the page as `data-odr-id`, and a plain number needs no handle. Element element_of(Session &session, const double identifier) { const Element element = document_of(session).element_by_id( - static_cast(identifier)); + checked_integer(identifier)); if (!element) { throw std::invalid_argument("element not found"); } @@ -168,48 +168,53 @@ emscripten::val set_paragraph_style(const Handle handle, const double id, emscripten::val edit_style(const Handle handle, const std::string &op, const std::string &place, const emscripten::val style) { - return guarded([&] { - Session &s = session(handle); - if (style.isUndefined() || style.isNull() || - style.typeOf().as() != "object") { - throw std::invalid_argument(op + " takes a style object"); - } - const std::string json = - emscripten::val::global("JSON").call("stringify", style); - document_of(s).edit(R"({"version":2,"ops":[{"op":")" + op + R"(",)" + - place + R"(,"style":)" + json + "}]}"); - return ok(); - }); + Session &s = session(handle); + if (style.isUndefined() || style.isNull() || + style.typeOf().as() != "object") { + throw std::invalid_argument(op + " takes a style object"); + } + const std::string json = + emscripten::val::global("JSON").call("stringify", style); + document_of(s).edit(R"({"version":2,"ops":[{"op":")" + op + R"(",)" + place + + R"(,"style":)" + json + "}]}"); + return ok(); } std::string index_field(const std::string &name, const double index) { return '"' + name + R"(":)" + - std::to_string(static_cast(index)); + std::to_string(checked_integer(index)); } emscripten::val set_cell_style(const Handle handle, const double sheet, const double column, const double row, const emscripten::val style) { - return edit_style(handle, "setCellStyle", - index_field("sheet", sheet) + "," + - index_field("column", column) + "," + - index_field("row", row), - style); + return guarded([&] { + return edit_style(handle, "setCellStyle", + index_field("sheet", sheet) + "," + + index_field("column", column) + "," + + index_field("row", row), + style); + }); } emscripten::val set_row_style(const Handle handle, const double sheet, const double row, const emscripten::val style) { - return edit_style(handle, "setRowStyle", - index_field("sheet", sheet) + "," + index_field("row", row), - style); + return guarded([&] { + return edit_style( + handle, "setRowStyle", + index_field("sheet", sheet) + "," + index_field("row", row), style); + }); } emscripten::val set_column_style(const Handle handle, const double sheet, const double column, const emscripten::val style) { - return edit_style( - handle, "setColumnStyle", - index_field("sheet", sheet) + "," + index_field("column", column), style); + return guarded([&] { + return edit_style(handle, "setColumnStyle", + index_field("sheet", sheet) + "," + + index_field("column", column), + style); + }); } /// A row or column op, @p axis naming its index, replayed through the diff --git a/wasm/src/wasm_html.cpp b/wasm/src/wasm_html.cpp index 122d0829c..e88ee5853 100644 --- a/wasm/src/wasm_html.cpp +++ b/wasm/src/wasm_html.cpp @@ -23,7 +23,11 @@ void read(const emscripten::val &value, const char *key, T &target) { if (field.isUndefined() || field.isNull()) { return; } - target = field.as(); + if constexpr (std::integral && !std::same_as) { + target = checked_integer(field.as()); + } else { + target = field.as(); + } } /// @ref read for an enum, which JS carries as its ordinal. @@ -33,7 +37,7 @@ void read_enum(const emscripten::val &value, const char *key, T &target) { if (field.isUndefined() || field.isNull()) { return; } - target = static_cast(field.as()); + target = static_cast(checked_integer(field.as())); } /// A css length a caller states as a string, e.g. `"3mm"`. @@ -87,8 +91,9 @@ emscripten::val list_views(const Handle handle) { /// The rendered view as one HTML string, self-contained under the default /// `embedImages` — which is what lets a viewer drop it into a `blob:` iframe. -emscripten::val render_view(const Handle handle, const std::size_t index) { +emscripten::val render_view(const Handle handle, const double requested_index) { return guarded([&] { + const std::size_t index = checked_integer(requested_index); const Session &s = warm(handle); if (index >= s.views.size()) { return error(ErrorCode::unknown, @@ -178,7 +183,7 @@ HtmlConfig to_html_config(const emscripten::val &value) { read(value, "pageRangeBegin", config.page_range_begin); if (const emscripten::val end = value["pageRangeEnd"]; !end.isUndefined() && !end.isNull()) { - config.page_range_end = end.as(); + config.page_range_end = checked_integer(end.as()); } read_enum(value, "colorScheme", config.color_scheme); @@ -187,16 +192,18 @@ HtmlConfig to_html_config(const emscripten::val &value) { // absent leaves the default in place. if (const emscripten::val limit = value["spreadsheetLimit"]; !limit.isUndefined()) { - config.spreadsheet_limit = limit.isNull() - ? std::optional() - : std::optional(TableDimensions( - limit["rows"].as(), - limit["columns"].as())); + config.spreadsheet_limit = + limit.isNull() + ? std::optional() + : std::optional(TableDimensions( + checked_integer(limit["rows"].as()), + checked_integer( + limit["columns"].as()))); } if (const emscripten::val buffer = value["spreadsheetStyleBuffer"]; !buffer.isUndefined() && !buffer.isNull()) { config.spreadsheet_style_buffer = - static_cast(buffer.as()); + checked_integer(buffer.as()); } if (const emscripten::val limit = value["spreadsheetCellLimit"]; !limit.isUndefined()) { @@ -204,12 +211,12 @@ HtmlConfig to_html_config(const emscripten::val &value) { config.spreadsheet_cell_limit = limit.isNull() ? std::optional() - : std::optional(static_cast(limit.as())); + : std::optional(checked_integer(limit.as())); } read_enum(value, "viewportMode", config.viewport_mode); if (const emscripten::val width = value["viewportWidth"]; !width.isUndefined() && !width.isNull()) { - config.viewport_width = width.as(); + config.viewport_width = checked_integer(width.as()); } if (const emscripten::val zoom = value["initialZoom"]; !zoom.isUndefined() && !zoom.isNull()) { diff --git a/wasm/tests/edit.test.mjs b/wasm/tests/edit.test.mjs index 6b20be3e8..c6e82a9fc 100644 --- a/wasm/tests/edit.test.mjs +++ b/wasm/tests/edit.test.mjs @@ -315,10 +315,34 @@ describe('edit', () => { } }); - it('refuses an id the document does not hold', () => { - const doc = odr.open(minimalOdt('hello')); + it('refuses unknown or nonintegral element ids', () => { + const doc = odr.open(minimalOdt('hello'), { editable: true }); try { - assert.throws(() => doc.removeElement(999999), OdrError); + const id = firstEditableRunId(doc.render().html); + for (const invalid of [999999, id + 0.5, -1, NaN, Infinity, 2 ** 64]) { + assert.throws(() => doc.removeElement(invalid), OdrError); + } + assert.match(doc.render().html, /hello/); + } finally { + doc.close(); + } + }); + + it('rejects invalid sheet coordinates before an edit', () => { + const doc = odr.open(minimalOds()); + try { + for (const invalid of [-1, 0.5, NaN, Infinity, 2 ** 32]) { + for (const call of [ + () => doc.setCellStyle(0, invalid, 0, { bold: true }), + () => doc.setRowStyle(0, invalid, { bold: true }), + () => doc.setColumnStyle(invalid, 0, { bold: true }), + () => doc.insertRows(0, 0, invalid), + () => doc.deleteColumns(0, invalid, 1), + ]) { + assert.throws(call, OdrError); + } + } + assert.doesNotMatch(doc.render().html, /font-weight:bold/); } finally { doc.close(); } diff --git a/wasm/tests/render.test.mjs b/wasm/tests/render.test.mjs index d891b074c..c98c89577 100644 --- a/wasm/tests/render.test.mjs +++ b/wasm/tests/render.test.mjs @@ -10,6 +10,30 @@ describe('render', () => { }); after(() => odr.closeAll()); + it('rejects invalid numeric config and view indices', () => { + const bytes = minimalOdt(); + for (const invalid of [-1, 0.5, NaN, Infinity, 2 ** 64]) { + for (const config of [ + { pageRangeBegin: invalid }, { pageRangeEnd: invalid }, + { viewportWidth: invalid }, { spreadsheetStyleBuffer: invalid }, + { spreadsheetCellLimit: invalid }, + { spreadsheetLimit: { rows: invalid, columns: 1 } }, + { spreadsheetLimit: { rows: 1, columns: invalid } }, + ]) { + assert.throws(() => odr.open(bytes, config), OdrError); + } + } + const doc = odr.open(bytes); + try { + for (const invalid of [-1, 0.5, NaN, Infinity, 2 ** 32]) { + assert.throws(() => doc.render(invalid), OdrError); + } + assert.match(doc.render(0).html, /hello/); + } finally { + doc.close(); + } + }); + it('renders a view to self-contained html', () => { const doc = odr.open(fixture('mixed-layout.odt')); try {