From 83919915d574728c401754b1c6cefbeae50247d5 Mon Sep 17 00:00:00 2001 From: Dick Snel Date: Sat, 4 Jul 2026 13:29:24 +0200 Subject: [PATCH] Add a 'raw' argument to the template formatter for verbatim inserts The :template / :T formatter runs the template engine over the inserted sub-document, so any {{ }} tokens or {{#..}} loops it contains are interpreted - and under SkipBindingAndRemoveContent unbound ones are silently removed. That corrupts documents that legitimately contain the template syntax as literal text, e.g. code samples or template-injection payloads embedded from an untrusted / user-authored source. Add an optional 'raw' argument ({{.}:template('ds.Doc','raw')}) that inserts the sub-document verbatim, skipping template processing. It composes with the existing selector argument and still imports referenced parts (images, hyperlinks), so an inserted document keeps its images while its text is preserved literally. Adds SubTemplateRawInsertTest. --- .../SubTemplateRawInsertTest.cs | 105 ++++++++++++++++++ .../Formatter/SubTemplateFormatter.cs | 49 ++++++-- 2 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 DocxTemplater.Test/SubTemplateRawInsertTest.cs diff --git a/DocxTemplater.Test/SubTemplateRawInsertTest.cs b/DocxTemplater.Test/SubTemplateRawInsertTest.cs new file mode 100644 index 0000000..d56834b --- /dev/null +++ b/DocxTemplater.Test/SubTemplateRawInsertTest.cs @@ -0,0 +1,105 @@ +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using A = DocumentFormat.OpenXml.Drawing; +using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; +using PIC = DocumentFormat.OpenXml.Drawing.Pictures; + +namespace DocxTemplater.Test +{ + [TestFixture] + public class SubTemplateRawInsertTest + { + // The 'raw' argument inserts a sub-document verbatim, WITHOUT running the template engine over it. + // This is for embedding user-authored / untrusted documents whose text may legitimately contain the + // template syntax ({{ }}, {{#..}} loops) - e.g. code samples or template-injection findings in a + // security report - which must survive as literal text rather than be interpreted (and removed). + [Test] + public void RawInsert_PreservesTemplateSyntaxVerbatim_AndImage() + { + var imageBytes = File.ReadAllBytes("Resources/testImage.jpg"); + + using var subDocStream = new MemoryStream(); + using (var subDocument = WordprocessingDocument.Create(subDocStream, WordprocessingDocumentType.Document)) + { + var subMainPart = subDocument.AddMainDocumentPart(); + subMainPart.Document = new Document(new Body()); + var imagePart = subMainPart.AddImagePart(ImagePartType.Jpeg); + using (var imageStream = new MemoryStream(imageBytes)) + { + imagePart.FeedData(imageStream); + } + var relationshipId = subMainPart.GetIdOfPart(imagePart); + subMainPart.Document.Body.Append( + new Paragraph(new Run(new Text("Literal scalar {{ds.Name}} and loop {{#each users}}{{.name}}{{/each}} must survive.") { Space = SpaceProcessingModeValues.Preserve })), + new Paragraph(new Run(CreateInlineImage(relationshipId, 990000L, 792000L)))); + subMainPart.Document.Save(); + } + + using var memStream = new MemoryStream(); + using (var wpDocument = WordprocessingDocument.Create(memStream, WordprocessingDocumentType.Document)) + { + var mainPart = wpDocument.AddMainDocumentPart(); + mainPart.Document = new Document(new Body( + new Paragraph(new Run(new Text("{{ds}:template('ds.SubDocument','raw')}"))))); + } + memStream.Position = 0; + + var docTemplate = new DocxTemplate(memStream, new ProcessSettings + { + // Even under the most aggressive error handling, raw content must not be touched. + BindingErrorHandling = BindingErrorHandling.SkipBindingAndRemoveContent + }); + // Note: "Name" and "users" are deliberately NOT bound - if the sub-document were processed they + // would be treated as unbound and removed. Under 'raw' they must survive verbatim. + docTemplate.BindModel("ds", new { SubDocument = subDocStream.ToArray() }); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.That(result, Is.Not.Null); + result.Position = 0; + + using var document = WordprocessingDocument.Open(result, false); + var mainDocumentPart = document.MainDocumentPart; + var body = mainDocumentPart.Document.Body; + + // The template syntax inside the inserted document survived as literal text. + Assert.That(body.InnerText, Does.Contain("{{ds.Name}}")); + Assert.That(body.InnerText, Does.Contain("{{#each users}}")); + Assert.That(body.InnerText, Does.Contain("{{/each}}")); + + // The embedded image is still preserved and resolvable in the raw insert. + var blip = body.Descendants().SingleOrDefault(); + Assert.That(blip, Is.Not.Null, "the inserted image should be present in the raw-merged document"); + Assert.That(blip.Embed?.Value, Is.Not.Null.And.Not.Empty); + Assert.That(mainDocumentPart.GetPartById(blip.Embed.Value), Is.InstanceOf()); + } + + private static Drawing CreateInlineImage(string relationshipId, long cx, long cy) + { + var picture = new PIC.Picture( + new PIC.NonVisualPictureProperties( + new PIC.NonVisualDrawingProperties { Id = 0U, Name = "image.jpg" }, + new PIC.NonVisualPictureDrawingProperties()), + new PIC.BlipFill( + new A.Blip { Embed = relationshipId }, + new A.Stretch(new A.FillRectangle())), + new PIC.ShapeProperties( + new A.Transform2D( + new A.Offset { X = 0L, Y = 0L }, + new A.Extents { Cx = cx, Cy = cy }), + new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle })); + + var inline = new DW.Inline( + new DW.Extent { Cx = cx, Cy = cy }, + new DW.DocProperties { Id = 1U, Name = "Picture 1" }, + new A.Graphic(new A.GraphicData(picture) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })) + { + DistanceFromTop = 0U, + DistanceFromBottom = 0U, + DistanceFromLeft = 0U, + DistanceFromRight = 0U + }; + return new Drawing(inline); + } + } +} diff --git a/DocxTemplater/Formatter/SubTemplateFormatter.cs b/DocxTemplater/Formatter/SubTemplateFormatter.cs index f2fa98e..5701fba 100644 --- a/DocxTemplater/Formatter/SubTemplateFormatter.cs +++ b/DocxTemplater/Formatter/SubTemplateFormatter.cs @@ -45,9 +45,31 @@ public void ApplyFormat(ITemplateProcessingContext templateContext, FormatterCon // after which target.GetRoot() can no longer reach the owning part. var targetPart = (target.GetRoot() as OpenXmlPartRootElement)?.OpenXmlPart; - if (formatterContext.Args.Length > 1) + // Optional args after the template name: a selector (p/run/tr/tc) and/or "raw". + // "raw" inserts the sub-document verbatim, WITHOUT running the template engine over it - for + // embedding user-authored / untrusted documents whose text may legitimately contain {{ }} that + // must survive literally (e.g. code samples or template-injection findings in a security report). + string selector = null; + var raw = false; + foreach (var arg in formatterContext.Args.Skip(1)) + { + var value = arg?.Trim(); + if (string.Equals(value, "raw", StringComparison.OrdinalIgnoreCase)) + { + raw = true; + } + else if (value is "p" or "run" or "tr" or "tc") + { + selector = value; + } + else + { + throw new OpenXmlTemplateException($"Invalid template formatter argument '{value}'"); + } + } + + if (selector != null) { - var selector = formatterContext.Args[1]; templateElement = selector switch { "p" => templateElement.Descendants().First(), @@ -58,17 +80,22 @@ public void ApplyFormat(ITemplateProcessingContext templateContext, FormatterCon }; } - // create a new Template context with replaced ModelLookup - var templateModelLookup = new ModelLookup(); - templateModelLookup.Add("ds", formatterContext.Value); - foreach (var models in templateContext.ModelLookup.Models.Skip(1)) + // Process the sub-document unless "raw" was requested. A verbatim insert skips templating, so the + // inserted content's {{ }} tokens, loops and formatters are preserved as literal text. + if (!raw) { - templateModelLookup.Add(models.Key, models.Value); + // create a new Template context with replaced ModelLookup + var templateModelLookup = new ModelLookup(); + templateModelLookup.Add("ds", formatterContext.Value); + foreach (var models in templateContext.ModelLookup.Models.Skip(1)) + { + templateModelLookup.Add(models.Key, models.Value); + } + var variableReplacer = new VariableReplacer(templateModelLookup, templateContext.ProcessSettings); + var scriptCompiler = new ScriptCompiler(templateModelLookup, templateContext.ProcessSettings); + var processor = new XmlNodeTemplate(templateElement, new TemplateProcessingContext(templateContext.ProcessSettings, templateModelLookup, variableReplacer, scriptCompiler)); + processor.Process(); } - var variableReplacer = new VariableReplacer(templateModelLookup, templateContext.ProcessSettings); - var scriptCompiler = new ScriptCompiler(templateModelLookup, templateContext.ProcessSettings); - var processor = new XmlNodeTemplate(templateElement, new TemplateProcessingContext(templateContext.ProcessSettings, templateModelLookup, variableReplacer, scriptCompiler)); - processor.Process(); // Elements cloned into the target document. Their relationship references (images, external links) // still point at the source document's parts and must be re-imported / re-mapped below.