fix codeql findings: youtube host check and redos-prone url regex

- ExternalPlayer gated WebView navigation with
  event.url.includes('www.youtube.com'), which also matches hostile
  URLs like https://www.youtube.com.evil.com; parse and compare the
  hostname exactly.
- isValidUrlAndDomain used a nested-quantifier regex that backtracks
  exponentially on inputs like '//0.' + '00.'.repeat(n), and it runs on
  every composer keystroke; rewritten as linear-time parsing with the
  same semantics (http/https/ftp/protocol-relative, userinfo, public
  IPv4 or domain host, 2-5 digit port, whitespace-free path). New test
  file pins accept/reject behavior plus ReDoS canaries.

Both pre-existing (2024) issues surfaced by CodeQL on the migration PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-17 17:16:14 +03:00
parent ac2309c182
commit 46095a0220
3 changed files with 242 additions and 11 deletions
@@ -85,10 +85,19 @@ function Player({
// ensures we only load what's requested
// when it's a youtube video, we need to allow both bsky.app and youtube.com
const onShouldStartLoadWithRequest = useCallback(
(event: ShouldStartLoadRequest) =>
event.url === params.playerUri ||
(params.source.startsWith('youtube') &&
event.url.includes('www.youtube.com')),
(event: ShouldStartLoadRequest) => {
if (event.url === params.playerUri) return true
if (!params.source.startsWith('youtube')) return false
/*
* Compare the host exactly. A substring check like `includes` would also
* match hostile URLs such as `https://www.youtube.com.evil.com`.
*/
try {
return new URL(event.url).hostname === 'www.youtube.com'
} catch {
return false
}
},
[params.playerUri, params.source],
)
@@ -0,0 +1,115 @@
import {describe, expect, it} from '@jest/globals'
import {type LinkFacetMatch, suggestLinkCardUri} from './text-input-util'
/*
* `isValidUrlAndDomain` is module-private, so it is exercised here through its
* only caller. In "suggest immediately" mode, suggestLinkCardUri returns a URI
* iff it passes isValidUrlAndDomain, which lets us assert URL validity directly.
*/
function isValidUrl(uri: string): boolean {
const next = new Map<string, LinkFacetMatch>([[uri, {} as LinkFacetMatch]])
const result = suggestLinkCardUri(
/*suggestLinkImmediately*/ true,
next,
new Map(),
new Set(),
)
return result === uri
}
describe('isValidUrlAndDomain (via suggestLinkCardUri)', () => {
describe('accepts', () => {
it.each([
'http://example.com',
'https://example.com',
'ftp://example.com',
'//example.com',
'https://example.com/',
'https://example.com/path/to/page',
'https://example.com/path?query=1#frag',
'https://sub.example.co.uk',
'https://example.com:8080',
'https://example.com:8080/path',
'https://user:pass@example.com',
'https://user@example.com/path',
'https://xn--80ak6aa92e.com',
'https://münchen.de',
'https://ex-ample.com',
'https://a--b.example.com',
'https://1.example.com',
// public IPv4
'http://8.8.8.8',
'http://1.2.3.4',
'http://223.255.255.254',
'http://8.8.8.8:8080/path',
])('%s', uri => {
expect(isValidUrl(uri)).toBe(true)
})
})
describe('rejects private / reserved IPv4 ranges', () => {
it.each([
'http://10.0.0.1',
'http://10.255.255.255',
'http://127.0.0.1',
'http://169.254.1.1',
'http://192.168.0.1',
'http://172.16.0.1',
'http://172.31.255.255',
])('%s', uri => {
expect(isValidUrl(uri)).toBe(false)
})
})
describe('rejects out-of-range IPv4 octets', () => {
it.each([
'http://0.0.0.0', // first octet 0
'http://224.0.0.1', // first octet > 223
'http://999.1.1.1',
'http://1.2.3.0', // last octet 0
'http://1.2.3.255', // last octet 255
'http://256.1.1.1',
'http://1.2.3', // too few octets
'http://1.2.3.4.5', // too many octets
])('%s', uri => {
expect(isValidUrl(uri)).toBe(false)
})
})
describe('rejects malformed input', () => {
it.each([
'example.com', // no protocol / no leading //
'http:/example.com', // missing second slash
'httpx://example.com', // unsupported scheme
'gopher://example.com',
'https://example', // bare TLD-less host
'https://com', // single label
'https://example.c', // TLD too short
'https://-example.com', // leading hyphen
'https://example-.com', // trailing hyphen
'https://example.c0m', // digit in TLD
'https://example..com', // empty label
'https://example.com:1', // port too short
'https://example.com:123456', // port too long
'https://example.com:abc', // non-numeric port
'https://exa mple.com', // whitespace
'https://example.com/pa th', // whitespace in path
'https://@example.com', // empty userinfo
'',
'https://',
])('%s', uri => {
expect(isValidUrl(uri)).toBe(false)
})
})
describe('ReDoS canaries return quickly', () => {
it('rejects long "0." repetition without hanging', () => {
expect(isValidUrl('//' + '0.'.repeat(50000))).toBe(false)
})
it('rejects long "00." repetition without hanging', () => {
expect(isValidUrl('//0.' + '00.'.repeat(50000))).toBe(false)
})
})
})
@@ -84,11 +84,118 @@ export function suggestLinkCardUri(
return suggestedUri
}
// https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url
// question credit Muhammad Imran Tariq https://stackoverflow.com/users/420613/muhammad-imran-tariq
// answer credit Christian David https://stackoverflow.com/users/967956/christian-david
function isValidUrlAndDomain(value: string) {
return /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(
value,
)
/**
* A single alphanumeric domain character (unicode letters or digits).
*/
const DOMAIN_ALNUM = /^[a-z\u00a1-\uffff0-9]$/i
/**
* The final label (TLD): unicode letters only, 2 or more characters. Single
* repetition of a non-overlapping class, so this is linear-time.
*/
const DOMAIN_TLD = /^[a-z\u00a1-\uffff]{2,}$/i
/**
* Validates a single non-final domain label: unicode letters/digits with
* interior hyphens only (hyphen may not lead or trail), at least one character.
* Implemented charwise to stay unambiguously linear-time (no backtracking).
*/
function isValidDomainLabel(label: string): boolean {
if (label.length === 0) return false
if (!DOMAIN_ALNUM.test(label[0])) return false
if (!DOMAIN_ALNUM.test(label[label.length - 1])) return false
for (let i = 1; i < label.length - 1; i++) {
const ch = label[i]
if (ch !== '-' && !DOMAIN_ALNUM.test(ch)) return false
}
return true
}
/**
* Exactly four dot-separated groups of 1-3 digits. Anchored and bounded, so it
* fails fast without backtracking.
*/
const IPV4_SHAPE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
/**
* Validates a public IPv4 host, matching the octet ranges and private-range
* rejections of the original regex: first octet 1-223, middle octets 0-255,
* last octet 1-254; rejects 10.x, 127.x, 169.254.x, 192.168.x, and
* 172.16-31.x.
*/
function isPublicIpv4Host(host: string): boolean {
const [a, b, c, d] = host.split('.').map(Number)
if (a < 1 || a > 223) return false
if (b < 0 || b > 255) return false
if (c < 0 || c > 255) return false
if (d < 1 || d > 254) return false
if (a === 10 || a === 127) return false
if (a === 169 && b === 254) return false
if (a === 192 && b === 168) return false
if (a === 172 && b >= 16 && b <= 31) return false
return true
}
/**
* Validates a hostname as a domain name: at least two labels, every non-final
* label a valid domain label, and a final letters-only TLD of 2+ characters.
*/
function isValidDomainHost(host: string): boolean {
const labels = host.split('.')
if (labels.length < 2) return false
if (!DOMAIN_TLD.test(labels[labels.length - 1])) return false
for (let i = 0; i < labels.length - 1; i++) {
if (!isValidDomainLabel(labels[i])) return false
}
return true
}
/*
* Rewritten (was a single StackOverflow URL regex with nested quantifiers that
* CodeQL flagged as ReDoS-prone - see git history). This runs on every composer
* keystroke via suggestLinkCardUri, so it must be linear-time. Behavior is
* preserved: the string must start with http://, https://, ftp://, or a
* protocol-relative //; may carry userinfo; the host is a public IPv4 or a
* domain name; an optional 2-5 digit port; and an optional path/query/fragment
* with no whitespace. Every regex below is applied to a bounded slice with no
* nested quantifiers.
*
* Original StackOverflow attribution:
* https://stackoverflow.com/questions/8667070/javascript-regular-expression-to-validate-url
* question credit Muhammad Imran Tariq https://stackoverflow.com/users/420613/muhammad-imran-tariq
* answer credit Christian David https://stackoverflow.com/users/967956/christian-david
*/
function isValidUrlAndDomain(value: string) {
// The whole match forbids whitespace (\S / anchored), so bail early.
if (/\s/.test(value)) return false
// A leading // is required; the scheme, if present, must be http/https/ftp.
let rest: string
if (value.startsWith('http://')) rest = value.slice('http://'.length)
else if (value.startsWith('https://')) rest = value.slice('https://'.length)
else if (value.startsWith('ftp://')) rest = value.slice('ftp://'.length)
else if (value.startsWith('//')) rest = value.slice('//'.length)
else return false
// The authority ends at the first path/query/fragment delimiter.
const authorityEnd = rest.search(/[/?#]/)
const authority = authorityEnd === -1 ? rest : rest.slice(0, authorityEnd)
// Optional userinfo: greedy up to the last @ (as \S+(?::\S*)?@ would match).
let hostPort = authority
const at = authority.lastIndexOf('@')
if (at !== -1) {
if (at === 0) return false // userinfo must be non-empty
hostPort = authority.slice(at + 1)
}
// Optional :port of 2-5 digits.
let host = hostPort
const colon = hostPort.lastIndexOf(':')
if (colon !== -1) {
const port = hostPort.slice(colon + 1)
if (!/^\d{2,5}$/.test(port)) return false
host = hostPort.slice(0, colon)
}
if (IPV4_SHAPE.test(host)) return isPublicIpv4Host(host)
return isValidDomainHost(host)
}