diff --git a/popup/views/onboarding-view.js b/popup/views/onboarding-view.js
index de190c6..5dae119 100644
--- a/popup/views/onboarding-view.js
+++ b/popup/views/onboarding-view.js
@@ -2,10 +2,44 @@ import { fetchGitHubRepoFromNpm } from '../../shared/api/npm-api.js';
import { OnboardingManager } from '../../shared/onboarding.js';
import { getToken, setToken } from '../../shared/storage-helpers.js';
import { createHeaders } from '../../shared/github-api.js';
+import { escapeHtml } from '../../shared/sanitize.js';
// Create onboarding manager instance
const onboardingManager = new OnboardingManager();
+function getStatusMarkup(type, message) {
+ return `
${escapeHtml(message)}
`;
+}
+
+function renderRepoSuggestion(repo) {
+ const rawOwner = repo?.owner?.login || 'unknown';
+ const rawName = repo?.name || 'unknown';
+ const owner = escapeHtml(rawOwner);
+ const name = escapeHtml(rawName);
+ const description = escapeHtml(repo?.description || `${repo?.language || 'Popular'} project`);
+ const language = escapeHtml(repo?.language || '');
+ const repoFullName = `${rawOwner}/${rawName}`;
+ const stars = Number.isFinite(repo?.stargazers_count)
+ ? repo.stargazers_count.toLocaleString()
+ : '';
+
+ return `
+
+
+
+ ${owner} /${name}
+
+
${description}
+
+
+
+
+
+ `;
+}
+
/**
* Onboarding view functions for popup
* Handles the multi-step onboarding wizard for first-time users
@@ -170,13 +204,15 @@ async function renderTokenStep() {
let statusHtml = '';
let buttonDisabled = '';
let buttonText = 'Validate';
+ const safeToken = escapeHtml(tokenData?.token || '');
+ const safeTokenUrl = escapeHtml(tokenUrl);
if (tokenData && tokenData.validated && tokenData.username) {
- statusHtml = `✓ Token is valid! Logged in as ${tokenData.username}
`;
+ statusHtml = getStatusMarkup('success', `✓ Token is valid! Logged in as ${tokenData.username}`);
buttonDisabled = 'disabled';
buttonText = 'Validated';
} else if (tokenData && tokenData.validated) {
- statusHtml = '✓ Token is valid!
';
+ statusHtml = getStatusMarkup('success', '✓ Token is valid!');
buttonDisabled = 'disabled';
buttonText = 'Validated';
}
@@ -189,7 +225,7 @@ async function renderTokenStep() {
Quick setup:
- Create a GitHub token
+ Create a GitHub token
Copy the generated token
Paste it below
@@ -202,7 +238,7 @@ async function renderTokenStep() {
placeholder="ghp_YourTokenHere"
class="token-input"
autocomplete="off"
- value="${tokenData.token || ''}"
+ value="${safeToken}"
>
${buttonText}
@@ -238,21 +274,7 @@ export async function renderReposStep() {
Popular repositories:
${popularRepos && popularRepos.length > 0 ?
- popularRepos.map(repo => `
-
-
-
- ${repo.owner.login} /${repo.name}
-
-
${repo.description || `${repo.language || 'Popular'} project`}
-
-
-
+
-
- `).join('') :
+ popularRepos.map(renderRepoSuggestion).join('') :
'
Loading popular repositories...
'
}
@@ -517,11 +539,11 @@ function setupTokenStepListeners() {
validateBtn?.addEventListener('click', async () => {
const token = tokenInput.value.trim();
if (!token) {
- tokenStatus.innerHTML = 'Please enter a token
';
+ tokenStatus.innerHTML = getStatusMarkup('error', 'Please enter a token');
return;
}
- tokenStatus.innerHTML = 'Validating token...
';
+ tokenStatus.innerHTML = getStatusMarkup('loading', 'Validating token...');
try {
// Test the token by making a simple API call
@@ -535,7 +557,7 @@ function setupTokenStepListeners() {
const userData = await response.json();
const username = userData.login;
const tokenData = { token, validated: true, username };
- tokenStatus.innerHTML = `✓ Token is valid! Logged in as ${username}
`;
+ tokenStatus.innerHTML = getStatusMarkup('success', `✓ Token is valid! Logged in as ${username}`);
await onboardingManager.saveStepData('token', tokenData);
// Persist the token first so any calls which read it
// can rely on the token being present. This reduces the chance of
@@ -550,10 +572,10 @@ function setupTokenStepListeners() {
// Silently handle prefetch errors - not critical
}
} else {
- tokenStatus.innerHTML = '✗ Invalid token
';
+ tokenStatus.innerHTML = getStatusMarkup('error', '✗ Invalid token');
}
} catch (_error) {
- tokenStatus.innerHTML = 'Error validating token
';
+ tokenStatus.innerHTML = getStatusMarkup('error', 'Error validating token');
}
});
}
@@ -567,21 +589,7 @@ async function loadPopularRepos() {
if (popularRepos && popularRepos.length > 0) {
// Success: render the repos
- repoSuggestions.innerHTML = popularRepos.map(repo => `
-
-
-
- ${repo.owner.login} /${repo.name}
-
-
${repo.description || `${repo.language || 'Popular'} project`}
-
-
-
+
-
- `).join('');
+ repoSuggestions.innerHTML = popularRepos.map(renderRepoSuggestion).join('');
// Re-attach event listeners to new buttons
attachRepoButtonListeners();
@@ -685,7 +693,7 @@ function setupReposStepListeners() {
let repo = manualInput.value.trim();
if (!repo) return;
- repoStatus.innerHTML = 'Validating repository...
';
+ repoStatus.innerHTML = getStatusMarkup('loading', 'Validating repository...');
try {
// Get token for API calls
@@ -704,14 +712,14 @@ function setupReposStepListeners() {
repo = npmResult.repo;
manualInput.value = repo; // Update input to show GitHub repo
} else {
- repoStatus.innerHTML = `${npmResult.error}
`;
+ repoStatus.innerHTML = getStatusMarkup('error', npmResult.error);
return;
}
}
// Validate owner/repo format
if (!repo.includes('/') || repo.split('/').length !== 2 || !repo.split('/')[0] || !repo.split('/')[1]) {
- repoStatus.innerHTML = 'Invalid format. Use: owner/repo, GitHub URL, or npm package
';
+ repoStatus.innerHTML = getStatusMarkup('error', 'Invalid format. Use: owner/repo, GitHub URL, or npm package');
return;
}
@@ -746,19 +754,19 @@ function setupReposStepListeners() {
await chrome.storage.sync.set({ watchedRepos: repos });
}
manualInput.value = '';
- repoStatus.innerHTML = '✓ Repository added
';
+ repoStatus.innerHTML = getStatusMarkup('success', '✓ Repository added');
} else {
if (response.status === 404) {
- repoStatus.innerHTML = 'Repository not found on GitHub
';
+ repoStatus.innerHTML = getStatusMarkup('error', 'Repository not found on GitHub');
} else if (response.status === 403) {
- repoStatus.innerHTML = 'GitHub API rate limit exceeded. Try again later.
';
+ repoStatus.innerHTML = getStatusMarkup('error', 'GitHub API rate limit exceeded. Try again later.');
} else {
- repoStatus.innerHTML = `Error validating repository (${response.status})
`;
+ repoStatus.innerHTML = getStatusMarkup('error', `Error validating repository (${response.status})`);
}
}
} catch (error) {
console.error('Error adding repository:', error);
- repoStatus.innerHTML = 'Network error. Please check your connection.
';
+ repoStatus.innerHTML = getStatusMarkup('error', 'Network error. Please check your connection.');
}
};
diff --git a/tests/onboarding.test.js b/tests/onboarding.test.js
index 8eadde5..b44dbf8 100644
--- a/tests/onboarding.test.js
+++ b/tests/onboarding.test.js
@@ -1,4 +1,5 @@
import { jest, describe, beforeEach, test, expect } from '@jest/globals';
+import { TextEncoder } from 'node:util';
// Simple mock storage to simulate chrome.storage.local for onboarding flows
let _localStorage = {};
@@ -63,6 +64,26 @@ import { OnboardingManager } from '../shared/onboarding.js';
let _handleNextStep;
let _renderReposStep;
+let _renderOnboardingStep;
+
+async function renderTokenStep(stateOverrides = {}) {
+ _localStorage = {
+ onboarding_state: {
+ currentStep: 1,
+ completed: false,
+ skippedSteps: [],
+ data: {},
+ ...stateOverrides
+ }
+ };
+
+ document.body.innerHTML = `
+
+
+ `;
+
+ await _renderOnboardingStep();
+}
describe('Onboarding - token persistence', () => {
beforeEach(async () => {
@@ -80,11 +101,32 @@ describe('Onboarding - token persistence', () => {
jest.clearAllMocks();
document.body.innerHTML = '';
+ Object.defineProperty(globalThis, 'crypto', {
+ configurable: true,
+ value: {
+ getRandomValues: jest.fn((array) => {
+ array.fill(1);
+ return array;
+ }),
+ subtle: {
+ generateKey: jest.fn(async () => ({ mockKey: true })),
+ importKey: jest.fn(async () => ({ mockKey: true })),
+ exportKey: jest.fn(async () => new Uint8Array([1, 2, 3, 4]).buffer),
+ encrypt: jest.fn(async () => new Uint8Array([9, 8, 7]).buffer),
+ decrypt: jest.fn(async () => new TextEncoder().encode('decrypted-token').buffer)
+ }
+ }
+ });
+ Object.defineProperty(globalThis, 'TextEncoder', {
+ configurable: true,
+ value: TextEncoder
+ });
// Reload modules to reset module-level onboardingManager cache
jest.resetModules();
const module = await import('../popup/views/onboarding-view.js');
_handleNextStep = module.handleNextStep;
_renderReposStep = module.renderReposStep;
+ _renderOnboardingStep = module.renderOnboardingStep;
});
test('preserves validated flag when saving token and navigating next', async () => {
@@ -196,6 +238,132 @@ describe('Onboarding - token persistence', () => {
global.fetch = oldFetch;
});
+ test('renderReposStep escapes repository metadata before building HTML', async () => {
+ const manager = new OnboardingManager();
+ const saved = [
+ {
+ owner: { login: 'alice"> ' },
+ name: 'fancy',
+ description: ' ',
+ language: 'JS');
+ expect(html).not.toContain(' ');
+ });
+
+ test('renderOnboardingStep escapes saved token values and usernames on the token step', async () => {
+ await renderTokenStep({
+ data: {
+ token: {
+ token: 'ghp_test" autofocus="true',
+ validated: true,
+ username: ' '
+ }
+ }
+ });
+
+ const onboardingHtml = document.getElementById('onboardingView').innerHTML;
+ const tokenInput = document.getElementById('tokenInput');
+ const tokenStatus = document.getElementById('tokenStatus');
+
+ expect(tokenInput.value).toBe('ghp_test" autofocus="true');
+ expect(tokenInput.outerHTML).toContain('"');
+ expect(tokenStatus.textContent).toContain('Logged in as ');
+ expect(onboardingHtml).toContain('<img src=x onerror=alert(1)>');
+ expect(onboardingHtml).not.toContain(' ');
+ });
+
+ test('renderOnboardingStep shows validated status without username safely', async () => {
+ await renderTokenStep({
+ data: {
+ token: {
+ token: 'ghp_token',
+ validated: true
+ }
+ }
+ });
+
+ const onboardingHtml = document.getElementById('onboardingView').innerHTML;
+ const tokenStatus = document.getElementById('tokenStatus');
+
+ expect(tokenStatus.textContent).toContain('✓ Token is valid!');
+ expect(onboardingHtml).toContain('Validated');
+ });
+
+ test('token step shows an error when validation is attempted with no token', async () => {
+ await renderTokenStep();
+
+ document.getElementById('validateTokenBtn').click();
+
+ expect(document.getElementById('tokenStatus').textContent).toBe('Please enter a token');
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+
+ test('token step escapes invalid-token responses', async () => {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 401
+ });
+
+ await renderTokenStep();
+
+ const tokenInput = document.getElementById('tokenInput');
+ tokenInput.value = 'ghp_invalid';
+ document.getElementById('validateTokenBtn').click();
+ await Promise.resolve();
+
+ expect(document.getElementById('tokenStatus').textContent).toBe('✗ Invalid token');
+ });
+
+ test('token step escapes network validation errors', async () => {
+ global.fetch = jest.fn().mockRejectedValue(new Error('network down'));
+
+ await renderTokenStep();
+
+ const tokenInput = document.getElementById('tokenInput');
+ tokenInput.value = 'ghp_network';
+ document.getElementById('validateTokenBtn').click();
+ await Promise.resolve();
+
+ expect(document.getElementById('tokenStatus').textContent).toBe('Error validating token');
+ });
+
+ test('token step escapes successful validation messages', async () => {
+ global.fetch = jest.fn(async (url) => {
+ if (url === 'https://api.github.com/user') {
+ return {
+ ok: true,
+ json: async () => ({ login: ' ' })
+ };
+ }
+
+ return {
+ ok: true,
+ json: async () => ({ items: [] })
+ };
+ });
+
+ await renderTokenStep();
+
+ const tokenInput = document.getElementById('tokenInput');
+ tokenInput.value = 'ghp_valid';
+ document.getElementById('validateTokenBtn').click();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ const tokenStatus = document.getElementById('tokenStatus');
+ expect(tokenStatus.textContent).toContain('Logged in as ');
+ expect(tokenStatus.innerHTML).toContain('<img src=x onerror=alert(1)>');
+ });
+
test('saves categories preferences during onboarding', async () => {
// Set onboarding step to categories
_localStorage = {