Skip to content

Commit dcaee49

Browse files
authored
fix: harden lastmod validation and support gzipped XML sources (#637)
1 parent 7f21fbf commit dcaee49

7 files changed

Lines changed: 135 additions & 9 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// gzip magic bytes: 1f 8b. Catches both `.xml.gz` sources and servers that respond
2+
// with a gzip body for a plain `.xml` URL without a Content-Encoding header (so the
3+
// runtime fetch layer never gets a chance to auto-decompress it).
4+
export function looksLikeGzipBytes(bytes: Uint8Array): boolean {
5+
return bytes.length >= 2 && bytes[0] === 0x1F && bytes[1] === 0x8B
6+
}
7+
8+
export async function decodeSitemapResponseBytes(bytes: Uint8Array): Promise<string> {
9+
if (!looksLikeGzipBytes(bytes))
10+
return new TextDecoder('utf-8').decode(bytes)
11+
12+
// Blob only accepts an ArrayBuffer-backed view, not the wider ArrayBufferLike
13+
// (e.g. SharedArrayBuffer) that a generic Uint8Array parameter admits.
14+
const buffer = new Uint8Array(bytes.byteLength)
15+
buffer.set(bytes)
16+
17+
const stream = new Blob([buffer]).stream().pipeThrough(new DecompressionStream('gzip'))
18+
const decompressed = await new Response(stream).arrayBuffer()
19+
return new TextDecoder('utf-8').decode(decompressed)
20+
}

src/runtime/server/sitemap/urlset/normalise.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,13 +195,24 @@ export function normaliseEntry(_e: ResolvedSitemapUrl, defaults?: Omit<SitemapUr
195195
}
196196

197197
const IS_VALID_W3C_DATE = [
198-
/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/,
198+
/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)$/,
199+
/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z)$/,
200+
/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z)$/,
199201
/^\d{4}-[01]\d-[0-3]\d$/,
200202
/^\d{4}-[01]\d$/,
201203
/^\d{4}$/,
202204
]
203205
export function isValidW3CDate(d: string) {
204-
return IS_VALID_W3C_DATE.some(r => r.test(d))
206+
if (!IS_VALID_W3C_DATE.some(r => r.test(d)))
207+
return false
208+
// The shape regex alone accepts impossible dates (e.g. month 13, Feb 30);
209+
// reject those explicitly rather than silently normalising garbage.
210+
const [year, month, day] = d.slice(0, 10).split('-').map(Number)
211+
if (month !== undefined && (month < 1 || month > 12))
212+
return false
213+
if (day !== undefined && (day < 1 || day > new Date(year!, month!, 0).getDate()))
214+
return false
215+
return true
205216
}
206217

207218
export function normaliseDate(date: string | Date): string

src/runtime/server/sitemap/urlset/sources.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { defu } from 'defu'
1212
import { getRequestHost } from 'h3'
1313
import { parseURL } from 'ufo'
1414
import { logger } from '../../../utils-pure'
15+
import { decodeSitemapResponseBytes } from './gzip'
1516

