💡 Full working example available on GitHub: load-untrusted-documents-safely-python

The Old Way Was Painful

You wrote three lines to render a thumbnail of an uploaded document. They looked like this, and they looked fine:

with signature.Signature(upload_path) as sign:
    save_page_preview(sign, thumbnail_path)

What those lines did, before GroupDocs.Signature 26.9, was fetch every address the document pointed at. A Word file can hold a picture it does not contain - the file stores a URL, and whatever opens it downloads that URL. On a desktop this is a feature. On a server that accepts uploads, it means the person who sent you the file decides which addresses your infrastructure requests.

The attack has a name, server-side request forgery, and three shapes worth naming. An internal address unreachable from the internet is reachable from your server, so a crafted document can make your service fetch http://169.254.169.254/ or an admin endpoint on localhost. A UNC path can prompt a Windows host to authenticate outbound, handing credentials to an attacker-controlled server. And a link to a host that simply never answers holds the loading thread until it times out, which is a cheap way to exhaust a worker pool with documents that look harmless.

Nothing there is a bug in the document library. Following a link is what the format asks for. The uncomfortable part was that obliging was the default, in code nobody would flag in review.

There’s a Better Way

Safe document loading is the GroupDocs.Signature behaviour for Python that declines to make those requests. From version 26.9, LoadOptions.skip_external_resources defaults to True, so the same three lines now fetch nothing and render a placeholder where the linked picture would be.

The change is a default rather than a new feature - the property already existed. What 26.9 altered is which way it points when your code says nothing, which is the only setting most services ever use.

The New Way: Three Load Modes

Step 1 - Keep the default for anything untrusted

No LoadOptions at all:

with signature.Signature(source_path) as sign:
    return save_page_preview(sign, preview_path)

Nothing is requested. The preview is smaller than it would otherwise be, and that size difference is the most convenient proof available that no request left the machine.

Step 2 - Whitelist a host you actually own

Plenty of documents link somewhere legitimate: a company CDN, an internal image server, a template store. Allow that and nothing else:

load_options = LoadOptions()
load_options.whitelisted_resources = [trusted_address]

with signature.Signature(source_path, load_options) as sign:
    return save_page_preview(sign, preview_path)

The matching rule deserves attention. It is a case-insensitive substring test against the resource address, which makes a short fragment dangerous: github matches github.attacker.example/payload.png as readily as the host you intended. Use a scheme, a host and a path - this sample whitelists raw.githubusercontent.com/groupdocs-signature/.

Step 3 - Allow everything, deliberately

The pre-26.9 behaviour, still available:

load_options = LoadOptions()
load_options.skip_external_resources = False

Reasonable for documents your own application produced. One trap: the obsolete load_external_resources property has the opposite polarity, so skip_external_resources = False is what replaces load_external_resources = True. Copy a value across from the old property and you invert your security posture with no error to tell you.

Side-by-Side: Before vs. After

Same document, same code path, three load policies. These are the sizes of the files committed in the sample’s Result/ folder, so they can be checked rather than taken on trust:

Load mode Preview size Outbound requests
default (26.9 and later) 16,435 bytes none
whitelisted host 51,738 bytes one, to the allowed address
all resources (pre-26.9 default) 51,738 bytes one per linked resource

The linked picture is 35,303 bytes of that difference. I did not trust the setting until those two numbers were side by side, and I would suggest the same: reading the property back tells you what you configured, not what the process did.

What Counts as an External Resource?

Narrower than people expect, which is why the upgrade is usually uneventful. Linked pictures rather than embedded ones, INCLUDEPICTURE fields, linked pictures in presentations and spreadsheets, and the images and style sheets an SVG references. Embedded content is untouched, because it is already inside the file and no request is needed to render it.

That distinction is the whole security boundary. A document can only make your server reach out if it stores an address instead of the bytes, so the question for any corpus is simply how many of its files link rather than embed. If none do, the new default costs you nothing and you can upgrade without reading further.

Real-World Example: An Upload That Gets Signed

The case the default change exists for. A document arrives from outside, and you need to put a signature on it:

with signature.Signature(source_path) as sign:
    options = QrCodeSignOptions("Approved by GroupDocs.Signature")
    options.encode_type = QrCodeTypes.QR
    options.left = 400
    options.top = 50
    options.width = 120
    options.height = 120

    result = sign.sign(output_path, options)

No external resource is requested while the document is loaded, signed or saved. The signed output keeps its link, so a user who opens it in Word later still sees the picture resolved on their own machine. Skipping is a server-side policy, not an edit to the document - which is exactly what makes it safe to apply to files you are handling on someone else’s behalf.

What Else Changes When You Upgrade?

For most services, nothing visible, which is worth stating plainly because a security default that altered behaviour everywhere would not survive an upgrade review. Signing, verification and search are untouched. The exception is a preview that used to show a linked picture and now shows a placeholder - the change doing its job. Whitelist the host if it is yours, accept it if not.

Worth calling out separately: SVG. An SVG can reference images and style sheets by URL, those references are external resources under the same rule, and SVG is both a common upload format and a common SSRF vector. A service that accepts SVG avatars and renders them server-side is precisely the shape of system this change protects.

One Python detail: how the preview gets written

PreviewOptions takes two stream factories rather than a path, and plain Python callables are all it needs:

def create_page_stream(page_data):
    return open(preview_path, "wb")

def release_page_stream(page_data, page_stream):
    page_stream.close()

preview_options = PreviewOptions(create_page_stream, release_page_stream)
preview_options.preview_format = PreviewOptions.PreviewFormats.PNG
sign.generate_preview(preview_options)

One creates a stream per page, the other releases it. The sample document has a single page, so one file is written; for multi-page input, include the page number in the name or every page overwrites the last.

Conclusion

The default flipped so that the risky behaviour needs an explicit decision and the safe one needs nothing. Keep the default for untrusted input, whitelist narrowly where your own hosts are involved, and remember that signing never needed the network at all.

If you want a stronger check than file size, point a test document at a host you control and watch its access log while the preview runs. Size tells you whether bytes arrived; the access log tells you whether a request was made at all, and those differ in exactly the case that matters - a whitelisted host that happens to be unreachable looks identical to a blocked one from the output alone.

Running the sample against one of your own documents takes a minute and tells you, in three file sizes, exactly what your service has been fetching on behalf of whoever sent you the file.

Additional Resources