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).
Link URI whitelist — hostile schemes can't reach the HTML
Clickable link annotations are extracted, but their targets are filtered at extraction time
(ManagedExtractionEngine.IsSafeExternalUri) before any URI is written:
- External URIs survive only if the scheme is
http://,https://,mailto:, ortel:. Ajavascript:ordata:URI (the classic PDF→HTML XSS vector) is dropped, as isfile:(which can't work in portable HTML and would leak the author's local paths). - Internal navigation is honored only for
GoToAction(same-document page jumps). Remote-file (GoToRAction) and embedded-file (GoToEAction) go-to actions are not followed. - Anything with no resolvable, safe target is skipped entirely — no element is emitted.
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.
Recommended Content-Security-Policy
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:
- Container CPU/memory limits — the primary DoS backstop.
- Reverse-proxy upload cap lower than 1 GB for your real workload.
- Request timeout / concurrency limit at the gateway, in addition to
ConversionTimeoutSecondsandMaxLiveJobs. - An external reaper on
%TEMP%/dyndyn-pdf-webas a backstop for the in-process one: a pod that dies mid-job leaves its upload and partial result behind until the next run. Wipe by age, or mount the temp dir as tmpfs and recycle the pod.
3. Deployment hardening checklist
Network & access
-
/api/*is gated by API-key middleware (DynDynPdf:ApiKeys/AllowAnonymous, see api.md); the default with no keys configured only serves loopback callers. Configure keys (orAllowAnonymous=truebehind your own gateway auth) before exposing/api/*to untrusted networks. - Job ids are not secrets; the per-job ownership
tokenreturned once byPOST /api/jobsis what gatesevents/download/previewand the cancellingDELETE(403without a matching token). This matters most forAllowAnonymous=true/open deployments where many callers share one key or none — don't log the token or forward it to anything besides the caller. See api.md. - Terminate TLS at the proxy; the app speaks plain HTTP by design.
- Cap upload size at the proxy below the 1 GB app limit for your workload.
- Apply request timeouts and a concurrency limit at the gateway.
Process & container
- Run as a non-root user (the sample Dockerfile does).
- Read-only root filesystem with a writable
/tmponly (the service writes job scratch there). - Drop all Linux capabilities — the app needs none.
- Set CPU and memory limits (primary DoS backstop until budgets ship).
- Mount the job scratch dir as tmpfs / emptyDir and recycle it, or run an external reaper.
Data handling
- Treat
%TEMP%/dyndyn-pdf-web/<jobId>as sensitive — it holds the raw upload and result. Wipe on job completion or on a schedule. - For sovereignty/regulated use, restrict egress at the network layer (conversion makes no outbound calls of its own) and rely on the self-contained output for downstream distribution.
- Use
Deterministic = truewhen you need byte-reproducible, auditable output.
Serving converted HTML
- Serve output under the CSP above (
default-src 'none'+ data URIs; addscript-src 'unsafe-inline'only for the viewer). - Prefer serving from a separate origin/subdomain so inline styles/scripts in converted documents can't touch your application's cookies or storage.
- Keep
rel="noopener noreferrer"on external links (already emitted).
Build & supply chain
-
TreatWarningsAsErrors=trueandRestorePackagesWithLockFile=trueare on — keep the lock files committed and restore with--locked-modein CI. - Pin/patch the native rendering library via
DYNDYN_PDF_NATIVE_LIBif you must vet the native binary; otherwise it is auto-restored from NuGet per RID (no manual install).
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):
MaxPagePixelsraster clamp — unit-tested inConversionLimitsTests; an e2e proof needs the native rasterizer (tests/DynDyn.Tests.Native).- Web-service DPI clamp (18–600) — only the CLI's argument validation is covered.
- Kestrel request-body cap (1 GB) — only the multipart path returns 413 under
TestServer. MaxLiveJobsadmission refusal andJobTtlMinutesjob expiry.- Bounded per-document parallelism (
jobs × per-doc ≤ cores) — unit only. - Batch failure isolation in
BatchConverter. GoToRAction/GoToEActionare not followed — no crafted document exercises them.FontValidatorfailure path (warning raised, font embedded raw, no markup injected).- "Warnings never leak into the page" — no test feeds a warning message through and asserts it is absent from the HTML.
- CLI exit codes 4 (
NativeUnavailable) and 5 (FidelityGateFailed) — need the native engine absent/present respectively. - CSP as an HTTP response header — by design the service does not set one; it is the reverse
proxy's job. The
<meta>tag in the body is what the tests check.
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