diff --git a/_data/navigation.yml b/_data/navigation.yml index f7ff45f..22cf6f7 100644 --- a/_data/navigation.yml +++ b/_data/navigation.yml @@ -8,5 +8,7 @@ link: /news/ - name: Apps link: /apps/ +- name: Check + link: /check/ - name: Community link: https://github.com/WebView-CG/Compatibility-Data-Project/discussions \ No newline at end of file diff --git a/assets/css/check.css b/assets/css/check.css new file mode 100644 index 0000000..bf5ac57 --- /dev/null +++ b/assets/css/check.css @@ -0,0 +1,234 @@ +/* + * Styles for the WebView Check widget (/check/), scoped under .check-page + * so they never leak onto the rest of the site. Colors follow the site's + * body.light-mode toggle rather than prefers-color-scheme, since that's + * what the site itself uses (see _js/_theme.js). + */ + +.check-page { + --bg: #14151a; + --fg: #e9e9ec; + --muted: #a5a5ad; + --border: #34353c; + --card-bg: #1d1e24; + --pass: #6cd67f; + --warn: #e3b341; + --fail: #ff6b64; + --info: #6fb3ff; + --link: #6fb3ff; + + box-sizing: border-box; + max-width: 46rem; + margin: 1.5rem auto; + padding: 1.5rem; + border-radius: 0.75rem; + background: var(--bg); + color: var(--fg); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + line-height: 1.5; +} + +body.light-mode .check-page { + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #5a5a5a; + --border: #d8d8d8; + --card-bg: #f7f7f8; + --pass: #1e7e34; + --warn: #8a6100; + --fail: #b3261e; + --info: #1a5a9e; + --link: #1a5a9e; +} + +.check-page *, +.check-page *::before, +.check-page *::after { + box-sizing: border-box; +} + +.check-page a { + color: var(--link); +} + +.check-page .check-page-header h1 { + margin: 0 0 0.25rem; +} + +.check-page .check-page-header p { + color: var(--muted); +} + +.check-page .verdict-banner { + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1rem 1.25rem; + margin: 1.5rem 0 1rem; +} + +.check-page .verdict-banner h2 { + margin: 0 0 0.25rem; +} + +.check-page .verdict-banner p { + margin: 0.5rem 0 0; + color: var(--muted); +} + +.check-page .verdict-banner p:first-of-type { + margin-top: 0; +} + +.check-page .verdict-basis { + margin: 0.75rem 0 0; +} + +.check-page .verdict-basis summary { + cursor: pointer; + color: var(--muted); + font-size: 0.9rem; +} + +.check-page .verdict-basis ul { + margin: 0.5rem 0 0; + padding-left: 1.25rem; + color: var(--muted); + font-size: 0.9rem; +} + +.check-page .verdict-counts { + font-size: 0.9rem; +} + +.check-page .verdict-pass { + border-left: 4px solid var(--pass); +} + +.check-page .verdict-warn { + border-left: 4px solid var(--warn); +} + +.check-page .verdict-info { + border-left: 4px solid var(--info); +} + +.check-page .actions { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} + +.check-page button { + font: inherit; + padding: 0.5rem 1rem; + border-radius: 0.4rem; + border: 1px solid var(--border); + background: var(--card-bg); + color: var(--fg); + cursor: pointer; +} + +.check-page button:hover { + filter: brightness(1.05); +} + +.check-page .category { + margin-bottom: 1.75rem; +} + +.check-page .category h3 { + margin-bottom: 0.15rem; +} + +.check-page .category-desc { + color: var(--muted); + margin-top: 0; + margin-bottom: 0.75rem; +} + +.check-page .test-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.check-page .test-item { + border: 1px solid var(--border); + border-radius: 0.4rem; + background: var(--card-bg); + padding: 0.15rem 0.85rem; +} + +.check-page .test-item summary { + cursor: pointer; + padding: 0.6rem 0; + list-style: none; +} + +.check-page .test-item summary::-webkit-details-marker { + display: none; +} + +.check-page .test-title { + font-weight: 600; +} + +.check-page .test-detail, +.check-page .test-refs { + color: var(--muted); + font-size: 0.9rem; + margin: 0 0 0.75rem; +} + +.check-page .check-icon { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: middle; +} + +.check-page .detected-badge { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + padding: 0.1rem 0.45rem; + border-radius: 1rem; + border: 1px solid currentColor; +} + +.check-page .status-pass .check-icon, +.check-page .status-pass .test-title, +.check-page .status-pass .detected-badge { + color: var(--pass); +} + +.check-page .status-warn .check-icon, +.check-page .status-warn .test-title, +.check-page .status-warn .detected-badge { + color: var(--warn); +} + +.check-page .status-fail .check-icon, +.check-page .status-fail .test-title, +.check-page .status-fail .detected-badge { + color: var(--fail); +} + +.check-page .status-info .check-icon, +.check-page .status-info .test-title, +.check-page .status-info .detected-badge { + color: var(--info); +} + +.check-page .check-page-footer { + margin-top: 2.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 0.85rem; +} diff --git a/assets/js/check/main.js b/assets/js/check/main.js new file mode 100644 index 0000000..3f1a075 --- /dev/null +++ b/assets/js/check/main.js @@ -0,0 +1,181 @@ +import { runAll, computeVerdict } from './tests/registry.js'; +import detectionTests from './tests/detection.js'; +import bridgeTests from './tests/bridge.js'; +import transportTests from './tests/transport.js'; +import chromeUiTests from './tests/chrome-ui.js'; +import storagePermissionsTests from './tests/storage-permissions.js'; + +const CATEGORIES = { + detection: { + title: 'WebView Detection', + description: 'Heuristics for whether this page is running inside a WebView at all.', + }, + bridge: { + title: 'Native Bridge Exposure', + description: 'Native bridge objects that let the host app inject or execute privileged code in this page.', + }, + transport: { + title: 'Transport Security', + description: 'Whether TLS validation and mixed-content protections are actually enforced.', + }, + 'chrome-ui': { + title: 'Browser Chrome / Origin Visibility', + description: 'Whether the user can see which origin and TLS status they are interacting with.', + }, + 'storage-permissions': { + title: 'Storage & Permissions', + description: 'Availability of storage and permission APIs, and tracking-protection transparency.', + }, +}; + +const STATUS_ICON = { pass: '✅', warn: '⚠️', fail: '❌', info: 'ℹ️' }; +const STATUS_LABEL = { pass: 'Pass', warn: 'Warning', fail: 'Fail', info: 'Info' }; + +const VERDICT_COPY = { + webview: { title: 'Likely running in a WebView', className: 'verdict-warn' }, + browser: { title: 'Likely a regular browser', className: 'verdict-pass' }, + uncertain: { title: 'Uncertain', className: 'verdict-info' }, +}; + +let lastResults = []; + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function renderBanner(verdict, results) { + const banner = document.getElementById('verdict-banner'); + const copy = VERDICT_COPY[verdict.verdict]; + const counts = results.reduce((acc, r) => { + acc[r.status] = (acc[r.status] || 0) + 1; + return acc; + }, {}); + + const contributing = results.filter((r) => r.signal); + const signalList = contributing + .map((r) => { + const sign = r.signal.label === 'webview' ? '+' : '−'; + return `
  • ${sign}${r.signal.weight} (toward ${escapeHtml(r.signal.label)}) — ${escapeHtml(r.title)}: ${escapeHtml(r.summary)}
  • `; + }) + .join(''); + + banner.className = `verdict-banner ${copy.className}`; + banner.innerHTML = ` +

    ${copy.title}

    + ${ + contributing.length + ? `
    + Based on ${contributing.length} signal${contributing.length === 1 ? '' : 's'} (net score: ${verdict.score}) + +
    ` + : '' + } +

    ${counts.fail || 0} failed, ${counts.warn || 0} warnings, ${counts.pass || 0} passed, ${counts.info || 0} informational.

    + `; +} + +function detectedBadge(item) { + if (item.detected === undefined) return ''; + const cls = item.detected === true ? 'detected-true' : item.detected === false ? 'detected-false' : 'detected-unknown'; + const label = item.detected === true ? 'Detected: Yes' : item.detected === false ? 'Detected: No' : 'Detected: Unknown'; + return `${label} `; +} + +function renderTestItem(item) { + const li = document.createElement('li'); + li.className = `test-item status-${item.status}`; + + const details = document.createElement('details'); + const summary = document.createElement('summary'); + summary.innerHTML = + ` ` + + `${escapeHtml(item.title)} ` + + `${detectedBadge(item)}` + + `— ${escapeHtml(item.summary)}`; + details.appendChild(summary); + + if (item.detail) { + const detail = document.createElement('p'); + detail.className = 'test-detail'; + detail.textContent = item.detail; + details.appendChild(detail); + } + + if (item.refs && item.refs.length) { + const refs = document.createElement('p'); + refs.className = 'test-refs'; + refs.innerHTML = + 'Reference: ' + + item.refs.map((r) => `${escapeHtml(r.label)}`).join(', '); + details.appendChild(refs); + } + + li.appendChild(details); + return li; +} + +function renderCategories(results) { + const container = document.getElementById('report'); + container.innerHTML = ''; + + for (const [key, meta] of Object.entries(CATEGORIES)) { + const items = results.filter((r) => r.category === key); + if (!items.length) continue; + + const section = document.createElement('section'); + section.className = 'category'; + + const heading = document.createElement('h3'); + heading.textContent = meta.title; + section.appendChild(heading); + + const desc = document.createElement('p'); + desc.className = 'category-desc'; + desc.textContent = meta.description; + section.appendChild(desc); + + const list = document.createElement('ul'); + list.className = 'test-list'; + items.forEach((item) => list.appendChild(renderTestItem(item))); + section.appendChild(list); + + container.appendChild(section); + } +} + +function reportAsText() { + const lines = lastResults.map((r) => `[${STATUS_LABEL[r.status]}] (${r.category}) ${r.title}: ${r.summary}`); + return [`WebView Check report — ${location.href}`, `User-Agent: ${navigator.userAgent}`, '', ...lines].join('\n'); +} + +async function main() { + const results = await runAll([ + detectionTests, + bridgeTests, + transportTests, + chromeUiTests, + storagePermissionsTests, + ]); + lastResults = results; + const verdict = computeVerdict(results); + + renderBanner(verdict, results); + renderCategories(results); +} + +document.getElementById('copy-report').addEventListener('click', async () => { + const button = document.getElementById('copy-report'); + try { + await navigator.clipboard.writeText(reportAsText()); + button.textContent = 'Copied!'; + } catch (err) { + button.textContent = 'Copy failed — clipboard unavailable'; + } + setTimeout(() => { + button.textContent = 'Copy report'; + }, 2000); +}); + +main(); diff --git a/assets/js/check/tests/bridge.js b/assets/js/check/tests/bridge.js new file mode 100644 index 0000000..2ac7c5b --- /dev/null +++ b/assets/js/check/tests/bridge.js @@ -0,0 +1,66 @@ +import { STATUS } from './registry.js'; + +const REPORT_SOP = { + label: '§6.1.2 Same Origin Policy Bypass — Security Considerations for WebViews', + url: 'https://webview-cg.github.io/webview-security/', +}; +const REPORT_BRIDGE = { + label: '§8.3 Bridge Validation — Security Considerations for WebViews', + url: 'https://webview-cg.github.io/webview-security/', +}; + +// Known native bridge globals. Detected by presence only — never invoked, since calling +// into a real bridge can trigger native side effects (camera, file pickers, navigation, ...). +const BRIDGES = [ + { path: ['webkit', 'messageHandlers'], name: 'window.webkit.messageHandlers', platform: 'WKWebView (iOS/macOS)' }, + { path: ['chrome', 'webview'], name: 'window.chrome.webview', platform: 'WebView2 (Windows)' }, + { path: ['Android'], name: 'window.Android', platform: 'common Android JS-interface name' }, + { path: ['ReactNativeWebView'], name: 'window.ReactNativeWebView', platform: 'React Native WebView' }, + { path: ['cordova'], name: 'window.cordova', platform: 'Apache Cordova' }, + { path: ['_cordovaNative'], name: 'window._cordovaNative', platform: 'Apache Cordova (Android bridge)' }, + { path: ['flutter_inappwebview'], name: 'window.flutter_inappwebview', platform: 'flutter_inappwebview plugin' }, + { path: ['Capacitor'], name: 'window.Capacitor', platform: 'Ionic Capacitor' }, + { path: ['__wxjs_environment'], name: 'window.__wxjs_environment', platform: 'WeChat embedded browser' }, +]; + +function resolvePath(path) { + let obj = window; + for (const key of path) { + if (obj == null || typeof obj !== 'object') return undefined; + obj = obj[key]; + } + return obj; +} + +function bridgeTest(bridge) { + return { + id: `bridge-${bridge.name}`, + category: 'bridge', + title: bridge.name, + refs: [REPORT_SOP, REPORT_BRIDGE], + run() { + const found = resolvePath(bridge.path) !== undefined; + if (found) { + return { + status: STATUS.WARN, + detected: true, + summary: `${bridge.name} is exposed on this page (${bridge.platform})`, + detail: + 'A native bridge object gives the host app a channel to inject and execute privileged code in this ' + + 'page\'s context, regardless of the page\'s own origin policy. This is expected in a hybrid app, but it ' + + 'means the host — not the page — controls what code can run here. This check only detects the object\'s ' + + 'presence and does not call it.', + signal: { label: 'webview', weight: 4 }, + }; + } + return { + status: STATUS.PASS, + detected: false, + summary: `${bridge.name} not present`, + detail: '', + }; + }, + }; +} + +export default BRIDGES.map(bridgeTest); diff --git a/assets/js/check/tests/chrome-ui.js b/assets/js/check/tests/chrome-ui.js new file mode 100644 index 0000000..dd5f575 --- /dev/null +++ b/assets/js/check/tests/chrome-ui.js @@ -0,0 +1,47 @@ +import { STATUS } from './registry.js'; + +const REPORT_UI = { + label: '§6.2.1 Visible Origin and TLS Status — Security Considerations for WebViews', + url: 'https://webview-cg.github.io/webview-security/', +}; + +const chromeVisibility = { + id: 'chrome-ui-bars-visible', + category: 'chrome-ui', + title: 'Visible browser chrome (address bar, toolbar, ...)', + refs: [REPORT_UI], + run() { + const bars = { + locationbar: !!window.locationbar && window.locationbar.visible, + toolbar: !!window.toolbar && window.toolbar.visible, + menubar: !!window.menubar && window.menubar.visible, + statusbar: !!window.statusbar && window.statusbar.visible, + }; + const anyVisible = Object.values(bars).some(Boolean); + const detail = Object.entries(bars) + .map(([name, visible]) => `${name}: ${visible}`) + .join(', '); + + if (anyVisible) { + return { + status: STATUS.PASS, + detected: true, + summary: 'Standard browser chrome (address bar or toolbar) is reported as visible', + detail, + signal: { label: 'browser', weight: 1 }, + }; + } + return { + status: STATUS.WARN, + detected: false, + summary: 'No standard browser chrome is reported as visible', + detail: + `${detail}. When no origin/TLS indicator is visible, a user has no way to verify which site they are ` + + 'interacting with, which is a common setup for fullscreen or embedded WebViews. Note this also happens in ' + + 'legitimate popups opened via window.open(), so treat this signal together with the others on this page.', + signal: { label: 'webview', weight: 2 }, + }; + }, +}; + +export default [chromeVisibility]; diff --git a/assets/js/check/tests/detection.js b/assets/js/check/tests/detection.js new file mode 100644 index 0000000..d14867b --- /dev/null +++ b/assets/js/check/tests/detection.js @@ -0,0 +1,86 @@ +import { STATUS } from './registry.js'; + +const REPORT_USAGE = { label: '§4 WebView Categories — Usage Scenarios and Challenges', url: 'https://webview-cg.github.io/usage-and-challenges/' }; + +const androidWvToken = { + id: 'detection-android-wv-token', + category: 'detection', + title: 'Android WebView user-agent token', + refs: [REPORT_USAGE], + run() { + const ua = navigator.userAgent; + const matches = /; ?wv\)/i.test(ua); + if (matches) { + return { + status: STATUS.WARN, + detected: true, + summary: 'User-agent carries the Android System WebView "wv" token', + detail: `User-agent: ${ua}`, + signal: { label: 'webview', weight: 3 }, + }; + } + return { + status: STATUS.PASS, + detected: false, + summary: 'No Android "wv" token present', + detail: `User-agent: ${ua}`, + }; + }, +}; + +const iosUaAmbiguity = { + id: 'detection-ios-ua', + category: 'detection', + title: 'iOS WebView user-agent signature', + refs: [REPORT_USAGE], + run() { + const ua = navigator.userAgent; + const isAppleWebKit = /iPhone|iPad|iPod|Macintosh/.test(ua) && /AppleWebKit/.test(ua); + if (!isAppleWebKit) { + return { + status: STATUS.INFO, + summary: 'Not an Apple WebKit platform user-agent — this check does not apply', + detail: `User-agent: ${ua}`, + }; + } + return { + status: STATUS.INFO, + detected: null, + summary: 'Cannot be determined — iOS does not expose a reliable WKWebView user-agent signature', + detail: + 'On Apple platforms a WKWebView-based in-app browser can present a user-agent nearly identical to Safari\'s. ' + + 'This check cannot reliably distinguish a WKWebView from Safari from the user-agent string alone — see the ' + + 'platform inconsistencies documented in the Usage Scenarios and Challenges report.', + }; + }, +}; + +const displayMode = { + id: 'detection-display-mode', + category: 'detection', + title: 'Standalone / app-like display mode', + refs: [REPORT_USAGE], + run() { + const modes = ['standalone', 'fullscreen', 'minimal-ui']; + const active = modes.find((m) => window.matchMedia && window.matchMedia(`(display-mode: ${m})`).matches); + if (active) { + return { + status: STATUS.WARN, + detected: true, + summary: `Page is running in "${active}" display mode`, + detail: + 'This mode is also used by installed PWAs, so on its own it is a weak signal — combine it with the bridge ' + + 'and browser-chrome checks below.', + signal: { label: 'webview', weight: 1 }, + }; + } + return { + status: STATUS.PASS, + detected: false, + summary: 'Page is running in a normal browser tab display mode', + detail: '', + }; + }, +}; + +export default [androidWvToken, iosUaAmbiguity, displayMode]; diff --git a/assets/js/check/tests/registry.js b/assets/js/check/tests/registry.js new file mode 100644 index 0000000..b45cda1 --- /dev/null +++ b/assets/js/check/tests/registry.js @@ -0,0 +1,41 @@ +export const STATUS = { PASS: 'pass', WARN: 'warn', FAIL: 'fail', INFO: 'info' }; + +export function withTimeout(promise, ms, fallback) { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(fallback), ms)), + ]); +} + +export async function runAll(testGroups) { + const tests = testGroups.flat(); + const results = []; + for (const test of tests) { + let outcome; + try { + outcome = await test.run(); + } catch (err) { + outcome = { status: STATUS.INFO, summary: 'Check failed to run', detail: String((err && err.message) || err) }; + } + results.push({ + id: test.id, + category: test.category, + title: test.title, + refs: test.refs || [], + ...outcome, + }); + } + return results; +} + +export function computeVerdict(results) { + let score = 0; + for (const r of results) { + if (!r.signal) continue; + score += r.signal.label === 'webview' ? r.signal.weight : -r.signal.weight; + } + let verdict = 'uncertain'; + if (score >= 3) verdict = 'webview'; + else if (score <= -2) verdict = 'browser'; + return { score, verdict }; +} diff --git a/assets/js/check/tests/storage-permissions.js b/assets/js/check/tests/storage-permissions.js new file mode 100644 index 0000000..079feae --- /dev/null +++ b/assets/js/check/tests/storage-permissions.js @@ -0,0 +1,82 @@ +import { STATUS } from './registry.js'; + +const REPORT_STORAGE = { + label: '§6.1.6 Storage Handling — Security Considerations for WebViews', + url: 'https://webview-cg.github.io/webview-security/', +}; +const REPORT_TRACKING = { + label: '§6.1.7 Anti-Tracking Measures — Security Considerations for WebViews', + url: 'https://webview-cg.github.io/webview-security/', +}; +const REPORT_PERMISSIONS = { + label: '§6.1.5 Permissions Handling — Security Considerations for WebViews', + url: 'https://webview-cg.github.io/webview-security/', +}; + +const cookiesEnabled = { + id: 'storage-cookies-enabled', + category: 'storage-permissions', + title: 'Cookies enabled', + refs: [REPORT_STORAGE], + run() { + if (navigator.cookieEnabled) { + return { status: STATUS.PASS, summary: 'Cookies are enabled', detail: '' }; + } + return { status: STATUS.INFO, summary: 'Cookies are disabled', detail: '' }; + }, +}; + +const storageAccessApi = { + id: 'storage-access-api', + category: 'storage-permissions', + title: 'Storage Access API availability', + refs: [REPORT_TRACKING], + run() { + const available = 'hasStorageAccess' in document; + return { + status: STATUS.INFO, + summary: available + ? 'document.hasStorageAccess is available' + : 'document.hasStorageAccess is not available', + detail: + 'This only reports whether the API exists, not whether tracking protection is actually enforced — the ' + + 'report notes users generally have no visibility into whether a host app has disabled tracking prevention ' + + '(e.g. via Android CookieManager) or whether iOS Intelligent Tracking Prevention is active.', + }; + }, +}; + +const PERMISSION_NAMES = ['geolocation', 'camera', 'microphone', 'notifications']; + +const permissionsMatrix = { + id: 'permissions-api-matrix', + category: 'storage-permissions', + title: 'Permissions API support matrix', + refs: [REPORT_PERMISSIONS], + async run() { + if (!navigator.permissions || !navigator.permissions.query) { + return { + status: STATUS.INFO, + summary: 'navigator.permissions is not available', + detail: 'Permission state cannot be queried from script in this environment.', + }; + } + const results = await Promise.all( + PERMISSION_NAMES.map(async (name) => { + try { + const status = await navigator.permissions.query({ name }); + return `${name}: queryable (state: ${status.state})`; + } catch (err) { + return `${name}: not queryable`; + } + }) + ); + return { + status: STATUS.INFO, + summary: 'Permissions API query support varies by platform', + detail: results.join('; '), + }; + }, +}; + +export default [cookiesEnabled, storageAccessApi, permissionsMatrix]; diff --git a/assets/js/check/tests/transport.js b/assets/js/check/tests/transport.js new file mode 100644 index 0000000..b55111c --- /dev/null +++ b/assets/js/check/tests/transport.js @@ -0,0 +1,98 @@ +import { STATUS, withTimeout } from './registry.js'; + +const REPORT_TLS = { + label: '§6.1.1 & §6.1.4 Host Control / TLS and Mixed Content — Security Considerations for WebViews', + url: 'https://webview-cg.github.io/webview-security/', +}; + +function probeFetch(url, timeoutMs = 5000) { + const attempt = fetch(url, { mode: 'no-cors', cache: 'no-store', redirect: 'follow' }) + .then(() => 'reached') + .catch(() => 'blocked'); + return withTimeout(attempt, timeoutMs, 'timeout'); +} + +const secureContext = { + id: 'transport-secure-context', + category: 'transport', + title: 'Secure context / page protocol', + refs: [REPORT_TLS], + run() { + if (location.protocol === 'file:') { + return { + status: STATUS.INFO, + summary: 'Page loaded from a local file:// origin', + detail: 'The mixed-content probe below does not apply to file:// origins.', + }; + } + if (window.isSecureContext && location.protocol === 'https:') { + return { status: STATUS.PASS, summary: 'Page is loaded over HTTPS in a secure context', detail: `Protocol: ${location.protocol}` }; + } + return { + status: STATUS.WARN, + summary: 'Page is not in a secure context', + detail: `Protocol: ${location.protocol}, isSecureContext: ${window.isSecureContext}`, + }; + }, +}; + +function tlsProbe(host) { + return { + id: `transport-tls-${host}`, + category: 'transport', + title: `TLS validation — ${host}`, + refs: [REPORT_TLS], + async run() { + const result = await probeFetch(`https://${host}/`); + if (result === 'reached') { + return { + status: STATUS.FAIL, + summary: `A request to ${host} (deliberately invalid certificate) succeeded`, + detail: + 'The environment appears to accept an invalid TLS certificate. A correctly configured browser or ' + + 'WebView should refuse this connection outright, since accepting it enables man-in-the-middle attacks.', + }; + } + if (result === 'blocked') { + return { + status: STATUS.PASS, + summary: `Request to ${host} was correctly rejected`, + detail: 'The deliberately invalid certificate was not accepted.', + }; + } + return { + status: STATUS.INFO, + summary: `Request to ${host} timed out`, + detail: 'Inconclusive — this can also happen when the network blocks the test domain outright.', + }; + }, + }; +} + +const mixedContent = { + id: 'transport-mixed-content', + category: 'transport', + title: 'Mixed content (plain HTTP from an HTTPS page)', + refs: [REPORT_TLS], + async run() { + if (location.protocol !== 'https:') { + return { status: STATUS.INFO, summary: 'Skipped — this page was not loaded over HTTPS', detail: '' }; + } + const result = await probeFetch('http://neverssl.com/'); + if (result === 'reached') { + return { + status: STATUS.WARN, + summary: 'A plain HTTP request from this HTTPS page succeeded', + detail: + 'Mixed content should normally be blocked automatically once a page is served over HTTPS. The host app ' + + 'may have relaxed this restriction, allowing unencrypted traffic to be intercepted or modified.', + }; + } + if (result === 'blocked') { + return { status: STATUS.PASS, summary: 'Mixed HTTP content was blocked as expected', detail: '' }; + } + return { status: STATUS.INFO, summary: 'Mixed content probe timed out', detail: 'Inconclusive.' }; + }, +}; + +export default [secureContext, tlsProbe('expired.badssl.com'), tlsProbe('self-signed.badssl.com'), mixedContent]; diff --git a/pages/check.html b/pages/check.html new file mode 100644 index 0000000..731c095 --- /dev/null +++ b/pages/check.html @@ -0,0 +1,44 @@ +--- +layout: default +title: WebView Check +permalink: /check/ +--- + + +
    +
    +

    WebView Check

    +

    + This page runs a set of in-browser checks to see whether it is being displayed inside a + native app's WebView, and whether that WebView is configured in a way that weakens the + security guarantees a normal browser tab provides. The checks are based on the + Security Considerations for WebViews + and + WebView: Usage Scenarios and Challenges + reports from the W3C WebView Community Group. +

    +
    + +
    +

    Running checks…

    +
    + +
    + +
    + +
    + + +
    + +