1617
export function normalizeSourceInput(source: SitemapSourceInput): SitemapSourceBase | SitemapSourceResolved {
1718
// string -> { fetch: string, context: { name: 'hook' } }
@@ -73,7 +74,11 @@ export async function fetchDataSource(input: SitemapSourceBase | SitemapSourceRe
7374

7475
try {
7576
let isMaybeErrorResponse = false
76-
const isXmlRequest = parseURL(url).pathname.endsWith('.xml')
77+
const pathname = parseURL(url).pathname.toLowerCase()
78+
// A `.gz` source (or a `.xml.gz` one) is still an XML sitemap once decompressed;
79+
// fetch it as bytes rather than assuming JSON.
80+
const isGzUrl = pathname.endsWith('.gz')
81+
const isXmlRequest = pathname.endsWith('.xml') || isGzUrl
7782

7883
// Merge external source headers with request headers
7984
const mergedHeaders = defu(
@@ -86,7 +91,10 @@ export async function fetchDataSource(input: SitemapSourceBase | SitemapSourceRe
8691

8792
const fetchOptions = {
8893
...options,
89-
responseType: isXmlRequest ? 'text' : 'json',
94+
// Fetch XML sources as raw bytes so we can detect and decompress a gzip body
95+
// (either a `.gz` URL, or a server that serves gzip without Content-Encoding)
96+
// before it's mangled by a UTF-8 text decode.
97+
responseType: isXmlRequest ? 'arrayBuffer' : 'json',
9098
signal: timeoutController.signal,
9199
headers: mergedHeaders,
92100
// Use ofetch's built-in retry for external sources
@@ -114,13 +122,24 @@ export async function fetchDataSource(input: SitemapSourceBase | SitemapSourceRe
114122
}
115123
}
116124
let urls = []
117-
if (typeof res === 'object') {
118-
urls = res.urls || res
119-
}
120-
else if (typeof res === 'string' && isXmlRequest) {
121-
const result = await parseSitemapXml(res)
125+
if (isXmlRequest) {
126+
const bytes = res instanceof Uint8Array ? res : new Uint8Array(res as ArrayBuffer)
127+
const text = await decodeSitemapResponseBytes(bytes)
128+
if (text.startsWith('<!DOCTYPE html>')) {
129+
return {
130+
...input,
131+
context,
132+
urls: [],
133+
timeTakenMs,
134+
error: 'Received HTML response instead of XML',
135+
}
136+
}
137+
const result = await parseSitemapXml(text)
122138
urls = result.urls
123139
}
140+
else if (typeof res === 'object') {
141+
urls = res.urls || res
142+
}
124143
return {
125144
...input,
126145
context,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { gzipSync } from 'node:zlib'
2+
import { describe, expect, it } from 'vitest'
3+
import { decodeSitemapResponseBytes } from '../../src/runtime/server/sitemap/urlset/gzip'
4+
5+
describe('decodeSitemapResponseBytes', () => {
6+
it('decompresses a gzip-compressed sitemap body (as served from a .xml.gz source)', async () => {
7+
const xml = '<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>https://example.com/</loc></url></urlset>'
8+
const compressed = new Uint8Array(gzipSync(Buffer.from(xml, 'utf-8')))
9+
10+
const result = await decodeSitemapResponseBytes(compressed)
11+
expect(result).toBe(xml)
12+
})
13+
14+
it('decompresses a gzip body served without a .gz extension (detected via magic bytes)', async () => {
15+
// Some servers serve a gzip-compressed body for a plain .xml URL without setting
16+
// Content-Encoding; the only signal is the 1f 8b magic bytes at the start.
17+
const xml = '<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>https://example.com/a</loc></url></urlset>'
18+
const compressed = new Uint8Array(gzipSync(Buffer.from(xml, 'utf-8')))
19+
20+
const result = await decodeSitemapResponseBytes(compressed)
21+
expect(result).toBe(xml)
22+
})
23+
24+
it('passes plain-text XML through untouched', async () => {
25+
const xml = '<?xml version="1.0" encoding="UTF-8"?><urlset><url><loc>https://example.com/</loc></url></urlset>'
26+
const bytes = new TextEncoder().encode(xml)
27+
28+
const result = await decodeSitemapResponseBytes(bytes)
29+
expect(result).toBe(xml)
30+
})
31+
32+
it('passes through empty input without throwing', async () => {
33+
const result = await decodeSitemapResponseBytes(new Uint8Array())
34+
expect(result).toBe('')
35+
})
36+
})

test/unit/lastmod.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,20 @@ describe('lastmod', () => {
1515
// time without timezone
1616
expect(normaliseDate('2023-12-21T13:49:27.963745')).toMatchInlineSnapshot(`"2023-12-21T13:49:27Z"`)
1717
})
18+
19+
it('rejects semantically impossible dates that match the shape regex', () => {
20+
// Feb 30 doesn't exist; 2024 is a leap year so Feb has 29 days max.
21+
expect(isValidW3CDate('2024-02-30')).toBeFalsy()
22+
// April has 30 days.
23+
expect(isValidW3CDate('2024-04-31')).toBeFalsy()
24+
// 2023 is not a leap year.
25+
expect(isValidW3CDate('2023-02-29')).toBeFalsy()
26+
// month 13 matches the [01]\d shape but isn't a real month.
27+
expect(isValidW3CDate('2024-13-01')).toBeFalsy()
28+
})
29+
30+
it('rejects garbage surrounding an otherwise-valid date (regex must be anchored)', () => {
31+
expect(isValidW3CDate('garbage2024-01-15T09:30:00Z')).toBeFalsy()
32+
expect(isValidW3CDate('2024-01-15T09:30:00Zjunk')).toBeFalsy()
33+
})
1834
})

test/unit/parseSitemapXml.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,4 +936,14 @@ describe('parseSitemapXml', () => {
936936
expect(warningTypes).toContain('validation')
937937
})
938938
})
939+
940+
it('tolerates a leading UTF-8 byte-order mark before the XML declaration', async () => {
941+
const BOM = String.fromCharCode(0xFEFF)
942+
const xml = `${BOM}<?xml version="1.0" encoding="UTF-8"?>
943+
<urlset>
944+
<url><loc>http://example.com/</loc></url>
945+
</urlset>`
946+
const result = await parseSitemapXml(xml)
947+
expect(result.urls).toEqual([{ loc: 'http://example.com/' }])
948+
})
939949
})

test/unit/sitemapIndex.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,18 @@ describe('parseSitemapIndex', () => {
147147

148148
await expect(parseSitemapIndex(xml)).rejects.toThrow('XML does not contain a valid sitemapindex element')
149149
})
150+
151+
it('tolerates a leading UTF-8 byte-order mark before the XML declaration', async () => {
152+
const BOM = String.fromCharCode(0xFEFF)
153+
const xml = `${BOM}<?xml version="1.0" encoding="UTF-8"?>
154+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
155+
<sitemap>
156+
<loc>https://example.com/sitemap.xml</loc>
157+
</sitemap>
158+
</sitemapindex>`
159+
160+
const { entries, warnings } = await parseSitemapIndex(xml)
161+
expect(entries).toEqual([{ loc: 'https://example.com/sitemap.xml' }])
162+
expect(warnings).toEqual([])
163+
})
150164
})

0 commit comments

Comments
 (0)