← All docs

Security & Output Safety

A PDF is an untrusted document: it can carry hostile URIs, scripts, and adversarial content streams. DynDyn.Pdf turns such a document into HTML that is safe to open in a browser and safe to serve to third parties. This page documents the output-safety posture, the resource-abuse surface, and a deployment hardening checklist.


1. Output-safety posture

Live, selectable text — never a screenshot

Text is emitted as real, positioned HTML (<span>/semantic elements) drawn with subset web fonts synthesized from the PDF's own glyph outlines. The document stays machine-readable and accessible — this is a hard constraint for regulated output such as ESEF filings. Even when a page's graphics are rasterized for correct compositing (--background raster or raster fallback), the text layer stays live HTML on top of the raster.

Every dynamic string is HTML-encoded

All text pulled from the PDF is passed through WebUtility.HtmlEncode before it enters the output — run text, the document title, bookmark/TOC labels, link labels, and link href/title attributes. A <script> (or any markup) sitting in the source text becomes inert, encoded characters. Verified in code: text runs (HtmlPageComposer), titles/labels (ViewerOutputWriter, MultiPageOutputWriter, HtmlTemplates), and link attributes (HtmlPageComposer.AppendLinks).

Clickable link annotations are extracted, but their targets are filtered at extraction time (ManagedExtractionEngine.IsSafeExternalUri) before any URI is written:

Surviving external links are rendered with target="_blank" rel="noopener noreferrer" and their URI is additionally HTML-encoded, so even a whitelisted URL can't break out of the attribute.

Self-contained, no external references

single/viewer output inlines fonts, images, and SVG as data: URIs. There are no outbound references in the artifact — no remote fonts, trackers, or beacons — so opening the HTML leaks nothing and works offline. This makes a strict Content-Security-Policy trivial to apply.

Fonts are validated, and untrusted font bytes are contained

Synthesized fonts run through FontValidator; a font that fails is surfaced as a ConversionWarning and embedded raw so browsers' OpenType Sanitizer (OTS) can reject/fall back gracefully. No font failure aborts the document or injects markup.

Warnings never leak into the page

ConversionWarnings are logged and returned on ConversionResult — never embedded in the output HTML — so diagnostic text (which may echo document content) can't become an injection channel.

Because the output is fully self-contained, serve it under a tight CSP. A single-file artifact needs inline styles and data URIs but no scripts; the viewer app needs inline script for navigation:

# single / per-page / multi-page output (no scripts needed)
Content-Security-Policy:
  default-src 'none';
  img-src 'self' data:;
  font-src data:;
  style-src 'unsafe-inline';
  base-uri 'none';
  form-action 'none';

# viewer output (adds inline navigation script)
Content-Security-Policy:
  default-src 'none';
  img-src 'self' data:;
  font-src data:;
  style-src 'unsafe-inline';
  script-src 'unsafe-inline';
  base-uri 'none';
  form-action 'none';

Adjust img-src/font-src if you host assets externally rather than inlining. The whitelist above already guarantees any surviving href is http(s)/mailto/tel, so navigation is safe under default-src 'none'.


2. Resource budgets & abuse surface

Conversion is CPU-bound and memory scales with page complexity and DPI. A hostile or pathological PDF (huge page count, enormous images, deeply nested XObjects) is a denial-of-service surface. Current built-in bounds:

