fix: checking for banned banned external links outside of external links

This commit is contained in:
tdgao
2026-08-26 19:04:37 -06:00
parent 3ec0dfcf35
commit dc613b4bc0
8 changed files with 117 additions and 40 deletions
+4 -4
View File
@@ -3,8 +3,8 @@ import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui
import type { Nag, NagContext } from '../../types/nags' import type { Nag, NagContext } from '../../types/nags'
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils' import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
import { import {
getBlockedProjectContentLink, getBlockedProjectExternalLink,
PROJECT_CONTENT_LINK_SHORTENERS, PROJECT_LINK_SHORTENERS,
} from '../../validators/project-links' } from '../../validators/project-links'
export const commonLinkDomains = { export const commonLinkDomains = {
@@ -49,7 +49,7 @@ export const commonLinkDomains = {
'example.com', 'example.com',
't.me', 't.me',
], ],
linkShorteners: PROJECT_CONTENT_LINK_SHORTENERS, linkShorteners: PROJECT_LINK_SHORTENERS,
} }
export function isCommonUrl(url: string | null, commonDomains: readonly string[]): boolean { export function isCommonUrl(url: string | null, commonDomains: readonly string[]): boolean {
@@ -92,7 +92,7 @@ export function findBlockedProjectExternalLink(context: Pick<NagContext, 'projec
for (const url of urls) { for (const url of urls) {
if (!url) continue if (!url) continue
const blockedLink = getBlockedProjectContentLink(url) const blockedLink = getBlockedProjectExternalLink(url)
if (blockedLink) return blockedLink if (blockedLink) return blockedLink
} }
@@ -134,6 +134,9 @@
"nags.invalid-project-summary.title": { "nags.invalid-project-summary.title": {
"defaultMessage": "Fix the project summary" "defaultMessage": "Fix the project summary"
}, },
"nags.link.description.invalid-url": {
"defaultMessage": "The description has an invalid link"
},
"nags.link.discord.channel": { "nags.link.discord.channel": {
"defaultMessage": "This is a link to a Discord channel, not a server invite." "defaultMessage": "This is a link to a Discord channel, not a server invite."
}, },
@@ -168,7 +171,7 @@
"defaultMessage": "The wiki is disabled on this repository." "defaultMessage": "The wiki is disabled on this repository."
}, },
"nags.link.invalid-url": { "nags.link.invalid-url": {
"defaultMessage": "There's an invalid URL in the description." "defaultMessage": "This URL is invalid"
}, },
"nags.link.license.url-mismatch": { "nags.link.license.url-mismatch": {
"defaultMessage": "This link points to the {detected} license, but your project is set to {selected}." "defaultMessage": "This link points to the {detected} license, but your project is set to {selected}."
@@ -1,6 +1,9 @@
import { computed, onScopeDispose, reactive, type Ref, watch } from 'vue' import { computed, onScopeDispose, reactive, type Ref, watch } from 'vue'
import { PROJECT_CONTENT_LINK_BLOCKLIST } from '../project-links/index.ts' import {
getBlockedProjectContentLink,
getBlockedProjectExternalLink,
} from '../project-links/index.ts'
interface MessageDescriptor { interface MessageDescriptor {
id: string id: string
@@ -104,11 +107,6 @@ function anchored(source: string): RegExp {
return new RegExp(`^${source}`, 'i') return new RegExp(`^${source}`, 'i')
} }
function blacklist(label: string, ...domains: string[]): LinkCheckBuilder {
const pattern = domains.map((domain) => domain.replace(/\./g, '\\.')).join('|')
return check(new RegExp(`^(?:[^./:?#]+\\.)*(?:${pattern})(?=[:/?#]|$)`, 'i'), label)
}
function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder { function buildNode(when: LinkCheckMatcher, label?: string): LinkCheckBuilder {
const childNodes: LinkCheckNode[] = [] const childNodes: LinkCheckNode[] = []
const forMatchers: FieldMatcher[] = [] const forMatchers: FieldMatcher[] = []
@@ -278,7 +276,12 @@ const coreMessages = defineMessages({
//TODO: we should probably just let you not provide https but backend currently requires it //TODO: we should probably just let you not provide https but backend currently requires it
const invalidUrlMessage = defineMessage({ const invalidUrlMessage = defineMessage({
id: 'nags.link.invalid-url', id: 'nags.link.invalid-url',
defaultMessage: "There's an invalid URL in the description.", defaultMessage: 'This URL is invalid',
})
const invalidDescriptionUrlMessage = defineMessage({
id: 'nags.link.description.invalid-url',
defaultMessage: 'The description has an invalid link',
}) })
function validUrlPrefix(remaining: string): number | null { function validUrlPrefix(remaining: string): number | null {
@@ -337,6 +340,13 @@ async function checkLink(context: LinkCheckContext) {
const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1') const normalizedUrl = url.replace(/^(https:\/\/)www\./i, '$1')
cache.set(key, 'pending') cache.set(key, 'pending')
const blockedLink = context.generalContent
? getBlockedProjectContentLink(url)
: getBlockedProjectExternalLink(url)
if (blockedLink) {
cache.set(key, error(coreMessages.neverValid, { label: blockedLink.label }))
return
}
const found = await matchNode(rootNode, normalizedUrl, context, true) const found = await matchNode(rootNode, normalizedUrl, context, true)
if (!found) { if (!found) {
@@ -361,7 +371,12 @@ async function checkLink(context: LinkCheckContext) {
const build = matched.unrecognizedSeverity === 'warn' ? warn : error const build = matched.unrecognizedSeverity === 'warn' ? warn : error
if (matched.unrecognizedMessage && isLeaf) { if (matched.unrecognizedMessage && isLeaf) {
cache.set(key, build(matched.unrecognizedMessage, { label: matched.label })) const message =
context.field === 'description' &&
matched.unrecognizedMessage.id === invalidUrlMessage.id
? invalidDescriptionUrlMessage
: matched.unrecognizedMessage
cache.set(key, build(message, { label: matched.label }))
return return
} }
@@ -830,8 +845,4 @@ checks.children(
), ),
) )
checks.children(
...PROJECT_CONTENT_LINK_BLOCKLIST.map(({ label, domains }) => blacklist(label, ...domains)),
)
export { checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck } export { checkLink, getLinkCheckState, isLinkCheckPending, useLinkCheck }
@@ -16,6 +16,22 @@ test('rejects invalid and insecure URLs', async () => {
assert.equal(getLinkCheckState(reserved)?.severity, 'error') assert.equal(getLinkCheckState(reserved)?.severity, 'error')
}) })
test('uses a description-specific message for invalid content links', async () => {
const context = {
field: 'description',
url: 'http://example.dev/project',
generalContent: true,
}
await checkLink(context)
assert.equal(getLinkCheckState(context)?.message?.id, 'nags.link.description.invalid-url')
assert.equal(
getLinkCheckState(context)?.message?.defaultMessage,
'The description has an invalid link',
)
})
test('matches recognized hosts case-insensitively', async () => { test('matches recognized hosts case-insensitively', async () => {
const googleForm = { field: 'issues', url: 'https://DOCS.GOOGLE.COM/forms/d/e/example' } const googleForm = { field: 'issues', url: 'https://DOCS.GOOGLE.COM/forms/d/e/example' }
const shortener = { field: 'source', url: 'https://BIT.LY/example' } const shortener = { field: 'source', url: 'https://BIT.LY/example' }
@@ -67,8 +83,12 @@ test('allows unrecognized valid links but keeps global restrictions in general c
assert.equal(getLinkCheckState(blocked)?.severity, 'error') assert.equal(getLinkCheckState(blocked)?.severity, 'error')
}) })
test('blocks subdomains of blocklisted hosts without blocking lookalike domains', async () => { test('applies the external-link blocklist only outside general content', async () => {
const blocked = { const blockedExternalLink = {
field: 'site',
url: 'https://social.modrinth.com/project',
}
const allowedContentLink = {
field: 'description', field: 'description',
url: 'https://social.modrinth.com/project', url: 'https://social.modrinth.com/project',
generalContent: true, generalContent: true,
@@ -79,10 +99,12 @@ test('blocks subdomains of blocklisted hosts without blocking lookalike domains'
generalContent: true, generalContent: true,
} }
await checkLink(blocked) await checkLink(blockedExternalLink)
await checkLink(allowedContentLink)
await checkLink(allowed) await checkLink(allowed)
assert.equal(getLinkCheckState(blocked)?.severity, 'error') assert.equal(getLinkCheckState(blockedExternalLink)?.severity, 'error')
assert.equal(getLinkCheckState(allowedContentLink)?.severity, 'valid')
assert.equal(getLinkCheckState(allowed)?.severity, 'valid') assert.equal(getLinkCheckState(allowed)?.severity, 'valid')
}) })
@@ -161,10 +161,7 @@ test('validates project summaries', () => {
}) })
test('rejects blocklisted links and IP addresses in summaries and descriptions', () => { test('rejects blocklisted links and IP addresses in summaries and descriptions', () => {
const blockedSummary = validateProjectSummary( const blockedSummary = validateProjectSummary('Visit https://bit.ly/project', 'Title')
'Visit https://social.modrinth.com/project',
'Title',
)
assert.deepEqual(blockedSummary[0], { assert.deepEqual(blockedSummary[0], {
code: 'text-banned-link', code: 'text-banned-link',
severity: 'error', severity: 'error',
@@ -173,8 +170,8 @@ test('rejects blocklisted links and IP addresses in summaries and descriptions',
defaultMessage: '“{url}” is not allowed in project summaries or descriptions.', defaultMessage: '“{url}” is not allowed in project summaries or descriptions.',
}, },
values: { values: {
label: 'Modrinth', label: 'URL shortener',
url: 'https://social.modrinth.com/project', url: 'https://bit.ly/project',
}, },
}) })
@@ -189,7 +186,7 @@ test('rejects blocklisted links and IP addresses in summaries and descriptions',
assert.equal(blockedIp[0]?.values?.label, 'IP address') assert.equal(blockedIp[0]?.values?.label, 'IP address')
const allowedDescription = validateProjectDescription( const allowedDescription = validateProjectDescription(
`Read more at https://example.dev/project. ${'More details. '.repeat(20)}`, `Read more at https://social.modrinth.com/project. ${'More details. '.repeat(20)}`,
) )
assert.equal( assert.equal(
allowedDescription.some(({ code }) => code === 'text-banned-link'), allowedDescription.some(({ code }) => code === 'text-banned-link'),
@@ -1,9 +1,9 @@
export interface ProjectContentLinkBlocklistEntry { export interface ProjectLinkBlocklistEntry {
label: string label: string
domains: readonly string[] domains: readonly string[]
} }
export const PROJECT_CONTENT_LINK_SHORTENERS = [ export const PROJECT_LINK_SHORTENERS = [
'bit.ly', 'bit.ly',
'adf.ly', 'adf.ly',
'tinyurl.com', 'tinyurl.com',
@@ -11,11 +11,17 @@ export const PROJECT_CONTENT_LINK_SHORTENERS = [
'is.gd', 'is.gd',
] as const ] as const
export const PROJECT_CONTENT_LINK_BLOCKLIST: readonly ProjectContentLinkBlocklistEntry[] = [ const URL_SHORTENER_BLOCKLIST_ENTRY: ProjectLinkBlocklistEntry = {
{ label: 'URL shortener',
label: 'URL shortener', domains: PROJECT_LINK_SHORTENERS,
domains: PROJECT_CONTENT_LINK_SHORTENERS, }
},
export const PROJECT_CONTENT_LINK_BLOCKLIST: readonly ProjectLinkBlocklistEntry[] = [
URL_SHORTENER_BLOCKLIST_ENTRY,
]
export const PROJECT_EXTERNAL_LINK_BLOCKLIST: readonly ProjectLinkBlocklistEntry[] = [
URL_SHORTENER_BLOCKLIST_ENTRY,
{ label: 'Twitter', domains: ['twitter.com', 'x.com'] }, { label: 'Twitter', domains: ['twitter.com', 'x.com'] },
{ label: 'Instagram', domains: ['instagram.com'] }, { label: 'Instagram', domains: ['instagram.com'] },
{ label: 'Facebook', domains: ['facebook.com'] }, { label: 'Facebook', domains: ['facebook.com'] },
@@ -37,7 +43,7 @@ export const PROJECT_CONTENT_LINK_BLOCKLIST: readonly ProjectContentLinkBlocklis
}, },
] ]
export interface BlockedProjectContentLink extends Record<string, unknown> { export interface BlockedProjectLink extends Record<string, unknown> {
label: string label: string
url: string url: string
} }
@@ -47,7 +53,10 @@ function isIpAddress(hostname: string) {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHostname) || strippedHostname.includes(':') return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(strippedHostname) || strippedHostname.includes(':')
} }
export function getBlockedProjectContentLink(url: string): BlockedProjectContentLink | null { function getBlockedProjectLink(
url: string,
blocklist: readonly ProjectLinkBlocklistEntry[],
): BlockedProjectLink | null {
let hostname: string let hostname: string
try { try {
hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '') hostname = new URL(url).hostname.toLowerCase().replace(/\.$/, '')
@@ -57,9 +66,17 @@ export function getBlockedProjectContentLink(url: string): BlockedProjectContent
if (isIpAddress(hostname)) return { label: 'IP address', url } if (isIpAddress(hostname)) return { label: 'IP address', url }
const entry = PROJECT_CONTENT_LINK_BLOCKLIST.find(({ domains }) => const entry = blocklist.find(({ domains }) =>
domains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)), domains.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)),
) )
return entry ? { label: entry.label, url } : null return entry ? { label: entry.label, url } : null
} }
export function getBlockedProjectContentLink(url: string): BlockedProjectLink | null {
return getBlockedProjectLink(url, PROJECT_CONTENT_LINK_BLOCKLIST)
}
export function getBlockedProjectExternalLink(url: string): BlockedProjectLink | null {
return getBlockedProjectLink(url, PROJECT_EXTERNAL_LINK_BLOCKLIST)
}
@@ -1,7 +1,12 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { getBlockedProjectContentLink, PROJECT_CONTENT_LINK_BLOCKLIST } from './index.ts' import {
getBlockedProjectContentLink,
getBlockedProjectExternalLink,
PROJECT_CONTENT_LINK_BLOCKLIST,
PROJECT_EXTERNAL_LINK_BLOCKLIST,
} from './index.ts'
test('blocks every configured project-content domain and its subdomains', () => { test('blocks every configured project-content domain and its subdomains', () => {
for (const { label, domains } of PROJECT_CONTENT_LINK_BLOCKLIST) { for (const { label, domains } of PROJECT_CONTENT_LINK_BLOCKLIST) {
@@ -18,9 +23,31 @@ test('blocks every configured project-content domain and its subdomains', () =>
} }
}) })
test('blocks every configured external-link domain and its subdomains', () => {
for (const { label, domains } of PROJECT_EXTERNAL_LINK_BLOCKLIST) {
for (const domain of domains) {
assert.deepEqual(getBlockedProjectExternalLink(`https://${domain}/project`), {
label,
url: `https://${domain}/project`,
})
assert.equal(
getBlockedProjectExternalLink(`https://subdomain.${domain}/project`)?.label,
label,
)
}
}
})
test('allows external-only blocklist entries in project content', () => {
assert.equal(getBlockedProjectContentLink('https://social.modrinth.com/project'), null)
assert.equal(getBlockedProjectExternalLink('https://social.modrinth.com/project')?.label, 'Modrinth')
})
test('blocks IP-address URLs without blocking domain lookalikes', () => { test('blocks IP-address URLs without blocking domain lookalikes', () => {
assert.equal(getBlockedProjectContentLink('http://127.0.0.1:25565')?.label, 'IP address') assert.equal(getBlockedProjectContentLink('http://127.0.0.1:25565')?.label, 'IP address')
assert.equal(getBlockedProjectContentLink('https://[2001:db8::1]')?.label, 'IP address') assert.equal(getBlockedProjectContentLink('https://[2001:db8::1]')?.label, 'IP address')
assert.equal(getBlockedProjectExternalLink('http://127.0.0.1:25565')?.label, 'IP address')
assert.equal(getBlockedProjectContentLink('https://modrinth.com.example.dev'), null) assert.equal(getBlockedProjectContentLink('https://modrinth.com.example.dev'), null)
assert.equal(getBlockedProjectExternalLink('https://modrinth.com.example.dev'), null)
assert.equal(getBlockedProjectContentLink('not a URL'), null) assert.equal(getBlockedProjectContentLink('not a URL'), null)
}) })
@@ -119,7 +119,7 @@ test('treats version numbers and explicit summary links as errors', () => {
test('rejects blocklisted links in summaries and descriptions', () => { test('rejects blocklisted links in summaries and descriptions', () => {
const summaryResult = validateProjectFields( const summaryResult = validateProjectFields(
createProject({ summary: 'Visit modrinth.com for more information' }), createProject({ summary: 'Visit bit.ly/project for more information' }),
) )
assert.equal(summaryResult.valid, false) assert.equal(summaryResult.valid, false)
assert.equal(summaryResult.failures[0]?.field, 'summary') assert.equal(summaryResult.failures[0]?.field, 'summary')