From 4392739e612d8c20f8c775de9c4b5fa8036b39df Mon Sep 17 00:00:00 2001
From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Sun, 2 Aug 2026 12:10:47 +0500
Subject: [PATCH] Fix memory-safety bugs in the ispell/hunspell dictionary
 loader.

Allocate CompoundAffix with room for its terminator, initialize the
old-format flag buffer before NIAddAffix(), and reject incomplete or
NULL Hunspell AF alias slots.  Add regression tests for the AF cases.

Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reported-by: Michael Malis <michaelmalis2@gmail.com>
Discussion: https://www.postgresql.org/message-id/19595-7dc18b4e212c4757%40postgresql.org
---
 src/backend/tsearch/Makefile                   |  4 +++-
 .../tsearch/dicts/hunspell_test_afshort.affix  |  5 +++++
 .../tsearch/dicts/hunspell_test_afshort.dict   |  1 +
 .../tsearch/dicts/hunspell_test_aftrunc.affix  |  5 +++++
 .../tsearch/dicts/hunspell_test_aftrunc.dict   |  1 +
 src/backend/tsearch/spell.c                    | 18 +++++++++++++++++-
 src/test/regress/expected/tsdicts.out          | 16 ++++++++++++++++
 src/test/regress/sql/tsdicts.sql               | 16 ++++++++++++++++
 8 files changed, 64 insertions(+), 2 deletions(-)
 create mode 100644 src/backend/tsearch/dicts/hunspell_test_afshort.affix
 create mode 100644 src/backend/tsearch/dicts/hunspell_test_afshort.dict
 create mode 100644 src/backend/tsearch/dicts/hunspell_test_aftrunc.affix
 create mode 100644 src/backend/tsearch/dicts/hunspell_test_aftrunc.dict

diff --git a/src/backend/tsearch/Makefile b/src/backend/tsearch/Makefile
index 4a436150109..9430d906122 100644
--- a/src/backend/tsearch/Makefile
+++ b/src/backend/tsearch/Makefile
@@ -18,7 +18,9 @@ DICTFILES=synonym_sample.syn thesaurus_sample.ths \
 	hunspell_sample.affix \
 	ispell_sample.affix ispell_sample.dict \
 	hunspell_sample_long.affix hunspell_sample_long.dict \
-	hunspell_sample_num.affix hunspell_sample_num.dict
+	hunspell_sample_num.affix hunspell_sample_num.dict \
+	hunspell_test_afshort.affix hunspell_test_afshort.dict \
+	hunspell_test_aftrunc.affix hunspell_test_aftrunc.dict
 
 # Local paths to dictionaries files
 DICTFILES_PATH=$(addprefix dicts/,$(DICTFILES))
diff --git a/src/backend/tsearch/dicts/hunspell_test_afshort.affix b/src/backend/tsearch/dicts/hunspell_test_afshort.affix
new file mode 100644
index 00000000000..df0656b7b50
--- /dev/null
+++ b/src/backend/tsearch/dicts/hunspell_test_afshort.affix
@@ -0,0 +1,5 @@
+COMPOUNDFLAG Z
+AF 2
+AF x
+SFX A Y 1
+SFX A 0 0/2 .
diff --git a/src/backend/tsearch/dicts/hunspell_test_afshort.dict b/src/backend/tsearch/dicts/hunspell_test_afshort.dict
new file mode 100644
index 00000000000..8c5c4c3f000
--- /dev/null
+++ b/src/backend/tsearch/dicts/hunspell_test_afshort.dict
@@ -0,0 +1 @@
+foo/2
diff --git a/src/backend/tsearch/dicts/hunspell_test_aftrunc.affix b/src/backend/tsearch/dicts/hunspell_test_aftrunc.affix
new file mode 100644
index 00000000000..f1b6e5b9513
--- /dev/null
+++ b/src/backend/tsearch/dicts/hunspell_test_aftrunc.affix
@@ -0,0 +1,5 @@
+AF 2
+AF x
+
+SFX A Y 1
+SFX A 0 s .
diff --git a/src/backend/tsearch/dicts/hunspell_test_aftrunc.dict b/src/backend/tsearch/dicts/hunspell_test_aftrunc.dict
new file mode 100644
index 00000000000..257cc5642cb
--- /dev/null
+++ b/src/backend/tsearch/dicts/hunspell_test_aftrunc.dict
@@ -0,0 +1 @@
+foo
diff --git a/src/backend/tsearch/spell.c b/src/backend/tsearch/spell.c
index 15dccb47bf5..f8454c5b89c 100644
--- a/src/backend/tsearch/spell.c
+++ b/src/backend/tsearch/spell.c
@@ -1182,12 +1182,18 @@ getAffixFlagSet(IspellDict *Conf, char *s)
 					 errmsg("invalid affix alias \"%s\"", s)));
 
 		if (curaffix > 0 && curaffix < Conf->nAffixData)
+		{
+			if (Conf->AffixData[curaffix] == NULL)
+				ereport(ERROR,
+						(errcode(ERRCODE_CONFIG_FILE_ERROR),
+						 errmsg("invalid affix alias \"%s\"", s)));
 
 			/*
 			 * Do not subtract 1 from curaffix because empty string was added
 			 * in NIImportOOAffixes
 			 */
 			return Conf->AffixData[curaffix];
+		}
 		else if (curaffix > Conf->nAffixData)
 			ereport(ERROR,
 					(errcode(ERRCODE_CONFIG_FILE_ERROR),
@@ -1422,6 +1428,13 @@ nextline:
 	tsearch_readline_end(&trst);
 	if (ptype)
 		pfree(ptype);
+
+	/* Reject incomplete AF alias table. */
+	if (Conf->useFlagAliases && curaffix != naffix)
+		ereport(ERROR,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("too few flag vector aliases (expected %d)",
+						naffix - 1)));
 }
 
 /*
@@ -1449,6 +1462,8 @@ NIImportAffixes(IspellDict *Conf, const char *filename)
 	bool		oldformat = false;
 	char	   *recoded = NULL;
 
+	flag[0] = '\0';		/* no flag seen yet */
+
 	if (!tsearch_readline_begin(&trst, filename))
 		ereport(ERROR,
 				(errcode(ERRCODE_CONFIG_FILE_ERROR),
@@ -1998,7 +2013,8 @@ NISortAffixes(IspellDict *Conf)
 	/* Store compound affixes in the Conf->CompoundAffix array */
 	if (Conf->naffixes > 1)
 		qsort(Conf->Affix, Conf->naffixes, sizeof(AFFIX), cmpaffix);
-	Conf->CompoundAffix = ptr = palloc_array(CMPDAffix, Conf->naffixes);
+	/* +1 for terminator */
+	Conf->CompoundAffix = ptr = palloc_array(CMPDAffix, Conf->naffixes + 1);
 	ptr->affix = NULL;
 
 	for (i = 0; i < Conf->naffixes; i++)
diff --git a/src/test/regress/expected/tsdicts.out b/src/test/regress/expected/tsdicts.out
index 0bbf2ff4ca2..fc56de6cc50 100644
--- a/src/test/regress/expected/tsdicts.out
+++ b/src/test/regress/expected/tsdicts.out
@@ -447,6 +447,22 @@ CREATE TEXT SEARCH DICTIONARY hunspell_err (
 						AffFile=hunspell_sample_long
 );
 ERROR:  invalid affix alias "302,301,202,303"
+-- incomplete AF table (alias references an unfilled slot)
+\set VERBOSITY terse
+CREATE TEXT SEARCH DICTIONARY hunspell_test_afshort (
+						Template=ispell,
+						DictFile=hunspell_test_afshort,
+						AffFile=hunspell_test_afshort
+);
+ERROR:  invalid affix alias "2"
+\set VERBOSITY default
+-- incomplete AF table (no reference to the missing slot)
+CREATE TEXT SEARCH DICTIONARY hunspell_test_aftrunc (
+						Template=ispell,
+						DictFile=hunspell_test_aftrunc,
+						AffFile=hunspell_test_aftrunc
+);
+ERROR:  too few flag vector aliases (expected 2)
 -- Synonym dictionary
 CREATE TEXT SEARCH DICTIONARY synonym (
 						Template=synonym,
diff --git a/src/test/regress/sql/tsdicts.sql b/src/test/regress/sql/tsdicts.sql
index cf08410bb2d..9bb2cd1a848 100644
--- a/src/test/regress/sql/tsdicts.sql
+++ b/src/test/regress/sql/tsdicts.sql
@@ -138,6 +138,22 @@ CREATE TEXT SEARCH DICTIONARY hunspell_err (
 						AffFile=hunspell_sample_long
 );
 
+-- incomplete AF table (alias references an unfilled slot)
+\set VERBOSITY terse
+CREATE TEXT SEARCH DICTIONARY hunspell_test_afshort (
+						Template=ispell,
+						DictFile=hunspell_test_afshort,
+						AffFile=hunspell_test_afshort
+);
+\set VERBOSITY default
+
+-- incomplete AF table (no reference to the missing slot)
+CREATE TEXT SEARCH DICTIONARY hunspell_test_aftrunc (
+						Template=ispell,
+						DictFile=hunspell_test_aftrunc,
+						AffFile=hunspell_test_aftrunc
+);
+
 -- Synonym dictionary
 CREATE TEXT SEARCH DICTIONARY synonym (
 						Template=synonym,
-- 
2.53.0