Bound Where Value Verified by
Page count ConversionOptions.MaxPages (Web: MaxPagesPerJob) 10 000; extra pages dropped, PagesTruncated warning, PagesExpected keeps the real count e2e SecurityE2eTests.PageCountBomb_TruncatesAtMaxPages_WithWarningAndWellFormedHtml
Estimated model size ConversionOptions.MaxEstimatedModelBytes 2 GB; trailing pages dropped with a warning e2e SecurityE2eTests.ModelByteBomb_TruncatesToFittingPages_WithByteBudgetWarning
Raster page size ConversionOptions.MaxPagePixels 40 M device px per rasterized page; DPI clamped, RasterDeviceSizeClamped unit ConversionLimitsTests; e2e on the managed engine only proves the documented scope (SecurityE2eTests.PixelBomb_ManagedEngineWithoutRasterizer_CompletesWithoutRasterClamp) — the clamp itself is untested e2e
Wall-clock ConversionOptions.ConversionTimeout; CLI --timeout; Web ConversionTimeoutSeconds (600 s) off by default in-process, on by default in the Web service e2e SecurityE2eTests.ConversionTimeout_ThrowsOperationCanceled; CLI SecurityExitCodeTests.Timeout_HeavyDocumentWithMinimalTimeout_ReturnsTimeout (exit 7); Web SecurityWebE2eTests.Timeout_ConversionExceedsLimit_TerminatesWithTimeoutError
Background image size cap ConversionOptions.MaxImageDimension 4096 device px/axis (resampled down) e2e SecurityE2eTests.OversizedImage_ClampedToMaxImageDimension
Render DPI clamp CLI argument validation; Web service 18–600 CLI e2e SecurityExitCodeTests.InvalidArguments_DpiTooHigh_ReturnsInvalidArguments / _DpiTooLow_ (exit 2); Web clamp untested
Upload size cap Web service (Kestrel + multipart), MaxUploadBytes 1 GB e2e SecurityWebE2eTests.Upload_ExceedingMaxUploadBytes_Returns413 (multipart path); the Kestrel request-body cap is untested — TestServer bypasses it
Request rate / live jobs Web RateLimitRequestsPerMinute (120), MaxLiveJobs (200) 429 once the window is exhausted; new jobs refused at the cap e2e SecurityWebE2eTests.RateLimit_FourthRequestWithinWindow_Returns429; MaxLiveJobs untested
Job expiry Web JobTtlMinutes (30) via JobCleanupService (CleanupIntervalSeconds) finished jobs and their scratch dir are reaped untested
Per-document parallelism MaxDegreeOfParallelism / batch derivation bounded so jobs × per-doc ≤ cores unit only — untested e2e
Failure isolation (batch) BatchConverter one document faulting never sinks the batch untested

The defaults in the first column are pinned by SecurityE2eTests.Defaults_MatchDocumentedBudgets.

Not yet enforced in-process: a total output-size cap. Everything else above is on by default or one option away; still impose the outer limits externally, because a budget only bounds what the converter meant to do:


3. Deployment hardening checklist

Network & access

Process & container

Data handling

Serving converted HTML

Build & supply chain


4. Reporting

For the current build, the item to track before exposing the service publicly is a total output-size cap (see configuration.md). Page, model, time and image budgets, job expiry and the live-job cap are enforced in-process today (section 2). Authentication is implemented today (API-key middleware, see api.md); everything in section 1 (output safety) is enforced today, on every conversion, regardless of caller. Section 5 lists which of these claims an end-to-end test actually proves.


5. Verified by — test coverage map

Each guarantee above maps to the end-to-end test that exercises it through a public surface (DocumentConverter API with the managed engine, the CLI in-process, or the Web service under WebApplicationFactory). "e2e" means the assertion is made on the emitted HTML, the exit code, or the HTTP response — never on an internal helper. Guarantees with no such test are listed explicitly below the table.

