CREATE EXTENSION pg_htmldoc; -- pg_htmldoc.c gates the pg_write_server_files-equivalent write path, -- htmldoc_addhtml(), and the whitelist-bypass "privileged" flag entirely on -- superuser() (see require_superuser()/htmldoc_addfile()/htmldoc_addurl() -- in pg_htmldoc.c) -- no predefined-role membership is consulted at all. -- This session (pg_htmldoc_test_orig_user, captured below) is a superuser, -- so it exercises the privileged path directly, with no role setup needed. -- One additional role, genuinely non-superuser, is used below for the -- unprivileged/whitelist path -- it needs LOGIN, since those tests need to -- \c into it directly (ALTER ROLE ... SET only takes effect for a new -- connection as that role, not retroactively via SET ROLE in an -- already-open session). SELECT current_user AS pg_htmldoc_test_orig_user \gset CREATE ROLE htmldoc_test_none LOGIN; -- -- No document queued: convert2pdf()/convert2pdf(file)/convert2ps()/ -- convert2ps(file) all check for a queued document before anything -- else -- before even require_superuser() or the NULL-argument check -- -- so calling any of them with nothing queued yet must fail with the -- same "!document" internal error regardless of call form. -- SELECT convert2pdf(); SELECT convert2pdf('/tmp/pg_htmldoc_test_no_document.pdf'); SELECT convert2ps(); SELECT convert2ps('/tmp/pg_htmldoc_test_no_document.ps'); -- -- Without superuser, every entry point must be denied by default (no -- pg_htmldoc.whitelist is configured for htmldoc_test_none yet): the -- render pipeline can resolve // references -- (and htmldoc_addfile/htmldoc_addurl arguments) to either a local file -- or a URL fetch, regardless of which function was called. -- SET ROLE htmldoc_test_none; SELECT htmldoc_addhtml('no privilege'); SELECT htmldoc_addfile('/etc/hostname'); SELECT htmldoc_addurl('/etc/hostname'); RESET ROLE; -- -- htmldoc_addfile()/htmldoc_addurl() name a single concrete file/URL up -- front, so pg_htmldoc.whitelist can grant htmldoc_test_none access despite -- it not being superuser -- the whitelist becomes its only authorization -- for that specific file/URL, rather than merely narrowing an -- already-privileged caller the way it does for a superuser further down. -- htmldoc_addhtml() has no equivalent: whatever local files or URLs its -- HTML ends up referencing are resolved deep inside libhtmldoc's rendering -- pipeline, never through a point pg_htmldoc.whitelist can see, so -- superuser stays mandatory for it regardless of whitelist (confirmed -- above). Needs \c, like the whitelist tests further down, since -- ALTER ROLE ... SET doesn't apply retroactively via SET ROLE in this -- already-open session. -- ALTER ROLE htmldoc_test_none SET pg_htmldoc.whitelist = 'file:///etc/hostname'; \c - htmldoc_test_none SELECT htmldoc_addfile('/etc/hostname'); SELECT octet_length(convert2pdf()) > 100 AS whitelist_grants_unprivileged_pdf_nonempty; SELECT htmldoc_addfile('/etc/passwd'); -- -- Still enforced *before* the fetch for the unprivileged-via-whitelist path -- too: htmldoc_test_none's whitelist above has no http(s):// entry at all, -- so this is rejected without ever attempting to reach 192.0.2.1 (the RFC -- 5737 reserved, non-routable documentation range). -- SELECT htmldoc_addurl('http://192.0.2.1/forbidden'); \c - :pg_htmldoc_test_orig_user ALTER ROLE htmldoc_test_none RESET pg_htmldoc.whitelist; -- -- Build a real document as superuser, then confirm the unprivileged role -- still can't write it to a server file, and that superuser can. -- SELECT htmldoc_addhtml('

pg_htmldoc regression test

