Skip to content

Commit 6e8f49e

Browse files
authored
fix: b64 encode props with URL-sensitive characters (#530)
1 parent c850dfd commit 6e8f49e

File tree

2 files changed

+276
-5
lines changed

2 files changed

+276
-5
lines changed

src/runtime/shared/urlEncoding.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,12 +227,20 @@ export function encodeOgImageParams(options: Record<string, any>, defaults?: Rec
227227
parts.push(`${alias}_~${b64Encode(str)}`)
228228
}
229229
else {
230-
// ASCII-safe value - URL encode for special chars
231-
// Escape leading ~ to avoid ambiguity with b64 marker prefix
230+
// ASCII-safe value: try URL encoding first, then check for problematic percent-encoding.
231+
// Characters like #, ?, /, \, =, & produce %XX sequences that get decoded by
232+
// proxies, CDNs, and prerender crawlers in unpredictable ways (#528, #529).
233+
// If percent-encoding is needed, use b64 instead to avoid these issues entirely.
232234
const escaped = str.startsWith('~') ? `~${str}` : str
233235
const encoded = encodeURIComponent(escaped.replace(RE_UNDERSCORE, '__'))
234236
.replace(RE_PERCENT20, '+') // spaces as +
235-
parts.push(`${alias}_${encoded}`)
237+
if (encoded.includes('%')) {
238+
// Value contains URL-sensitive chars; b64 encode to prevent intermediary decoding
239+
parts.push(`${alias}_~${b64Encode(str)}`)
240+
}
241+
else {
242+
parts.push(`${alias}_${encoded}`)
243+
}
236244
}
237245
}
238246
}

test/unit/urlEncoding.test.ts

Lines changed: 265 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/^title_~/)
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(/^title_~/)
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(/^image_~/)
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-z0-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(/^title_~/)
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&amp' },
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

Comments
 (0)