113 lines
3.8 KiB
JavaScript
113 lines
3.8 KiB
JavaScript
import { isIP } from 'node:net';
|
|
|
|
const PLACEHOLDER_PATTERN = /__|change[-_ ]?me|your[-_ ]?|example\.(com|org|net)/i;
|
|
const RESERVED_HOSTNAME_PATTERN = /(?:^|\.)(?:example|invalid|test)$/i;
|
|
|
|
const isPrivateIpv4 = (hostname) => {
|
|
const parts = hostname.split('.').map(Number);
|
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
return false;
|
|
}
|
|
return parts[0] === 0
|
|
|| parts[0] === 10
|
|
|| parts[0] === 127
|
|
|| (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
|
|
|| (parts[0] === 169 && parts[1] === 254)
|
|
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
|| (parts[0] === 192 && parts[1] === 168)
|
|
|| (parts[0] === 192 && parts[1] === 0 && (parts[2] === 0 || parts[2] === 2))
|
|
|| (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19))
|
|
|| (parts[0] === 198 && parts[1] === 51 && parts[2] === 100)
|
|
|| (parts[0] === 203 && parts[1] === 0 && parts[2] === 113)
|
|
|| parts[0] >= 224;
|
|
};
|
|
|
|
const isPrivateHostname = (hostname) => {
|
|
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
const ipVersion = isIP(normalized);
|
|
return normalized === 'localhost'
|
|
|| normalized.endsWith('.localhost')
|
|
|| (ipVersion === 6 && (
|
|
normalized === '::1'
|
|
|| /^f[cd]/.test(normalized)
|
|
|| normalized.startsWith('fe80:')
|
|
))
|
|
|| (ipVersion === 4 && isPrivateIpv4(normalized));
|
|
};
|
|
|
|
export const normalizeLegalUrl = (rawValue, label = 'legal URL') => {
|
|
const value = String(rawValue || '').trim();
|
|
if (!value) {
|
|
throw new Error(`${label} is required`);
|
|
}
|
|
if (PLACEHOLDER_PATTERN.test(value)) {
|
|
throw new Error(`${label} must not use a placeholder or example domain`);
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch {
|
|
throw new Error(`${label} must be a valid absolute URL`);
|
|
}
|
|
if (parsed.protocol !== 'https:') {
|
|
throw new Error(`${label} must use HTTPS`);
|
|
}
|
|
if (parsed.username || parsed.password) {
|
|
throw new Error(`${label} must not contain embedded credentials`);
|
|
}
|
|
if (RESERVED_HOSTNAME_PATTERN.test(parsed.hostname)) {
|
|
throw new Error(`${label} must not use a reserved hostname`);
|
|
}
|
|
if (isPrivateHostname(parsed.hostname)) {
|
|
throw new Error(`${label} must use a public hostname`);
|
|
}
|
|
return parsed.toString();
|
|
};
|
|
|
|
export const validateLegalUrlPair = (termsValue, privacyValue) => {
|
|
const termsUrl = normalizeLegalUrl(termsValue, 'service agreement URL');
|
|
const privacyUrl = normalizeLegalUrl(privacyValue, 'privacy policy URL');
|
|
if (termsUrl === privacyUrl) {
|
|
throw new Error('service agreement URL and privacy policy URL must be different');
|
|
}
|
|
return { termsUrl, privacyUrl };
|
|
};
|
|
|
|
export const validateAndroidPrivacyDocument = (privacy) => {
|
|
if (!privacy || typeof privacy !== 'object') {
|
|
throw new Error('androidPrivacy.json must contain a JSON object');
|
|
}
|
|
if (privacy.prompt !== 'none') {
|
|
throw new Error('androidPrivacy.json must use prompt=none for the controlled internal App');
|
|
}
|
|
if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) {
|
|
throw new Error('androidPrivacy.json version must be a positive numeric configuration version');
|
|
}
|
|
for (const templateOnlyField of [
|
|
'title',
|
|
'message',
|
|
'buttonAccept',
|
|
'buttonRefuse',
|
|
'hrefLoader',
|
|
'second',
|
|
'disagreeMode',
|
|
'styles'
|
|
]) {
|
|
if (Object.hasOwn(privacy, templateOnlyField)) {
|
|
throw new Error(`androidPrivacy.json prompt=none must not contain ${templateOnlyField}`);
|
|
}
|
|
}
|
|
return {
|
|
prompt: privacy.prompt,
|
|
version: String(privacy.version)
|
|
};
|
|
};
|
|
|
|
export const renderAndroidPrivacy = (templateText, termsValue, privacyValue) => {
|
|
validateLegalUrlPair(termsValue, privacyValue);
|
|
const privacy = JSON.parse(String(templateText));
|
|
validateAndroidPrivacyDocument(privacy);
|
|
return `${JSON.stringify(privacy, null, 2)}\n`;
|
|
};
|