Guarantee (section 1) Test Level
Live, selectable text on every page SecurityE2eTests.OutputHardening_RealFiles_NoScriptNoEventHandlers_SafeSchemesOnly_LiveText — four real reports, at least one text span on every emitted page e2e
Title, metadata, page labels HTML-encoded, never raw SecurityPathSafetyTests.HostileTitle_HtmlEncodedInSingleFileOutput_NeverInFileNames, HostileTitle_HtmlEncodedInMultiPageIndex_NeverInFileNames, HostileMetadata_HtmlEncodedInViewerInfoPanel, HostilePageLabel_HtmlEncodedInViewer_NeverRaw e2e
Run text and link attributes HTML-encoded — no <script>, no on*= handler survives SecurityE2eTests.OutputHardening_RealFiles_… (real files), HostileLinks_UnsafeSchemesNeverEmitted_SafeSchemesRenderedHardened (crafted href/title payloads) e2e
Link URI whitelist: http(s), mailto:, tel: kept; javascript:, data:, file:, vbscript: dropped with no element emitted SecurityE2eTests.HostileLinks_UnsafeSchemesNeverEmitted_SafeSchemesRenderedHardened e2e
Surviving external links carry target="_blank" rel="noopener noreferrer" same test e2e
Self-contained output — every src/href is data:, a fragment, a relative asset, or a whitelisted external link SecurityE2eTests.OutputHardening_RealFiles_…_SafeSchemesOnly_… e2e
CSP <meta> present in single-file, every multi-page HTML file, and the viewer (viewer policy adds script-src 'unsafe-inline', and only inline scripts exist) SecurityCspE2eTests.SingleFile_HasTheDocumentCsp, MultiPage_EveryEmittedHtmlFile_CarriesTheDocumentCsp, Viewer_HasExactlyOneCspMeta_WithTheDocumentedViewerPolicy_AndOnlyInlineScripts; over HTTP SecurityWebE2eTests.Preview_And_Download_CarryDocumentedCspMetaTag e2e
Hostile font names, titles and labels never escape OutputDirectory or reach a file name SecurityPathSafetyTests.HostileBaseFont_NeverEscapesOutputDirectory_MultiPageWriter plus the two _NeverInFileNames tests e2e
Malformed or hostile input never crashes, hangs, stack-overflows or exhausts memory — only typed exceptions or a warning-bearing result FuzzCorpusTests.Corpus_NeverHangsOrCrashesUnhandled (Category=Fuzz, whole tests/pdfs_fuzz corpus, per-file wall-clock and working-set ceilings, committed outcome distribution); SecurityE2eTests.FlateDecompressionBomb_…, LzwDecompressionBomb_…, CyclicPageTree_NeverHangsOrCrashes, DeepNestingInCatalog_ThrowsTypedException_NeverStackOverflow, DeepNestingInPage_RecoversWithWarning_OrThrowsTyped_NeverHangs; SecurityFuzzRegressionTests pins the three corpus files that escaped with a raw IndexOutOfRangeException before they were fixed (fast, runs in the default suite) e2e
Failure classes map to the documented CLI exit codes, with no stack trace on stdout/stderr SecurityExitCodeTests — Malformed_* (1), InvalidArguments_* (2), MissingInputPath_/GlobMatchingNothing_ (3), Encrypted_* (6), Timeout_* (7), UnsupportedEncryption_AdobePubSec_ (8), Success_FnacPageOne_ (0) e2e
Encrypted input: missing/wrong/empty user password is a typed PasswordRequiredException with PasswordWasSupplied set correctly; owner-only files open fully; never a silent one-page result SecurityEncryptionE2eTests (r2/r3/r5/r6, pdfium adversarial corpus, fuzzed owner-only file — 50 cases); CLI Encrypted_NoPassword_/Encrypted_WrongPassword_ReturnsPasswordRequired e2e
Web: /api/* needs a valid X-Api-Key; the key never appears in logs, bodies or headers SecurityWebE2eTests.Auth_NoKeyPresented_Returns401, Auth_WrongKeyPresented_Returns401, Auth_CorrectKeyPresented_IsNotUnauthorized, Auth_HealthEndpoint_NeedsNoKey, ApiKey_NeverAppearsInLogsBodyOrHeaders_FullRoundTrip, ApiKey_WrongKeyNotEchoedInUnauthorizedBody e2e
Web: non-PDF uploads rejected (header sniffed within the first 1 KiB) SecurityWebE2eTests.NonPdf_TextBytes_Returns400, NonPdf_HeaderAfterScanWindow_Returns400, NonPdf_EmptyFile_Returns400 e2e
Web: malformed PDF ends the job with a sanitized error, no exception text SecurityWebE2eTests.MalformedPdf_ValidHeaderJunkBody_TerminatesWithSanitizedError e2e

Resource budgets are mapped row by row in the section 2 table.

Untested (no end-to-end test exercises the claim):

Running the suite

# fast security tests (Integration, excluding the corpus fuzz run — seconds)
dotnet test tests/DynDyn.Tests.Integration --filter "Category!=Fuzz"

# whole fuzz corpus (about 15 min at 4-way parallelism); DYNDYN_FUZZ_LIMIT=<n> caps the file count,
# DYNDYN_UPDATE_SNAPSHOTS=1 regenerates Snapshots/Fuzz/fuzz_distribution.txt
dotnet test tests/DynDyn.Tests.Integration --filter "Category=Fuzz"

dotnet test tests/DynDyn.Tests.Cli
dotnet test tests/DynDyn.Tests.Web      # needs the .NET 10 ASP.NET runtime; the tests self-skip without it