@@ -42,11 +42,16 @@ describe('urlEncoding', () => {
4242 expect ( encoded ) . toBe ( 'title_Hello__World' )
4343 } )
4444
45- it ( 'escapes commas in values ' , ( ) => {
45+ it ( 'b64 encodes values with commas (avoids percent-encoding) ' , ( ) => {
4646 const encoded = encodeOgImageParams ( {
4747 props : { title : 'Hello, World' } ,
4848 } )
49- expect ( encoded ) . toBe ( 'title_Hello%2C+World' )
49+ // Commas produce %2C via encodeURIComponent, which triggers b64 encoding
50+ expect ( encoded ) . toMatch ( / ^ t i t l e _ ~ / )
51+ expect ( encoded ) . not . toContain ( '%' )
52+ // Verify roundtrip
53+ const decoded = decodeOgImageParams ( encoded )
54+ expect ( decoded ) . toEqual ( { props : { title : 'Hello, World' } } )
5055 } )
5156
5257 it ( 'base64 encodes complex objects' , ( ) => {
@@ -755,4 +760,262 @@ describe('urlEncoding', () => {
755760 roundtrip ( { title : 'Hello مرحبا World' } )
756761 } )
757762 } )
763+
764+ describe ( 'uRL-sensitive characters (#529)' , ( ) => {
765+ function roundtrip ( props : Record < string , any > ) {
766+ const encoded = encodeOgImageParams ( { props } )
767+ const decoded = decodeOgImageParams ( encoded )
768+ expect ( decoded ) . toEqual ( { props } )
769+ }
770+
771+ it ( 'round-trips value with hash character' , ( ) => {
772+ roundtrip ( { title : 'Some # char' } )
773+ } )
774+
775+ it ( 'round-trips value with question mark' , ( ) => {
776+ roundtrip ( { title : 'What? Really?' } )
777+ } )
778+
779+ it ( 'round-trips value with backslash' , ( ) => {
780+ roundtrip ( { title : 'path\\to\\file' } )
781+ } )
782+
783+ it ( 'round-trips value with all problematic chars from #529' , ( ) => {
784+ roundtrip ( { title : 'Some illegal chars here # ? \\' } )
785+ } )
786+
787+ it ( 'encodes URL-sensitive chars via b64 (no percent-encoding in output)' , ( ) => {
788+ const encoded = encodeOgImageParams ( { props : { title : 'Hello # World' } } )
789+ expect ( encoded ) . not . toContain ( '%' )
790+ expect ( encoded ) . toMatch ( / ^ t i t l e _ ~ / )
791+ } )
792+
793+ it ( 'round-trips value with equals sign' , ( ) => {
794+ roundtrip ( { title : 'key=value' } )
795+ } )
796+
797+ it ( 'round-trips value with ampersand' , ( ) => {
798+ roundtrip ( { title : 'foo&bar' } )
799+ } )
800+
801+ it ( 'round-trips value with at sign' , ( ) => {
802+ roundtrip ( { title : 'user@example.com' } )
803+ } )
804+
805+ it ( 'round-trips value with colon' , ( ) => {
806+ roundtrip ( { title : 'Time: 12:30' } )
807+ } )
808+
809+ it ( 'round-trips value with semicolon' , ( ) => {
810+ roundtrip ( { title : 'a;b;c' } )
811+ } )
812+
813+ it ( 'round-trips value with square brackets' , ( ) => {
814+ roundtrip ( { title : 'array[0]' } )
815+ } )
816+
817+ it ( 'round-trips value with curly braces' , ( ) => {
818+ roundtrip ( { title : '{json}' } )
819+ } )
820+ } )
821+
822+ describe ( 'image URLs as props (#528)' , ( ) => {
823+ function roundtrip ( props : Record < string , any > ) {
824+ const encoded = encodeOgImageParams ( { props } )
825+ const decoded = decodeOgImageParams ( encoded )
826+ expect ( decoded ) . toEqual ( { props } )
827+ }
828+
829+ it ( 'round-trips a full image URL with query params' , ( ) => {
830+ roundtrip ( { image : 'https://images.prismic.io/xxx/aVfGGnNYClf9ou-1-.png?auto=format,compress' } )
831+ } )
832+
833+ it ( 'round-trips image URL (no percent-encoding in output)' , ( ) => {
834+ const encoded = encodeOgImageParams ( {
835+ props : { image : 'https://example.com/image.png?w=200&h=100' } ,
836+ } )
837+ expect ( encoded ) . not . toContain ( '%' )
838+ expect ( encoded ) . toMatch ( / ^ i m a g e _ ~ / )
839+ } )
840+
841+ it ( 'round-trips URL with fragment' , ( ) => {
842+ roundtrip ( { link : 'https://example.com/page#section' } )
843+ } )
844+
845+ it ( 'round-trips URL with port and path' , ( ) => {
846+ roundtrip ( { image : 'http://localhost:3000/api/image.jpg' } )
847+ } )
848+
849+ it ( 'preserves full URL through buildOgImageUrl/parseOgImageUrl (dynamic)' , ( ) => {
850+ const options = {
851+ component : 'Article' ,
852+ props : { image : 'https://images.prismic.io/xxx/aVfGGnNYClf9ou-1-.png?auto=format,compress' } ,
853+ }
854+ const { url } = buildOgImageUrl ( options , 'png' , false )
855+ expect ( url ) . not . toContain ( '%' )
856+ const parsed = parseOgImageUrl ( url )
857+ expect ( parsed . options ) . toEqual ( options )
858+ } )
859+ } )
860+
861+ describe ( 'stress test: try to break the URL path system' , ( ) => {
862+ function roundtrip ( props : Record < string , any > ) {
863+ const encoded = encodeOgImageParams ( { props } )
864+ const decoded = decodeOgImageParams ( encoded )
865+ expect ( decoded ) . toEqual ( { props } )
866+ }
867+
868+ function fullRoundtrip ( props : Record < string , any > , isStatic = false ) {
869+ const options = { component : 'Test' , props }
870+ const { url } = buildOgImageUrl ( options , 'png' , isStatic )
871+ const parsed = parseOgImageUrl ( url )
872+ expect ( parsed . options ) . toEqual ( options )
873+ expect ( parsed . extension ) . toBe ( 'png' )
874+ }
875+
876+ // Values that mimic OG image URL structure
877+ it ( 'round-trips value containing /_og/s/ prefix' , ( ) => {
878+ roundtrip ( { title : '/_og/s/w_1200.png' } )
879+ } )
880+
881+ it ( 'round-trips value containing /_og/d/ prefix' , ( ) => {
882+ roundtrip ( { title : '/_og/d/c_NuxtSeo,title_Hello.png' } )
883+ } )
884+
885+ it ( 'round-trips value that looks like hash mode: o_abc123' , ( ) => {
886+ roundtrip ( { title : 'o_abc123' } )
887+ } )
888+
889+ // Extension confusion
890+ it ( 'round-trips value ending in .png' , ( ) => {
891+ roundtrip ( { title : 'screenshot.png' } )
892+ } )
893+
894+ it ( 'round-trips value ending in .jpeg' , ( ) => {
895+ roundtrip ( { path : '/images/photo.jpeg' } )
896+ } )
897+
898+ it ( 'full roundtrip with .png in prop value' , ( ) => {
899+ fullRoundtrip ( { image : 'https://cdn.example.com/banner.png' } )
900+ } )
901+
902+ // Null byte
903+ it ( 'round-trips value with null byte' , ( ) => {
904+ roundtrip ( { title : 'hello\x00world' } )
905+ } )
906+
907+ // Every ASCII printable special character
908+ it ( 'round-trips all ASCII special characters' , ( ) => {
909+ roundtrip ( { title : '!@#$%^&*()_+-=[]{}|;:\'",.<>?/\\`~' } )
910+ } )
911+
912+ // Newlines, tabs, carriage returns together
913+ it ( 'round-trips value with mixed whitespace' , ( ) => {
914+ roundtrip ( { title : 'line1\r\nline2\ttab\r\nline3' } )
915+ } )
916+
917+ // Values that look like b64 params
918+ it ( 'round-trips value that mimics encoded satori param' , ( ) => {
919+ roundtrip ( { title : 'satori_eyJmb250cyI6W119' } )
920+ } )
921+
922+ // Param injection: value that tries to inject new params
923+ it ( 'round-trips value with comma+param pattern injection attempt' , ( ) => {
924+ roundtrip ( { title : 'hello,c_Evil,w_9999' } )
925+ } )
926+
927+ // Unicode edge cases
928+ it ( 'round-trips lone surrogate pair halves' , ( ) => {
929+ // U+D800 is a lone high surrogate — edge case for UTF encoding
930+ // Most environments will replace with U+FFFD, so just verify no crash
931+ const encoded = encodeOgImageParams ( { props : { title : '\uFFFD' } } )
932+ const decoded = decodeOgImageParams ( encoded )
933+ expect ( decoded . props . title ) . toBeDefined ( )
934+ } )
935+
936+ it ( 'value with BOM (byte order mark) has BOM stripped by TextDecoder' , ( ) => {
937+ // BOM (\uFEFF) is stripped by TextDecoder — this is correct/expected behavior
938+ const encoded = encodeOgImageParams ( { props : { title : '\uFEFFhello' } } )
939+ const decoded = decodeOgImageParams ( encoded )
940+ expect ( decoded . props . title ) . toBe ( 'hello' )
941+ } )
942+
943+ it ( 'round-trips value with zero-width spaces' , ( ) => {
944+ roundtrip ( { title : 'hello\u200Bworld\u200Btest' } )
945+ } )
946+
947+ it ( 'round-trips value with RTL override characters' , ( ) => {
948+ roundtrip ( { title : '\u202Ehello\u202C' } )
949+ } )
950+
951+ // Extremely long URL values
952+ it ( 'full roundtrip with very long image URL (static uses hash)' , ( ) => {
953+ const longUrl = `https://images.example.com/${ 'a' . repeat ( 300 ) } .png?token=${ 'b' . repeat ( 100 ) } `
954+ const options = { component : 'Test' , props : { image : longUrl } }
955+ const result = buildOgImageUrl ( options , 'png' , true )
956+ // Should use hash mode
957+ expect ( result . hash ) . toBeDefined ( )
958+ expect ( result . url ) . toMatch ( / o _ [ a - z 0 - 9 ] + / )
959+ } )
960+
961+ // Multiple props with special chars
962+ it ( 'round-trips multiple props all containing URL-sensitive chars' , ( ) => {
963+ roundtrip ( {
964+ title : 'What? Really!' ,
965+ image : 'https://example.com/img.png?w=100' ,
966+ path : '/blog/hello#section' ,
967+ code : 'if (a && b) { return c; }' ,
968+ email : 'user@test.com' ,
969+ } )
970+ } )
971+
972+ // Values that are pure percent encoding
973+ it ( 'round-trips value that is all percent-encoded chars' , ( ) => {
974+ roundtrip ( { title : '%23%3F%2F%5C' } )
975+ } )
976+
977+ // Double encoding protection
978+ it ( 'does not double-encode already percent-encoded values' , ( ) => {
979+ const encoded = encodeOgImageParams ( { props : { title : '%23%3F' } } )
980+ // Should b64 encode since it contains %
981+ expect ( encoded ) . toMatch ( / ^ t i t l e _ ~ / )
982+ const decoded = decodeOgImageParams ( encoded )
983+ expect ( decoded . props . title ) . toBe ( '%23%3F' )
984+ } )
985+
986+ // Mixed safe and unsafe props in same encode
987+ it ( 'full roundtrip with mix of safe and unsafe props' , ( ) => {
988+ fullRoundtrip ( {
989+ slug : 'hello-world' ,
990+ title : 'Hello # World?' ,
991+ count : 42 ,
992+ featured : true ,
993+ image : 'https://cdn.test.com/img.jpg' ,
994+ } )
995+ } )
996+
997+ // Encoded output should never contain raw URL-sensitive chars
998+ it ( 'encoded output never contains raw #, ?, or unescaped slashes' , ( ) => {
999+ const cases = [
1000+ { title : 'test#hash' } ,
1001+ { title : 'test?query' } ,
1002+ { title : 'test/slash' } ,
1003+ { title : 'test\\backslash' } ,
1004+ { title : 'test=equals' } ,
1005+ { title : 'test&' } ,
1006+ ]
1007+ for ( const props of cases ) {
1008+ const encoded = encodeOgImageParams ( { props } )
1009+ // Raw dangerous chars should never appear in encoded output
1010+ expect ( encoded ) . not . toContain ( '#' )
1011+ expect ( encoded ) . not . toContain ( '?' )
1012+ expect ( encoded ) . not . toContain ( '/' )
1013+ expect ( encoded ) . not . toContain ( '\\' )
1014+ expect ( encoded ) . not . toContain ( '=' )
1015+ expect ( encoded ) . not . toContain ( '&' )
1016+ // And no percent-encoding either (all handled via b64)
1017+ expect ( encoded ) . not . toContain ( '%' )
1018+ }
1019+ } )
1020+ } )
7581021} )
0 commit comments