Commit 6c82a02fa03 for woocommerce

commit 6c82a02fa03d4ca5e485801c1eea4ae84b6c01b2
Author: Andrew Matia <73502748+drewmt@users.noreply.github.com>
Date:   Thu Aug 20 18:41:29 2026 +0300

    Unify postcode validation between PHP and Checkout blocks (#67780)

    * Unify postcode validation rules

    * Refine shared postcode validation compatibility

    * Guard postcode validation runtime inputs

    * Guard shared postcode rule lookup

diff --git a/plugins/woocommerce/changelog/66953-unify-postcode-validation b/plugins/woocommerce/changelog/66953-unify-postcode-validation
new file mode 100644
index 00000000000..a687942769c
--- /dev/null
+++ b/plugins/woocommerce/changelog/66953-unify-postcode-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Unify postcode validation rules used by the server and Checkout blocks.
diff --git a/plugins/woocommerce/client/blocks/bin/generate-postcode-validation-rules.js b/plugins/woocommerce/client/blocks/bin/generate-postcode-validation-rules.js
new file mode 100644
index 00000000000..b4d3a61105f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/bin/generate-postcode-validation-rules.js
@@ -0,0 +1,181 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+
+/**
+ * External dependencies
+ */
+const fs = require( 'fs' );
+const path = require( 'path' );
+const {
+	POSTCODE_REGEXES,
+} = require( 'postcode-validator/lib/cjs/postcode-regexes.js' );
+const { version } = require( 'postcode-validator/package.json' );
+const { dependencies } = require( '../package.json' );
+
+const OUTPUT_PATH = path.resolve(
+	__dirname,
+	'../../../i18n/postcode-validation-rules.json'
+);
+
+// Countries with an explicit server-side rule or a Blocks override before the
+// validators were unified. Country expansion is intentionally handled
+// separately.
+const COUNTRY_CODES = [
+	'AT',
+	'BA',
+	'BE',
+	'BR',
+	'CA',
+	'CH',
+	'CZ',
+	'DE',
+	'DK',
+	'EE',
+	'ES',
+	'FI',
+	'FR',
+	'GB',
+	'HU',
+	'IE',
+	'IN',
+	'IT',
+	'JP',
+	'KH',
+	'LI',
+	'LV',
+	'MN',
+	'NI',
+	'NL',
+	'NO',
+	'PL',
+	'PR',
+	'PT',
+	'SE',
+	'SI',
+	'SK',
+	'US',
+];
+
+// Preserve WooCommerce's existing explicit validation behaviour where it is
+// intentionally different from the upstream package. These overrides are
+// applied only while generating the shared artifact.
+const COMPATIBILITY_OVERRIDES = {
+	AT: { pattern: '[0-9]{4}' },
+	BA: { pattern: '[7-8][0-9]{4}' },
+	CA: {
+		pattern:
+			'[ABCEGHJKLMNPRSTVXY][0-9][ABCEGHJKLMNPRSTVWXYZ] ?[0-9][ABCEGHJKLMNPRSTVWXYZ][0-9]',
+		flags: 'i',
+	},
+	CZ: { pattern: '(?:CZ-)?[0-9]{3}\\s?[0-9]{2}' },
+	DE: { pattern: '(?:0[1-9]|[1-9][0-9])[0-9]{3}' },
+	DK: { pattern: '(?:DK-)?(?:[1-24-9]\\d{3}|3[0-8]\\d{2})' },
+	ES: { pattern: '[0-9]{5}' },
+	FI: { pattern: '[0-9]{5}' },
+	FR: { pattern: '[0-9]{5}', flags: 'i' },
+	GB: {
+		pattern:
+			'(?:[abcdefghijklmnoprstuwyz][abcdefghklmnopqrstuvwxy]?[0-9]{1,2}[0-9][abdefghjlnpqrstuwxyz]{2}|[abcdefghijklmnoprstuwyz][0-9][abcdefghjkpstuw][0-9][abdefghjlnpqrstuwxyz]{2}|[abcdefghijklmnoprstuwyz][abcdefghklmnopqrstuvwxy][0-9][abehmnprvwxy][0-9][abdefghjlnpqrstuwxyz]{2}|gir0aa|bfpo[0-9]{1,4}|bfpoc\\/o[0-9]{1,3})',
+		flags: 'i',
+		normalization: 'removeSpaces',
+	},
+	IE: {
+		pattern: '(?:[AC-FHKNPRTV-Y][0-9]{2}|D6W)[0-9AC-FHKNPRTV-Y]{4}',
+		flags: 'i',
+		normalization: 'removeSpacesAndHyphens',
+	},
+	IN: { pattern: '[1-9][0-9]{2}\\s?[0-9]{3}' },
+	JP: { pattern: '[0-9]{3}-?[0-9]{4}' },
+	KH: { pattern: '[0-9]{6}' },
+	LI: { pattern: '94[8-9][0-9]' },
+	LV: { pattern: '(?:LV[- ]?)?[1-9][0-9]{3}', flags: 'i' },
+	MN: { pattern: '[0-9]{5}(?:-[0-9]{4})?' },
+	NI: { pattern: '[1-9][0-9]{4}' },
+	NL: {
+		pattern: '[1-9][0-9]{3}\\s?(?!SA|SD|SS)[A-Z]{2}',
+		flags: 'i',
+	},
+	PR: { pattern: '[0-9]{5}(?:-[0-9]{4})?', flags: 'i' },
+	PT: { pattern: '[0-9]{4}-[0-9]{3}' },
+	SE: { pattern: '(?:SE-)?[0-9]{3}\\s?[0-9]{2}' },
+	SI: { pattern: '[1-9][0-9]{3}' },
+	SK: { pattern: '(?:SK-)?[0-9]{3}\\s?[0-9]{2}' },
+};
+
+if ( dependencies[ 'postcode-validator' ] !== version ) {
+	throw new Error(
+		`Installed postcode-validator ${ version } does not match the ${ dependencies[ 'postcode-validator' ] } pin`
+	);
+}
+
+/**
+ * Remove the ECMAScript anchors supplied by postcode-validator. Consumers add
+ * native anchors so PHP can use \A/\z while JavaScript uses ^/$.
+ *
+ * @param {RegExp} regex Upstream regular expression.
+ * @return {string} Portable, unanchored expression source.
+ */
+function removeAnchors( regex ) {
+	if ( ! regex.source.startsWith( '^' ) || ! regex.source.endsWith( '$' ) ) {
+		throw new Error( `Expected an anchored expression: ${ regex.source }` );
+	}
+
+	return regex.source.slice( 1, -1 );
+}
+
+/**
+ * Replace ECMAScript whitespace tokens with literal spaces. JavaScript's \s
+ * also matches Unicode whitespace that the PHP validator rejects before
+ * applying country-specific rules.
+ *
+ * @param {string} pattern Regular expression source.
+ * @return {string} Expression source with portable space matching.
+ */
+function replaceWhitespaceTokens( pattern ) {
+	return pattern.replaceAll( '\\s', '[ ]' );
+}
+
+const rules = Object.fromEntries(
+	COUNTRY_CODES.map( ( countryCode ) => {
+		const upstreamRegex = POSTCODE_REGEXES.get( countryCode );
+		if ( ! upstreamRegex ) {
+			throw new Error(
+				`No postcode-validator rule for ${ countryCode }`
+			);
+		}
+
+		const sourceRule = {
+			pattern: removeAnchors( upstreamRegex ),
+			...( upstreamRegex.flags ? { flags: upstreamRegex.flags } : {} ),
+			...COMPATIBILITY_OVERRIDES[ countryCode ],
+		};
+		const rule = {
+			...sourceRule,
+			pattern: replaceWhitespaceTokens( sourceRule.pattern ),
+		};
+
+		if ( rule.pattern.includes( '~' ) ) {
+			throw new Error( `Unsupported delimiter in ${ countryCode } rule` );
+		}
+		if ( rule.flags && rule.flags !== 'i' ) {
+			throw new Error( `Unsupported flags in ${ countryCode } rule` );
+		}
+
+		// Catch malformed generated rules before they reach either consumer.
+		new RegExp( `^(?:${ rule.pattern })$`, rule.flags || '' );
+
+		return [ countryCode, rule ];
+	} )
+);
+
+const artifact = {
+	generatedFrom: {
+		package: 'postcode-validator',
+		version,
+	},
+	rules,
+};
+
+fs.writeFileSync(
+	OUTPUT_PATH,
+	`${ JSON.stringify( artifact, null, '\t' ) }\n`
+);
diff --git a/plugins/woocommerce/client/blocks/package.json b/plugins/woocommerce/client/blocks/package.json
index 7b1a3a1dcd7..dece5662dcf 100644
--- a/plugins/woocommerce/client/blocks/package.json
+++ b/plugins/woocommerce/client/blocks/package.json
@@ -51,6 +51,7 @@
 		"build:docs:block-references": "node ./bin/gen-block-list-doc.js",
 		"postbuild:docs": "./bin/add-doc-footer.sh",
 		"dev": "rimraf ../../assets/client/blocks/* && BABEL_ENV=default webpack",
+		"generate:postcode-validation": "node ./bin/generate-postcode-validation-rules.js",
 		"lint": "pnpm --if-present '/^lint:lang:.*$/'",
 		"lint:fix": "pnpm --if-present '/^lint:fix:lang:.*$/'",
 		"lint:fix:lang:css": "pnpm lint:css-fix",
@@ -268,7 +269,7 @@
 		"fast-deep-equal": "^3.1.3",
 		"fast-sort": "^3.4.0",
 		"html-react-parser": "3.0.4",
-		"postcode-validator": "3.9.2",
+		"postcode-validator": "3.10.22",
 		"preact": "^10.24.2",
 		"prop-types": "^15.8.1",
 		"react-number-format": "5.4.5",
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/is-postcode.ts b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/is-postcode.ts
index b50e97be32b..b71b95bbc68 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/is-postcode.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/is-postcode.ts
@@ -6,21 +6,34 @@ import {
 	postcodeValidatorExistsForCountry,
 } from 'postcode-validator';

-const CUSTOM_REGEXES = new Map< string, RegExp >( [
-	[ 'BA', /^([7-8]{1})([0-9]{4})$/ ],
-	[
-		'GB',
-		/^([A-Z]){1}([0-9]{1,2}|[A-Z][0-9][A-Z]|[A-Z][0-9]{2}|[A-Z][0-9]|[0-9][A-Z]){1}([ ])?([0-9][A-Z]{2}){1}|BFPO(?:\s)?([0-9]{1,4})$|BFPO(c\/o[0-9]{1,3})$/i,
-	],
-	[ 'IN', /^[1-9]{1}[0-9]{2}\s{0,1}[0-9]{3}$/ ],
-	[ 'JP', /^([0-9]{3})([-]?)([0-9]{4})$/ ],
-	[ 'KH', /^[0-9]{6}$/ ], // Cambodia (6-digit postal code).
-	[ 'LI', /^(94[8-9][0-9])$/ ],
-	[ 'MN', /^[0-9]{5}(-[0-9]{4})?$/ ], // Mongolia (5-digit postal code or 5-digit postal code followed by a hyphen and 4-digit postal code).
-	[ 'NI', /^[1-9]{1}[0-9]{4}$/ ], // Nicaragua (5-digit postal code)
-	[ 'NL', /^([1-9][0-9]{3})(\s?)(?!SA|SD|SS)[A-Z]{2}$/i ],
-	[ 'SI', /^([1-9][0-9]{3})$/ ],
-] );
+/**
+ * Internal dependencies
+ */
+import postcodeValidationData from '../../../../../../../i18n/postcode-validation-rules.json';
+
+type PostcodeValidationRule = {
+	pattern: string;
+	flags?: string;
+	normalization?: 'removeSpaces' | 'removeSpacesAndHyphens';
+};
+
+const SHARED_RULES = postcodeValidationData.rules as Record<
+	string,
+	PostcodeValidationRule
+>;
+
+const normalizePostcode = (
+	postcode: string,
+	normalization?: PostcodeValidationRule[ 'normalization' ]
+): string => {
+	if ( normalization === 'removeSpaces' ) {
+		return postcode.replace( / /g, '' );
+	}
+	if ( normalization === 'removeSpacesAndHyphens' ) {
+		return postcode.trim().replace( /[\s-]/g, '' );
+	}
+	return postcode;
+};

 export interface IsPostcodeProps {
 	postcode: string;
@@ -28,9 +41,25 @@ export interface IsPostcodeProps {
 }

 const isPostcode = ( { postcode, country }: IsPostcodeProps ): boolean => {
-	const customRegex = CUSTOM_REGEXES.get( country );
-	if ( customRegex ) {
-		return customRegex.test( postcode );
+	if ( typeof postcode !== 'string' || typeof country !== 'string' ) {
+		return false;
+	}
+
+	// Mirror WC_Validation::is_postcode(): only ASCII whitespace, letters,
+	// digits, and hyphens may reach country-specific validation.
+	if ( /[^ \t\n\r\f\vA-Za-z0-9-]/.test( postcode ) ) {
+		return false;
+	}
+
+	if ( Object.hasOwn( SHARED_RULES, country ) ) {
+		const sharedRule = SHARED_RULES[ country ];
+		const regex = new RegExp(
+			`^(?:${ sharedRule.pattern })$`,
+			sharedRule.flags || ''
+		);
+		return regex.test(
+			normalizePostcode( postcode, sharedRule.normalization )
+		);
 	}
 	// If the country is not in the upstream list, trying to validate it would throw, so we skip and assume
 	// that it is valid.
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/is-postcode.ts b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/is-postcode.ts
index a8049816505..2804d3bc051 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/is-postcode.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/is-postcode.ts
@@ -3,13 +3,14 @@
  */
 import isPostcode from '../is-postcode';
 import type { IsPostcodeProps } from '../is-postcode';
+import postcodeValidationFixtures from './postcode-validation-fixtures.json';

 describe( 'isPostcode', () => {
 	const cases = [
 		// Austrian postcodes
 		[ true, '1000', 'AT' ],
 		[ true, '9999', 'AT' ],
-		[ false, '0000', 'AT' ],
+		[ true, '0000', 'AT' ],
 		[ false, '10000', 'AT' ],

 		// Bosnian postcodes
@@ -64,7 +65,7 @@ describe( 'isPostcode', () => {
 		// French postcodes
 		[ true, '01000', 'FR' ],
 		[ true, '99999', 'FR' ],
-		[ true, '01 000', 'FR' ],
+		[ false, '01 000', 'FR' ],
 		[ false, '1234', 'FR' ],

 		// British postcodes
@@ -87,6 +88,7 @@ describe( 'isPostcode', () => {

 		// Irish postcodes
 		[ true, 'A65F4E2', 'IE' ],
+		[ true, 'a65f4e2', 'IE' ],
 		[ true, 'A65 F4E2', 'IE' ],
 		[ true, 'A65-F4E2', 'IE' ],
 		[ false, 'B23F854', 'IE' ],
@@ -190,10 +192,21 @@ describe( 'isPostcode', () => {
 		[ true, '12345', 'TW' ],
 		[ true, '123', 'TW' ],

+		// Countries using the postcode-validator fallback still apply the
+		// character guard shared with PHP.
+		[ true, '2000', 'AU' ],
+		[ false, '2000#', 'AU' ],
+
 		// Unknown country codes — assumed valid since no regex applies.
 		[ true, '12345', 'XX' ],
 		[ true, 'anything', 'ZZ' ],
 		[ true, '', 'XX' ],
+		[ false, 'anything#', 'XX' ],
+
+		// Object prototype keys are unknown country codes, not shared rules.
+		[ true, '12345', 'constructor' ],
+		[ true, '12345', '__proto__' ],
+		[ true, '12345', 'toString' ],
 	];

 	test.each( cases )( '%s: %s for %s', ( result, postcode, country ) =>
@@ -201,4 +214,28 @@ describe( 'isPostcode', () => {
 			result
 		)
 	);
+
+	test( 'returns false for non-string runtime values', () => {
+		expect(
+			isPostcode( {
+				postcode: null,
+				country: 'GB',
+			} as unknown as IsPostcodeProps )
+		).toBe( false );
+		expect(
+			isPostcode( {
+				postcode: 'SW1A 1AA',
+				country: null,
+			} as unknown as IsPostcodeProps )
+		).toBe( false );
+	} );
+} );
+
+describe( 'shared postcode validation contract', () => {
+	test.each( postcodeValidationFixtures )(
+		'$expected: $postcode for $country',
+		( { expected, postcode, country } ) => {
+			expect( isPostcode( { postcode, country } ) ).toBe( expected );
+		}
+	);
 } );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/postcode-validation-fixtures.json b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/postcode-validation-fixtures.json
new file mode 100644
index 00000000000..f8ab0887335
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/validation/test/postcode-validation-fixtures.json
@@ -0,0 +1,78 @@
+[
+	{ "country": "AT", "postcode": "0000", "expected": true },
+	{ "country": "AT", "postcode": "10000", "expected": false },
+	{ "country": "BA", "postcode": "71000", "expected": true },
+	{ "country": "BA", "postcode": "61000", "expected": false },
+	{ "country": "BE", "postcode": "1111", "expected": true },
+	{ "country": "BE", "postcode": "111", "expected": false },
+	{ "country": "BR", "postcode": "99999-999", "expected": true },
+	{ "country": "BR", "postcode": "99999 999", "expected": false },
+	{ "country": "CA", "postcode": "A9A 9A9", "expected": true },
+	{ "country": "CA", "postcode": "D0A 9A9", "expected": false },
+	{ "country": "CH", "postcode": "9999", "expected": true },
+	{ "country": "CH", "postcode": "99999", "expected": false },
+	{ "country": "CZ", "postcode": "CZ-115 03", "expected": true },
+	{ "country": "CZ", "postcode": "CZ-115\t03", "expected": false },
+	{ "country": "CZ", "postcode": "1600", "expected": false },
+	{ "country": "DE", "postcode": "01000", "expected": true },
+	{ "country": "DE", "postcode": "00000", "expected": false },
+	{ "country": "DK", "postcode": "DK-1234", "expected": true },
+	{ "country": "DK", "postcode": "3900", "expected": false },
+	{ "country": "EE", "postcode": "12345", "expected": true },
+	{ "country": "EE", "postcode": "1234", "expected": false },
+	{ "country": "ES", "postcode": "99999", "expected": true },
+	{ "country": "ES", "postcode": "1234", "expected": false },
+	{ "country": "FI", "postcode": "00100", "expected": true },
+	{ "country": "FI", "postcode": "FI-00100", "expected": false },
+	{ "country": "FR", "postcode": "01000", "expected": true },
+	{ "country": "FR", "postcode": "01 000", "expected": false },
+	{ "country": "GB", "postcode": "BFPO 801", "expected": true },
+	{ "country": "GB", "postcode": "99999", "expected": false },
+	{ "country": "HU", "postcode": "1234", "expected": true },
+	{ "country": "HU", "postcode": "123", "expected": false },
+	{ "country": "IE", "postcode": "A65-F4E2", "expected": true },
+	{ "country": "IE", "postcode": "a65f4e2", "expected": true },
+	{ "country": "IE", "postcode": "A65F4E2\n", "expected": true },
+	{ "country": "IE", "postcode": "JUNKA651234JUNK", "expected": false },
+	{ "country": "IN", "postcode": "110 001", "expected": true },
+	{ "country": "IN", "postcode": "110\t001", "expected": false },
+	{ "country": "IN", "postcode": "11 0001", "expected": false },
+	{ "country": "IT", "postcode": "99999", "expected": true },
+	{ "country": "IT", "postcode": "9999", "expected": false },
+	{ "country": "JP", "postcode": "1340088", "expected": true },
+	{ "country": "JP", "postcode": "12345", "expected": false },
+	{ "country": "KH", "postcode": "123456", "expected": true },
+	{ "country": "KH", "postcode": "12345", "expected": false },
+	{ "country": "LI", "postcode": "9482", "expected": true },
+	{ "country": "LI", "postcode": "9475", "expected": false },
+	{ "country": "LV", "postcode": "LV 1050", "expected": true },
+	{ "country": "LV", "postcode": "LV-0123", "expected": false },
+	{ "country": "LV", "postcode": "LV-1050\n", "expected": false },
+	{ "country": "MN", "postcode": "12345-6789", "expected": true },
+	{ "country": "MN", "postcode": "1234", "expected": false },
+	{ "country": "NI", "postcode": "12345", "expected": true },
+	{ "country": "NI", "postcode": "01234", "expected": false },
+	{ "country": "NL", "postcode": "3852 gc", "expected": true },
+	{ "country": "NL", "postcode": "3852\tgc", "expected": false },
+	{ "country": "NL", "postcode": "3852 SA", "expected": false },
+	{ "country": "NO", "postcode": "1234", "expected": true },
+	{ "country": "NO", "postcode": "123", "expected": false },
+	{ "country": "PL", "postcode": "00-001", "expected": true },
+	{ "country": "PL", "postcode": "00001", "expected": false },
+	{ "country": "PR", "postcode": "12345", "expected": true },
+	{ "country": "PR", "postcode": "1234", "expected": false },
+	{ "country": "PT", "postcode": "1234-567", "expected": true },
+	{ "country": "PT", "postcode": "1234", "expected": false },
+	{ "country": "SE", "postcode": "SE-123 45", "expected": true },
+	{ "country": "SE", "postcode": "SE-123\t45", "expected": false },
+	{ "country": "SE", "postcode": "12 345", "expected": false },
+	{ "country": "SI", "postcode": "1234", "expected": true },
+	{ "country": "SI", "postcode": "0123", "expected": false },
+	{ "country": "SK", "postcode": "SK-010 01", "expected": true },
+	{ "country": "SK", "postcode": "SK-010\t01", "expected": false },
+	{ "country": "SK", "postcode": "01 001", "expected": false },
+	{ "country": "US", "postcode": "90210", "expected": true },
+	{ "country": "US", "postcode": "ABCDE", "expected": false },
+	{ "country": "XX", "postcode": "anything", "expected": true },
+	{ "country": "XX", "postcode": "anything#", "expected": false }
+]
diff --git a/plugins/woocommerce/i18n/postcode-validation-rules.json b/plugins/woocommerce/i18n/postcode-validation-rules.json
new file mode 100644
index 00000000000..47d9fd6a9a5
--- /dev/null
+++ b/plugins/woocommerce/i18n/postcode-validation-rules.json
@@ -0,0 +1,116 @@
+{
+	"generatedFrom": {
+		"package": "postcode-validator",
+		"version": "3.10.22"
+	},
+	"rules": {
+		"AT": {
+			"pattern": "[0-9]{4}"
+		},
+		"BA": {
+			"pattern": "[7-8][0-9]{4}"
+		},
+		"BE": {
+			"pattern": "\\d{4}"
+		},
+		"BR": {
+			"pattern": "\\d{5}[\\-]?\\d{3}"
+		},
+		"CA": {
+			"pattern": "[ABCEGHJKLMNPRSTVXY][0-9][ABCEGHJKLMNPRSTVWXYZ] ?[0-9][ABCEGHJKLMNPRSTVWXYZ][0-9]",
+			"flags": "i"
+		},
+		"CH": {
+			"pattern": "\\d{4}"
+		},
+		"CZ": {
+			"pattern": "(?:CZ-)?[0-9]{3}[ ]?[0-9]{2}"
+		},
+		"DE": {
+			"pattern": "(?:0[1-9]|[1-9][0-9])[0-9]{3}"
+		},
+		"DK": {
+			"pattern": "(?:DK-)?(?:[1-24-9]\\d{3}|3[0-8]\\d{2})"
+		},
+		"EE": {
+			"pattern": "\\d{5}"
+		},
+		"ES": {
+			"pattern": "[0-9]{5}"
+		},
+		"FI": {
+			"pattern": "[0-9]{5}"
+		},
+		"FR": {
+			"pattern": "[0-9]{5}",
+			"flags": "i"
+		},
+		"GB": {
+			"pattern": "(?:[abcdefghijklmnoprstuwyz][abcdefghklmnopqrstuvwxy]?[0-9]{1,2}[0-9][abdefghjlnpqrstuwxyz]{2}|[abcdefghijklmnoprstuwyz][0-9][abcdefghjkpstuw][0-9][abdefghjlnpqrstuwxyz]{2}|[abcdefghijklmnoprstuwyz][abcdefghklmnopqrstuvwxy][0-9][abehmnprvwxy][0-9][abdefghjlnpqrstuwxyz]{2}|gir0aa|bfpo[0-9]{1,4}|bfpoc\\/o[0-9]{1,3})",
+			"flags": "i",
+			"normalization": "removeSpaces"
+		},
+		"HU": {
+			"pattern": "\\d{4}"
+		},
+		"IE": {
+			"pattern": "(?:[AC-FHKNPRTV-Y][0-9]{2}|D6W)[0-9AC-FHKNPRTV-Y]{4}",
+			"flags": "i",
+			"normalization": "removeSpacesAndHyphens"
+		},
+		"IN": {
+			"pattern": "[1-9][0-9]{2}[ ]?[0-9]{3}"
+		},
+		"IT": {
+			"pattern": "\\d{5}"
+		},
+		"JP": {
+			"pattern": "[0-9]{3}-?[0-9]{4}"
+		},
+		"KH": {
+			"pattern": "[0-9]{6}"
+		},
+		"LI": {
+			"pattern": "94[8-9][0-9]"
+		},
+		"LV": {
+			"pattern": "(?:LV[- ]?)?[1-9][0-9]{3}",
+			"flags": "i"
+		},
+		"MN": {
+			"pattern": "[0-9]{5}(?:-[0-9]{4})?"
+		},
+		"NI": {
+			"pattern": "[1-9][0-9]{4}"
+		},
+		"NL": {
+			"pattern": "[1-9][0-9]{3}[ ]?(?!SA|SD|SS)[A-Z]{2}",
+			"flags": "i"
+		},
+		"NO": {
+			"pattern": "\\d{4}"
+		},
+		"PL": {
+			"pattern": "\\d{2}-\\d{3}"
+		},
+		"PR": {
+			"pattern": "[0-9]{5}(?:-[0-9]{4})?",
+			"flags": "i"
+		},
+		"PT": {
+			"pattern": "[0-9]{4}-[0-9]{3}"
+		},
+		"SE": {
+			"pattern": "(?:SE-)?[0-9]{3}[ ]?[0-9]{2}"
+		},
+		"SI": {
+			"pattern": "[1-9][0-9]{3}"
+		},
+		"SK": {
+			"pattern": "(?:SK-)?[0-9]{3}[ ]?[0-9]{2}"
+		},
+		"US": {
+			"pattern": "([0-9]{5})(?:-([0-9]{4}))?"
+		}
+	}
+}
diff --git a/plugins/woocommerce/includes/class-wc-validation.php b/plugins/woocommerce/includes/class-wc-validation.php
index fecdebb7f94..ed127c330bd 100644
--- a/plugins/woocommerce/includes/class-wc-validation.php
+++ b/plugins/woocommerce/includes/class-wc-validation.php
@@ -76,83 +76,8 @@ class WC_Validation {
 			return false;
 		}

-		switch ( $country ) {
-			case 'AT':
-			case 'BE':
-			case 'CH':
-			case 'HU':
-			case 'NO':
-				$valid = (bool) preg_match( '/^([0-9]{4})$/', $postcode );
-				break;
-			case 'BA':
-				$valid = (bool) preg_match( '/^([7-8]{1})([0-9]{4})$/', $postcode );
-				break;
-			case 'BR':
-				$valid = (bool) preg_match( '/^([0-9]{5})([-])?([0-9]{3})$/', $postcode );
-				break;
-			case 'DE':
-				$valid = (bool) preg_match( '/^([0]{1}[1-9]{1}|[1-9]{1}[0-9]{1})[0-9]{3}$/', $postcode );
-				break;
-			case 'DK':
-				$valid = (bool) preg_match( '/^(DK-)?([1-24-9]\d{3}|3[0-8]\d{2})$/', $postcode );
-				break;
-			case 'ES':
-			case 'FI':
-			case 'EE':
-			case 'FR':
-			case 'IT':
-				$valid = (bool) preg_match( '/^([0-9]{5})$/i', $postcode );
-				break;
-			case 'GB':
-				$valid = self::is_gb_postcode( $postcode );
-				break;
-			case 'IE':
-				$valid = (bool) preg_match( '/([AC-FHKNPRTV-Y]\d{2}|D6W)[0-9AC-FHKNPRTV-Y]{4}/', wc_normalize_postcode( $postcode ) );
-				break;
-			case 'IN':
-				$valid = (bool) preg_match( '/^[1-9]{1}[0-9]{2}\s{0,1}[0-9]{3}$/', $postcode );
-				break;
-			case 'JP':
-				$valid = (bool) preg_match( '/^([0-9]{3})([-]?)([0-9]{4})$/', $postcode );
-				break;
-			case 'PT':
-				$valid = (bool) preg_match( '/^([0-9]{4})([-])([0-9]{3})$/', $postcode );
-				break;
-			case 'PR':
-			case 'US':
-				$valid = (bool) preg_match( '/^([0-9]{5})(-[0-9]{4})?$/i', $postcode );
-				break;
-			case 'CA':
-				// CA Postal codes cannot contain D,F,I,O,Q,U and cannot start with W or Z. https://en.wikipedia.org/wiki/Postal_codes_in_Canada#Number_of_possible_postal_codes.
-				$valid = (bool) preg_match( '/^([ABCEGHJKLMNPRSTVXY]\d[ABCEGHJKLMNPRSTVWXYZ])([\ ])?(\d[ABCEGHJKLMNPRSTVWXYZ]\d)$/i', $postcode );
-				break;
-			case 'PL':
-				$valid = (bool) preg_match( '/^([0-9]{2})([-])([0-9]{3})$/', $postcode );
-				break;
-			case 'CZ':
-			case 'SE':
-			case 'SK':
-				$valid = (bool) preg_match( "/^($country-)?([0-9]{3})(\s?)([0-9]{2})$/", $postcode );
-				break;
-			case 'NL':
-				$valid = (bool) preg_match( '/^([1-9][0-9]{3})(\s?)(?!SA|SD|SS)[A-Z]{2}$/i', $postcode );
-				break;
-			case 'SI':
-				$valid = (bool) preg_match( '/^([1-9][0-9]{3})$/', $postcode );
-				break;
-			case 'LI':
-				$valid = (bool) preg_match( '/^(94[8-9][0-9])$/', $postcode );
-				break;
-			case 'LV':
-				// An optional case-insensitive LV prefix, followed by at most one hyphen or
-				// space, then four digits that do not start with a zero. A literal space
-				// rather than \s, and \z rather than $, so newlines and tabs are rejected.
-				$valid = (bool) preg_match( '/^(?:LV[- ]?)?[1-9][0-9]{3}\z/i', $postcode );
-				break;
-			default:
-				$valid = true;
-				break;
-		}
+		$valid = Automattic\WooCommerce\Internal\Utilities\PostcodeValidation::validate( (string) $postcode, (string) $country );
+		$valid = null === $valid ? true : $valid;

 		return apply_filters( 'woocommerce_validate_postcode', $valid, $postcode, $country );
 	}
diff --git a/plugins/woocommerce/src/Internal/Utilities/PostcodeValidation.php b/plugins/woocommerce/src/Internal/Utilities/PostcodeValidation.php
new file mode 100644
index 00000000000..bd475b0bf27
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/PostcodeValidation.php
@@ -0,0 +1,77 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+/**
+ * Provides postcode validation from the shared PHP/JavaScript rule artifact.
+ */
+final class PostcodeValidation {
+	/**
+	 * Cached generated rules.
+	 *
+	 * @var array<string, array{pattern: string, flags?: string, normalization?: string}>|null
+	 */
+	private static ?array $rules = null;
+
+	/**
+	 * Get all generated postcode rules.
+	 *
+	 * @return array<string, array{pattern: string, flags?: string, normalization?: string}>
+	 */
+	public static function get_rules(): array {
+		if ( null !== self::$rules ) {
+			return self::$rules;
+		}
+
+		$rules_file = dirname( __DIR__, 3 ) . '/i18n/postcode-validation-rules.json';
+		if ( ! is_readable( $rules_file ) ) {
+			self::$rules = array();
+			return self::$rules;
+		}
+
+		$contents = file_get_contents( $rules_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+		$data     = false !== $contents ? json_decode( $contents, true ) : null;
+		$rules    = is_array( $data ) && isset( $data['rules'] ) && is_array( $data['rules'] ) ? $data['rules'] : array();
+
+		self::$rules = array_filter(
+			$rules,
+			static function ( $rule ): bool {
+				return is_array( $rule ) && isset( $rule['pattern'] ) && is_string( $rule['pattern'] );
+			}
+		);
+
+		return self::$rules;
+	}
+
+	/**
+	 * Validate a postcode if a shared rule exists for its country.
+	 *
+	 * @param string $postcode Postcode to validate.
+	 * @param string $country  Country code.
+	 * @return bool|null Whether the postcode is valid, or null when no usable rule exists.
+	 */
+	public static function validate( string $postcode, string $country ): ?bool {
+		$rules = self::get_rules();
+		if ( ! isset( $rules[ $country ] ) ) {
+			return null;
+		}
+
+		$rule = $rules[ $country ];
+		if ( isset( $rule['normalization'] ) ) {
+			switch ( $rule['normalization'] ) {
+				case 'removeSpaces':
+					$postcode = str_replace( ' ', '', $postcode );
+					break;
+				case 'removeSpacesAndHyphens':
+					$postcode = (string) preg_replace( '/[\s\-]/', '', trim( $postcode ) );
+					break;
+			}
+		}
+
+		$flags   = isset( $rule['flags'] ) && 'i' === $rule['flags'] ? 'i' : '';
+		$matched = preg_match( '~\A(?:' . $rule['pattern'] . ')\z~' . $flags, $postcode );
+
+		return false === $matched ? null : 1 === $matched;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-validation-test.php b/plugins/woocommerce/tests/php/includes/class-wc-validation-test.php
index 20e3b975b67..998aa2531c0 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-validation-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-validation-test.php
@@ -133,4 +133,51 @@ class WC_Validation_Test extends \WC_Unit_Test_Case {
 	public function test_is_postcode( bool $expected, string $postcode, string $country ): void {
 		$this->assertSame( $expected, WC_Validation::is_postcode( $postcode, $country ) );
 	}
+
+	/**
+	 * The woocommerce_validate_postcode filter can still override a shared rule.
+	 */
+	public function test_postcode_filter_can_override_shared_rule(): void {
+		$callback = static function ( $valid, $postcode, $country ) {
+			return 'US' === $country && 'ABCDE' === $postcode ? true : $valid;
+		};
+
+		add_filter( 'woocommerce_validate_postcode', $callback, 10, 3 );
+		try {
+			$this->assertTrue( WC_Validation::is_postcode( 'ABCDE', 'US' ) );
+		} finally {
+			remove_filter( 'woocommerce_validate_postcode', $callback, 10 );
+		}
+	}
+
+	/**
+	 * Both postcode validators must produce the same result for the shared
+	 * compatibility contract.
+	 *
+	 * @dataProvider data_provider_postcode_validation_parity
+	 *
+	 * @param bool   $expected Expected result.
+	 * @param string $postcode Postcode to validate.
+	 * @param string $country  Country code.
+	 */
+	public function test_postcode_validation_parity( bool $expected, string $postcode, string $country ): void {
+		$this->assertSame( $expected, WC_Validation::is_postcode( $postcode, $country ) );
+	}
+
+	/**
+	 * Loads the fixture list also exercised by the Blocks validator.
+	 *
+	 * @return array<int, array{bool, string, string}>
+	 */
+	public function data_provider_postcode_validation_parity(): array {
+		$fixture_path = WC_ABSPATH . 'client/blocks/packages/public-api/blocks-checkout/utils/validation/test/postcode-validation-fixtures.json';
+		$fixtures     = json_decode( (string) file_get_contents( $fixture_path ), true, 512, JSON_THROW_ON_ERROR ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local test fixture.
+
+		return array_map(
+			static function ( array $fixture ): array {
+				return array( $fixture['expected'], $fixture['postcode'], $fixture['country'] );
+			},
+			$fixtures
+		);
+	}
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 85f170eea56..026b4e5b26f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3538,8 +3538,8 @@ importers:
         specifier: 3.0.4
         version: 3.0.4(react@18.3.1)
       postcode-validator:
-        specifier: 3.9.2
-        version: 3.9.2
+        specifier: 3.10.22
+        version: 3.10.22
       preact:
         specifier: ^10.24.2
         version: 10.29.1
@@ -18610,8 +18610,9 @@ packages:
     resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
     engines: {node: '>= 0.4'}

-  postcode-validator@3.9.2:
-    resolution: {integrity: sha512-C+oaXif+z+mAN1EWDZG/EM2dnrUxRQR0gpMq8VeLSZwHuT3Bqo4hR3+k7OaEevPl0VK/7W27JwEQb24CbdtzuQ==}
+  postcode-validator@3.10.22:
+    resolution: {integrity: sha512-4rQfw8cPGHaAwIJsOAh1RTxr34EkBcKYYgp72+IQrVTPnmmrdz45OsVeese6X7oOkJ19SY/leRP+o1syeWGJuw==}
+    engines: {node: '>=20.8.1'}

   postcss-calc@8.2.4:
     resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==}
@@ -45879,7 +45880,7 @@ snapshots:

   possible-typed-array-names@1.1.0: {}

-  postcode-validator@3.9.2: {}
+  postcode-validator@3.10.22: {}

   postcss-calc@8.2.4(postcss@8.4.49):
     dependencies: