Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,16 +718,16 @@ app.get('/', (c) => {
<section id="manage" style="box-shadow: 6px 6px 0 var(--violet);">
<h2>Manage verified links</h2>
<p>View and remove your linked identity verifications. You need to be signed in (Verify Here, Step 1).</p>
<button id="load-links-btn" class="verify-btn verify-btn-primary" type="button" onclick="loadLinkedVerifications()">Load my links</button>
<button id="load-links-btn" class="verify-btn verify-btn-primary" type="button">Load my links</button>
<div id="manage-links-container" style="margin-top:1rem;"></div>
<div id="manage-status" class="status-row"></div>
<div id="remove-confirm-dialog" style="display:none;margin-top:1rem;padding:16px;background:rgba(255,127,175,0.18);border:2px solid var(--dark);border-radius:16px;">
<h4 style="margin-bottom:0.5rem;color:var(--dark);">Remove this verification?</h4>
<p>This unlinks <strong id="remove-confirm-claim"></strong> from your Nostr profile.</p>
<p class="field-help">Relay updates may take a short moment.</p>
<div style="display:flex;gap:10px;margin-top:0.75rem;flex-wrap:wrap;">
<button class="verify-btn" type="button" onclick="cancelRemove()">Cancel</button>
<button class="verify-btn" type="button" style="background:var(--pink);color:var(--dark);" onclick="executeRemoveVerification()">Remove verification</button>
<button id="remove-cancel-btn" class="verify-btn" type="button">Cancel</button>
<button id="remove-confirm-btn" class="verify-btn" type="button" style="background:var(--pink);color:var(--dark);">Remove verification</button>
</div>
</div>
</section>
Expand All @@ -739,7 +739,7 @@ app.get('/', (c) => {

<div style="display:flex;gap:10px;margin-bottom:1rem;flex-wrap:wrap;">
<input id="lookup-input" type="text" class="lookup-input" placeholder="example@divine.video or npub1...">
<button id="lookup-btn" class="lookup-btn" onclick="doLookup()">Check</button>
<button id="lookup-btn" class="lookup-btn" type="button">Check</button>
</div>
<div id="lookup-status" class="lookup-status"></div>
<div id="lookup-results"></div>
Expand Down Expand Up @@ -2979,6 +2979,15 @@ Content-Type: application/json
document.getElementById('bunker-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') connectBunkerSigner();
});

// Buttons wired here rather than with inline onclick attributes, which a
// script-nonce Content-Security-Policy would block. They come before the
// start-up code below so an error there cannot leave them dead.
document.getElementById('load-links-btn').addEventListener('click', loadLinkedVerifications);
document.getElementById('remove-cancel-btn').addEventListener('click', cancelRemove);
document.getElementById('remove-confirm-btn').addEventListener('click', executeRemoveVerification);
document.getElementById('lookup-btn').addEventListener('click', doLookup);

// Start-up: a returning sign-in is handled first, before any signer is
// restored, so its one-time code leaves the address straight away.
updateOAuthInputs();
Expand Down
115 changes: 115 additions & 0 deletions src/no-inline-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import worker from './index'
import { renderVerifyHtml } from './routes/verify'
import type { VerifyResult } from './types'

// A Content-Security-Policy with a script nonce blocks inline event-handler
// attributes and javascript: links, so neither page may carry one. Buttons are
// wired from each page's script instead.

const API = 'https://verifier.divine.video'
const PUBKEY = 'ab'.repeat(32)
const RESULT: VerifyResult = { platform: 'github', identity: 'octocat', verified: true, checked_at: 1_700_000_000, cached: false }

// Every start tag, treating a '>' inside a quoted attribute value as part of it.
const START_TAG = /<[a-z](?:"[^"]*"|'[^']*'|[^>"'])*>/gi

// True when a start tag carries an on* attribute, whatever its quoting.
function hasInlineHandler(markup: string): boolean {
return (markup.match(START_TAG) ?? []).some(tag => /\son[a-z]+\s*=/i.test(tag.replace(/"[^"]*"|'[^']*'/g, '""')))
}
const JAVASCRIPT_URL = /(?:href|src|action|formaction|xlink:href)\s*=\s*["']?\s*javascript:/i

// The page with its <script> bodies removed, so only markup is checked.
function markupOnly(html: string): string {
return html.replace(/<script[\s\S]*?<\/script>/gi, '')
}

async function mainPage(): Promise<string> {
return (await worker.fetch(new Request(`${API}/`), {} as never)).text()
}

function verificationPage(): string {
return renderVerifyHtml(RESULT, 'github', 'octocat', 'abc123', PUBKEY, 'npub1test', `${API}/verify/github/octocat/abc123?pubkey=${PUBKEY}`, API)
}

const BUTTONS: Array<[string, string]> = [
['load-links-btn', 'loadLinkedVerifications'],
['remove-cancel-btn', 'cancelRemove'],
['remove-confirm-btn', 'executeRemoveVerification'],
['lookup-btn', 'doLookup'],
]

// Runs the page's real button-wiring lines against stand-in buttons, so a
// commented-out, misplaced or mis-paired listener fails here, not on the live page.
async function wireButtons() {
const html = await mainPage()
const start = html.indexOf('// Buttons wired here rather')
const end = html.indexOf('// Start-up: a returning sign-in is handled first', start)
expect(start).toBeGreaterThan(-1)
expect(end).toBeGreaterThan(start)
const listeners: Record<string, Array<[string, () => void]>> = {}
const calls: string[] = []
const document = {
getElementById: (id: string) => BUTTONS.some(([b]) => b === id)
? { addEventListener: (type: string, fn: () => void) => { (listeners[id] ??= []).push([type, fn]) } }
: null,
}
const handlers = BUTTONS.map(([, fn]) => () => { calls.push(fn) })
new Function('document', ...BUTTONS.map(([, fn]) => fn), html.slice(start, end))(document, ...handlers)
const click = (id: string) => (listeners[id] ?? []).forEach(([type, fn]) => { if (type === 'click') fn() })
return { click, calls }
}

describe('pages carry no inline handlers', () => {
it('the main page has no on* attribute and no javascript: URL in its markup', async () => {
const markup = markupOnly(await mainPage())
expect(hasInlineHandler(markup)).toBe(false)
expect(markup).not.toMatch(JAVASCRIPT_URL)
})

it('the verification-link page has no on* attribute and no javascript: URL in its markup', () => {
const markup = markupOnly(verificationPage())
expect(hasInlineHandler(markup)).toBe(false)
expect(markup).not.toMatch(JAVASCRIPT_URL)
})

it('neither page builds on* attributes or javascript: URLs inside its script strings', async () => {
for (const html of [await mainPage(), verificationPage()]) {
expect(html).not.toMatch(/\son[a-z]+\s*=\s*["'\\]/i)
expect(html).not.toMatch(/javascript:/i)
}
})

it('the checker sees an on* attribute after a quoted ">"', () => {
expect(hasInlineHandler('<div data-x=">" onclick="go()">')).toBe(true)
expect(hasInlineHandler('<div data-x=">" class="a">')).toBe(false)
})

it('the main page has each of the four buttons, once', async () => {
const html = await mainPage()
for (const [id] of BUTTONS) expect(html.split(`id="${id}"`).length - 1).toBe(1)
})

it('every wired button is type="button", so it can never submit a form', async () => {
const html = await mainPage()
for (const [id] of BUTTONS) expect(html).toMatch(new RegExp(`<button id="${id}"[^>]*type="button"|<button[^>]*type="button"[^>]*id="${id}"`))
})

it.each(BUTTONS)('clicking %s runs %s, and nothing else', async (id, fn) => {
const page = await wireButtons()
page.click(id)
expect(page.calls).toEqual([fn])
})

it('the buttons are wired before start-up code runs, so a start-up error cannot leave them dead', async () => {
const html = await mainPage()
const startUp = html.indexOf('// Start-up: a returning sign-in is handled first')
expect(startUp).toBeGreaterThan(-1)
for (const id of ['load-links-btn', 'remove-cancel-btn', 'remove-confirm-btn', 'lookup-btn']) {
const wired = html.indexOf(`getElementById('${id}').addEventListener(`)
expect(wired).toBeGreaterThan(-1)
expect(wired).toBeLessThan(startUp)
}
})
})
Loading