diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/sisudoc/ocda/abstraction/doc_from_artefact.d | 32 | ||||
| -rw-r--r-- | src/sisudoc/ocda/io_in/carried_names.d | 199 |
2 files changed, 230 insertions, 1 deletions
diff --git a/src/sisudoc/ocda/abstraction/doc_from_artefact.d b/src/sisudoc/ocda/abstraction/doc_from_artefact.d index 3a32d25..7cbc73a 100644 --- a/src/sisudoc/ocda/abstraction/doc_from_artefact.d +++ b/src/sisudoc/ocda/abstraction/doc_from_artefact.d @@ -86,6 +86,7 @@ template spineDocFromArtefact() { import sisudoc.ocda.meta.conf_make_meta_structs; import sisudoc.ocda.meta.doc_matters; import sisudoc.ocda.io_in.paths_source; + import sisudoc.ocda.io_in.carried_names; import sisudoc.ocda.abstraction.doc_has; import sisudoc.ocda.abstraction.load; import sisudoc.ocda.meta.topic_register; @@ -93,6 +94,7 @@ template spineDocFromArtefact() { mixin spineDocHasFromAbstraction; mixin spineAbstractionLoad; mixin spineTopicRegister; + mixin spineCarriedNames; /+ ↓ a .ssp header key from a field name: the first underscore becomes the dot that separates the group, so title_main is title.main and rights_copyright_text is rights.copyright_text. The writer's keys are @@ -220,6 +222,24 @@ template spineDocFromArtefact() { mixin spineAbstractionDbRead _dbr; auto _files = _dbr.dbReadFiles(_artefact, "image"); if (_files.length == 0) { return ""; } + /+ ↓ every name checked before anything is created or written. + the name comes out of the database and a database can be + downloaded, so it is attacker-controlled: it can climb with "..", + and chainPath drops everything before an absolute segment, so "/x" + would not land under the image directory at all. One bad name means + the artefact cannot be trusted for the rest of them, so this + refuses the lot rather than skipping one, which is what the zip + reader does with a zip. + +/ + foreach (_f; _files) { + string _bad = validateCarriedFileName(_f.name); + if (_bad.length > 0) { + stderr.writeln("WARNING: ", _artefact.baseName, + " carries an image spine will not write: ", _bad, + "; no image is taken from this artefact"); + return ""; + } + } string _root = (tempDir.chainPath("spine-ocda-" ~ _artefact.baseName ~ "-" ~ thisProcessID.to!string).array).to!string; string _img_dir = (_root.chainPath("media").chainPath("image").array).to!string; @@ -237,8 +257,18 @@ template spineDocFromArtefact() { " does not match the digest recorded with it (", _f.sha256, " expected, ", _got, " found); it is written out as it stands"); } + string _out_path = (_img_dir.chainPath(_f.name).array).to!string; + /+ ↓ the check on the check: the name rules above already forbid a + directory part, so this can only fire if they were loosened + +/ + if (!(carriedPathIsWithin(_img_dir, _out_path))) { + stderr.writeln("WARNING: ", _f.name, " in ", _artefact.baseName, + " resolves outside the image directory; no image is taken from", + " this artefact"); + return ""; + } try { - (_img_dir.chainPath(_f.name).array).to!string.write(_f.data); + _out_path.write(_f.data); } catch (Exception ex) { stderr.writeln("WARNING: could not write ", _f.name, ": ", ex.msg); } diff --git a/src/sisudoc/ocda/io_in/carried_names.d b/src/sisudoc/ocda/io_in/carried_names.d new file mode 100644 index 0000000..09e8912 --- /dev/null +++ b/src/sisudoc/ocda/io_in/carried_names.d @@ -0,0 +1,199 @@ +/+ +- Name: SisuDoc Spine, Doc Reform [a part of] + - Description: documents, structuring, processing, publishing, search + - static content generator + + - Author: Ralph Amissah + [ralph.amissah@gmail.com] + + - Copyright: (C) 2015 (continuously updated, current 2026) Ralph Amissah, All Rights Reserved. + + - License: AGPL 3 or later: + + Spine (SiSU), a framework for document structuring, publishing and + search + + Copyright (C) Ralph Amissah + + This program is free software: you can redistribute it and/or modify it + under the terms of the GNU AFERO 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 General Public License for + more details. + + You should have received a copy of the GNU General Public License along with + this program. If not, see [https://www.gnu.org/licenses/]. + + If you have Internet connection, the latest version of the AGPL should be + available at these locations: + [https://www.fsf.org/licensing/licenses/agpl.html] + [https://www.gnu.org/licenses/agpl.html] + + - Spine (by Doc Reform, related to SiSU) uses standard: + - docReform markup syntax + - standard SiSU markup syntax with modified headers and minor modifications + - docReform object numbering + - standard SiSU object citation numbering & system + + - Homepages: + [https://www.sisudoc.org] + [https://www.doc-reform.org] + + - Git + [https://git.sisudoc.org/] + ++/ +/++ + module carried_names;<BR> + - validate the names of files carried inside an artefact, before any of + them is written to disk<BR> + - one set of rules for a bare filename, one for a relative path, and a + containment check for either ++/ +module sisudoc.ocda.io_in.carried_names; +@safe: +/+ ↓ names that arrive from outside, and decide where a file gets written + . + A pod zip and a .ocda.db both carry files by name, and both can arrive + over https. A name read out of either is attacker-controlled: it can be + absolute, it can climb with "..", and chainPath silently drops + everything before an absolute segment, so "/etc/x" does not land under + the directory it was joined to. read_zip_pod.d has guarded against this + since the zip reader was written; the database reader did not, which is + what this module is for. + . + Two rules, because the two roles differ: + - an image is carried under a bare filename and belongs in one + directory, so a directory part in the name is itself the error; + - markup, conf and manifest are carried under their path inside the pod + (media/text/de/about_manual.ssi), so the rule there is the zip + reader's: relative, no climbing, bounded depth. + . + Both are followed by carriedPathIsWithin() at the point of writing. + That is deliberate duplication: the name rules are the check, and + containment is the check on the check. ++/ +template spineCarriedNames() { + import std.algorithm : canFind; + /+ ↓ imports stay narrow on purpose. This template is mixed in beside + others, and a template-level import lands in the mixing scope: an + "import std.array : split" here takes over the UFCS lookup for + split(ctRegex) at every mixin site and breaks it. Nothing below needs + split, and nothing below should acquire it. + +/ + import std.array : array; + import std.conv : to; + import std.path : asNormalizedPath, isAbsolute; + import std.regex; + import std.string : indexOf; + /+ ↓ limits, the zip reader's where they overlap +/ + enum size_t MAX_CARRIED_NAME_LENGTH = 255; /+ one path component +/ + enum size_t MAX_CARRIED_PATH_LENGTH = 1024; /+ the whole path +/ + enum size_t MAX_CARRIED_PATH_DEPTH = 10; /+ as read_zip_pod.d +/ + /+ ↓ allowed characters: a filename has no separator, a path has "/" +/ + static auto rgx_safe_carried_name = ctRegex!(`^[a-zA-Z0-9._ -]+$`); + static auto rgx_safe_carried_path = ctRegex!(`^[a-zA-Z0-9._/ -]+$`); + /+ ↓ the checks both rules share, "" when the name is acceptable +/ + private string _carriedNameCommon(string _name) { + if (_name.length == 0) { + return "empty carried name"; + } + if (_name.indexOf('\0') >= 0) { + return "null byte in carried name"; + } + if (_name.canFind("\\")) { + return "backslash in carried name: " ~ _name; + } + if (_name.canFind("..")) { + return "path traversal in carried name: " ~ _name; + } + if (_name.isAbsolute || _name[0] == '/') { + return "absolute path in carried name: " ~ _name; + } + return ""; + } + /+ ↓ a bare filename, as an image is carried: no directory part at all +/ + string validateCarriedFileName(string _name) { + string _common = _carriedNameCommon(_name); + if (_common.length > 0) { + return _common; + } + if (_name.length > MAX_CARRIED_NAME_LENGTH) { + return "carried filename too long: " ~ _name; + } + if (_name.canFind("/")) { + return "directory part in carried filename: " ~ _name; + } + if (_name[0] == '.') { + return "carried filename begins with a dot: " ~ _name; + } + if (!(_name.matchFirst(rgx_safe_carried_name))) { + return "disallowed characters in carried filename: " ~ _name; + } + return ""; + } + /+ ↓ a relative path inside a pod, as markup and conf are carried +/ + string validateCarriedPath(string _name) { + string _common = _carriedNameCommon(_name); + if (_common.length > 0) { + return _common; + } + if (_name.length > MAX_CARRIED_PATH_LENGTH) { + return "carried path too long: " ~ _name; + } + if (_name[$-1] == '/') { + return "carried path names a directory: " ~ _name; + } + size_t _depth = 0; + foreach (_c; _name) { + if (_c == '/') { + _depth++; + } + } + if (_depth > MAX_CARRIED_PATH_DEPTH) { + return "carried path too deep: " ~ _name; + } + /+ ↓ each component in turn, walked rather than split, so that this + module needs no import that would leak into a mixing scope + +/ + size_t _from = 0; + foreach (_i; 0 .. _name.length + 1) { + if (_i < _name.length && _name[_i] != '/') { + continue; + } + string _component = _name[_from .. _i]; + if (_component.length == 0) { + return "empty component in carried path: " ~ _name; + } + if (_component.length > MAX_CARRIED_NAME_LENGTH) { + return "component too long in carried path: " ~ _name; + } + if (_component[0] == '.') { + return "component begins with a dot in carried path: " ~ _name; + } + _from = _i + 1; + } + if (!(_name.matchFirst(rgx_safe_carried_path))) { + return "disallowed characters in carried path: " ~ _name; + } + return ""; + } + /+ ↓ does _path, normalised, still sit inside _root? the check on the check +/ + bool carriedPathIsWithin(string _root, string _path) { + auto _canonical_root = (_root.asNormalizedPath).array.to!string; + auto _canonical_path = (_path.asNormalizedPath).array.to!string; + if (_canonical_root.length == 0 + || _canonical_path.length <= _canonical_root.length + ) { + return false; + } + if (_canonical_path[0.._canonical_root.length] != _canonical_root) { + return false; + } + return (_canonical_path[_canonical_root.length] == '/'); + } +} |