'); SET ROLE htmldoc_test_none; SELECT convert2pdf('/tmp/pg_htmldoc_test_denied.pdf'); RESET ROLE; SELECT convert2pdf('/tmp/pg_htmldoc_test_out.pdf'); -- -- convert2ps(file text) -- the file-output variant of convert2ps(), -- as opposed to the bytea-returning convert2ps() exercised below -- -- was never exercised at all, unlike its convert2pdf(file) sibling -- above. Mirror the same denied/success pair for it. -- SELECT htmldoc_addhtml('ps file test'); SET ROLE htmldoc_test_none; SELECT convert2ps('/tmp/pg_htmldoc_test_denied.ps'); RESET ROLE; SELECT convert2ps('/tmp/pg_htmldoc_test_out.ps'); -- -- htmldoc_addfile() was, until now, only ever exercised for its denial -- (via the htmldoc_test_none block above) -- its success path for -- superuser was never actually run. Confirm it end-to-end via -- the in-memory (bytea) output path, same as the addurl/addhtml -- checks below. -- SELECT htmldoc_addfile('/etc/hostname'); SELECT octet_length(convert2pdf()) > 100 AS addfile_pdf_nonempty; -- -- htmldoc_addfile() with a path that resolves locally (no "http:"/ -- "https:"/"//" prefix) but doesn't exist on disk: file_find() (in -- the vendored htmldoc library) returns NULL, and read_fileurl() -- surfaces that as its own internal error rather than silently doing -- nothing. -- SELECT htmldoc_addfile('/nonexistent/pg_htmldoc_test_missing_file'); -- -- htmldoc_addurl() resolves a plain local path exactly the way -- htmldoc_addfile() does (no "http:"/"https:"/"//" prefix), so it needs -- superuser too; confirm success end-to-end via the in-memory (bytea) -- output path, for both PDF and PS. -- SELECT htmldoc_addurl('/etc/hostname'); SELECT octet_length(convert2pdf()) > 100 AS pdf_nonempty; SELECT htmldoc_addhtml('ps test'); SELECT octet_length(convert2ps()) > 100 AS ps_nonempty; -- -- Multiple add* calls before a single convert* chain their documents -- together (via document->next/->prev, set up identically in -- read_fileurl()/read_html()) rather than each new add* replacing the -- previous one. htmldoc flows chained documents continuously (no -- forced page break between them), so output size isn't a reliable -- signal -- a second copy of the same filler content mostly just -- fills out remaining whitespace on the last page. Count PDF page -- objects instead (a direct, unambiguous signal of how much content -- actually got rendered) and confirm a second add* produces more -- pages than a single one. Counting is done on the raw bytea (via a -- manual position()/substring() scan) rather than by casting to text, -- since compressed PDF content can contain embedded NUL bytes that -- text values can't hold. -- DO $$ DECLARE needle bytea := convert_to('/Type/Page', 'LATIN1'); single_pdf bytea; combined_pdf bytea; single_pages integer; combined_pages integer; pos integer; BEGIN PERFORM htmldoc_addhtml('

multi-document test

' || repeat('filler text. ', 400) || '

'); single_pdf := convert2pdf(); PERFORM htmldoc_addhtml('

multi-document test, part one

' || repeat('filler text. ', 400) || '

'); PERFORM htmldoc_addhtml('

multi-document test, part two

' || repeat('filler text. ', 400) || '

'); combined_pdf := convert2pdf(); single_pages := 0; LOOP pos := position(needle in single_pdf); EXIT WHEN pos = 0; single_pages := single_pages + 1; single_pdf := substring(single_pdf from pos + length(needle)); END LOOP; combined_pages := 0; LOOP pos := position(needle in combined_pdf); EXIT WHEN pos = 0; combined_pages := combined_pages + 1; combined_pdf := substring(combined_pdf from pos + length(needle)); END LOOP; IF combined_pages <= single_pages THEN RAISE EXCEPTION 'combined two-document render (% pages) has no more pages than a single document (% pages) -- second add* may not have been included', combined_pages, single_pages; END IF; END $$; -- -- Cleanup on rollback: an add* call that fails *after* a document has -- already been chained onto `document` and its MemoryContextCallback -- registered -- unlike the NULL-argument and missing-file checks -- above, which both fail before either happens -- must still leave -- things clean once the aborted statement unwinds: -- documentMemoryContextCallbackFunction() tears the tree down and -- resets `document` to NULL (PG >= 9.5 only -- see the -- PG_VERSION_NUM guard in pg_htmldoc.c; older versions have no such -- callback, so the check below is skipped there). htmldoc_addfile() -- on a file that exists (so file_find() succeeds and the tree/ -- callback get set up) but isn't readable by this role reaches -- exactly that path: fopen() in read_fileurl() is the only failure -- point left after the tree is chained in. -- SELECT htmldoc_addfile('/etc/shadow'); DO $$ BEGIN IF current_setting('server_version_num')::int >= 90500 THEN BEGIN PERFORM convert2pdf(); RAISE EXCEPTION 'convert2pdf() succeeded after a failed htmldoc_addfile() -- the half-built document from the failed call was not cleaned up'; EXCEPTION WHEN OTHERS THEN IF SQLERRM IS DISTINCT FROM '!document' THEN RAISE; END IF; END; END IF; END $$; -- -- pg_htmldoc.whitelist (see the pg_whitelist submodule) adds an optional -- restriction on top of the superuser check above: even a superuser can be -- scoped down to specific file:// / http(s):// prefixes. pg_htmldoc.whitelist -- is registered PGC_SUSET, so a superuser session can enable it with a -- plain SET -- no reconnect needed, unlike the role-level defaults used for -- htmldoc_test_none elsewhere in this file. -- -- -- An exact file:// entry permits only that file. -- SELECT set_config('pg_htmldoc.whitelist', 'file:///etc/hostname', false); SELECT htmldoc_addfile('/etc/hostname'); SELECT octet_length(convert2pdf()) > 100 AS whitelist_exact_match_pdf_nonempty; SELECT htmldoc_addfile('/etc/passwd'); -- -- A file:// entry with a trailing slash permits anything under that -- directory, but the resolved path is realpath()-canonicalized before -- comparison, so ".." can't be used to climb back out of it even though the -- raw string would otherwise start with the whitelisted prefix. -- SELECT set_config('pg_htmldoc.whitelist', 'file:///etc/ssl/', false); SELECT htmldoc_addfile('/etc/ssl/openssl.cnf'); SELECT octet_length(convert2pdf()) > 100 AS whitelist_directory_match_pdf_nonempty; SELECT htmldoc_addfile('/etc/hostname'); SELECT htmldoc_addfile('/etc/ssl/../passwd'); -- -- http(s):// entries are enforced *before* the URL is fetched -- -- pg_whitelist_check_url() runs ahead of htmldoc's file_find() in -- read_fileurl(), specifically so a disallowed host is never actually -- contacted. 192.0.2.0/24 is the RFC 5737 documentation range: reserved and -- guaranteed non-routable, so if the fetch were attempted anyway (i.e. if -- this ordering regressed) the error below would come back as an internal -- "!file_find" failure instead of the permission-denied error this checks -- for -- and it would take a while doing it too, since htmldoc's HTTP -- client retries with real connect timeouts. -- SELECT set_config('pg_htmldoc.whitelist', 'https://example.com/', false); SELECT htmldoc_addurl('http://192.0.2.1/forbidden'); SELECT set_config('pg_htmldoc.whitelist', '', false); -- -- pg_htmldoc.whitelist is registered PGC_SUSET, so only a superuser can set -- it -- confirm that htmldoc_test_none, connected directly (not merely SET -- ROLE'd) and thus genuinely non-superuser, can't loosen its own scope with -- a plain SET. -- ALTER ROLE htmldoc_test_none SET pg_htmldoc.whitelist = 'file:///etc/hostname'; \c - htmldoc_test_none -- -- pg_htmldoc.so isn't actually loaded into this fresh backend yet -- that -- only happens on the first call into it, below -- so pg_htmldoc.whitelist -- is still just an unenforced placeholder GUC at this point, not yet -- reconciled with the PGC_SUSET definition pg_whitelist_init() registers. -- The PGC_SUSET check further down deliberately runs after this, once the -- module (and the real GUC definition) is guaranteed to be loaded. -- SELECT htmldoc_addfile('/etc/hostname'); SELECT octet_length(convert2pdf()) > 100 AS whitelist_reconnect_pdf_nonempty; DO $$ BEGIN BEGIN PERFORM set_config('pg_htmldoc.whitelist', 'file:///etc/passwd', false); RAISE EXCEPTION 'htmldoc_test_none was able to loosen its own pg_htmldoc.whitelist with a plain SET'; EXCEPTION WHEN insufficient_privilege THEN NULL; END; END $$; SELECT htmldoc_addfile('/etc/passwd'); \c - :pg_htmldoc_test_orig_user ALTER ROLE htmldoc_test_none RESET pg_htmldoc.whitelist; -- -- Required arguments: htmldoc_addhtml/addfile/addurl and the -- file-output convert2pdf(file)/convert2ps(file) all reject a NULL -- argument outright. The PG_ARGISNULL(0) check runs before -- require_superuser() in every one of them, so this isolates the -- NULL check from the permission check. convert2pdf/convert2ps need -- a document already queued to reach their own NULL check instead of -- the (separate, untested) "no document queued" error, hence the -- addhtml() in between. -- SELECT htmldoc_addhtml(NULL); SELECT htmldoc_addfile(NULL); SELECT htmldoc_addurl(NULL); SELECT htmldoc_addhtml('null file arg test'); SELECT convert2pdf(NULL); SELECT convert2ps(NULL); DROP ROLE htmldoc_test_none; DROP EXTENSION pg_htmldoc;