Sechs Review-Punkte: Speicherort, Domain-Pruefung, Rechte, Generator, Notizen, Bearbeiten
1. Passwort mehrstufiger Logins nicht mehr auf der Platte: Der Auftrag lag
als __pendingFill in chrome.storage.local, also im Klartext auf der
Festplatte. Die 30-Sekunden-Pruefung verhinderte nur die Verwendung,
nicht die Speicherung - wurde der zweite Schritt nie erreicht, blieb
das Passwort liegen. Er liegt jetzt ausschliesslich im Speicher des
Service Workers, je Tab, und wird beim Abholen verbraucht, nach 30 s
verworfen, beim Sperren geleert und beim Schliessen des Tabs entfernt.
Reste frueherer Versionen raeumt onInstalled ab.
2. Domain-Warnung: fillDomainMatches akzeptierte mit
eh.endsWith('.' + pageHost) auch die Gegenrichtung - ein Eintrag fuer
vpn.firma.de galt auf firma.de als passend und die Warnung blieb aus.
Diese Klausel entfaellt.
3. scripting und activeTab werden nicht mehr angefordert; beide waren
unbenutzt (das Content-Script laeuft ueber content_scripts, der
Tab-Zugriff ueber host_permissions). PERMISSIONS.md begruendete
scripting mit dem nativen Value-Setter, was nichts damit zu tun hat.
4. Passwort-Generator: buf lieferte dieselben Werte fuer Zeichenwahl und
Mischreihenfolge, wodurch die Permutation mit dem Inhalt korrelierte.
Beides zieht jetzt getrennt ueber randomBelow(), das den obersten,
unvollstaendigen Block verwirft (gleichverteilt statt Rest-Modulo).
Laenge (12-48) und Sonderzeichen sind waehlbar.
5. Notizen laufen ueber copySecret und werden damit ebenfalls aus der
Zwischenablage entfernt; sie enthalten in der Praxis oft
Wiederherstellungscodes. copyToClipboard entfaellt.
6. urlmatch.js buendelt die drei abweichenden matchUrl-Fassungen zu einer
Regel, geladen in Service Worker, Popup und Seiten. escAttr escapt jetzt
auch & < > und Apostroph, traegt also in jedem Attributkontext.
Eintraege lassen sich im Popup bearbeiten und loeschen; beim Bearbeiten
bedeutet ein leeres Passwortfeld unveraendert, sodass das Passwort das
Popup nicht verlaesst.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G12DRMpe4UjYDwuRU1sjy1
This commit is contained in:
parent
26c01a4fc3
commit
07a5785580
10 changed files with 305 additions and 107 deletions
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -6,12 +6,32 @@ Die Erweiterungsversion (`manifest.json`) ist unabhängig von der OpenNIT-Server
|
|||
## [Unreleased]
|
||||
|
||||
### Hinzugefügt
|
||||
- **Einträge bearbeiten und löschen:** In der Detailansicht gibt es jetzt „Bearbeiten" und „Löschen".
|
||||
Beim Bearbeiten bleibt das Passwortfeld leer – wer es leer lässt, behält das gespeicherte Passwort;
|
||||
2FA-Secret und Ordner bleiben unangetastet. Für Team-Einträge greift das Schreibrecht des Teams.
|
||||
- **Passwort-Generator einstellbar:** Länge (12–48) und Sonderzeichen lassen sich beim Anlegen wählen –
|
||||
hilfreich bei Seiten, die Sonderzeichen ablehnen.
|
||||
- **2FA-Code wird für den nächsten Schritt bereitgelegt:** Nach dem Ausfüllen eines Eintrags mit 2FA
|
||||
landet beim nächsten 2FA-Feld automatisch ein **frischer** Code in der Zwischenablage – auch dann,
|
||||
wenn die Abfrage erst auf einer Folgeseite kommt (z. B. Microsoft-Anmeldung). Einfügen genügt mit
|
||||
Strg + V; der bisherige Inhalt der Zwischenablage wird dabei überschrieben und der Code nach 30
|
||||
Sekunden wieder entfernt. Abschaltbar unter „Sicherheit → 2FA-Code beim Anmelden bereitlegen".
|
||||
|
||||
### Sicherheit
|
||||
- **Passwort für mehrstufige Logins nicht mehr auf der Festplatte:** Verteilt eine Anmeldung Benutzername
|
||||
und Passwort auf zwei Schritte, wurde das Passwort bisher im lokalen Speicher der Erweiterung abgelegt –
|
||||
und blieb dort liegen, wenn der zweite Schritt nie erreicht wurde. Es wird jetzt nur noch im Arbeits-
|
||||
speicher gehalten, an den jeweiligen Tab gebunden und nach 30 Sekunden bzw. beim Sperren verworfen.
|
||||
- **Warnung bei fremder Domain greift zuverlässiger:** Ein Eintrag für `vpn.firma.de` galt auf `firma.de`
|
||||
fälschlich als passend, sodass die Warnung ausblieb. Zuordnung und Warnung nutzen jetzt dieselbe Regel:
|
||||
nur die hinterlegte Adresse selbst und deren Unteradressen gelten als passend.
|
||||
- **Weniger Berechtigungen:** `scripting` und `activeTab` werden nicht mehr angefordert – beide wurden
|
||||
nicht benötigt.
|
||||
- **Passwort-Generator:** Der Zufall für die Mischreihenfolge wird jetzt getrennt von der Zeichenauswahl
|
||||
gezogen, und die Zeichenwahl ist gleichverteilt.
|
||||
- Notizen werden beim Kopieren wie andere Geheimnisse behandelt und nach 30 Sekunden aus der
|
||||
Zwischenablage entfernt.
|
||||
|
||||
### Geändert
|
||||
- **Vorschläge weichen der Seite:** Sobald in ein Feld getippt wird, verschwindet die Vault-Liste –
|
||||
darunter erscheint typischerweise die Suche der Seite selbst. Bei Feldern, die erkennbar eine eigene
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ deiner selbst gehosteten OpenNIT-Instanz und bietet:
|
|||
- 👁️ **Detailansicht** mit Anzeigen/Kopieren von Zugangsdaten und 2FA-Code mit Countdown
|
||||
- ➕ **Neue Einträge anlegen** inkl. **Passwort-Generator**
|
||||
- 🔒 **PIN-Sperre** mit demselben PIN wie der Web-Tresor (Dauer frei wählbar, bis „bis Browser schließt")
|
||||
- ✏️ **Anlegen, Bearbeiten und Löschen** von Einträgen direkt im Popup
|
||||
- 🧹 **Zwischenablage-Auto-Clear** nach dem Kopieren von Geheimnissen
|
||||
- 📋 **2FA-Code bereitgelegt:** nach dem Ausfüllen landet beim nächsten 2FA-Feld – auch auf einer
|
||||
Folgeseite – automatisch ein frischer Code in der Zwischenablage (Strg + V genügt)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht
|
|||
└───────────────┘ └──────────┬───────────┘
|
||||
│ CLIP_WRITE
|
||||
┌────────▼─────────┐
|
||||
│ offscreen.html │ (Zwischenablage leeren)
|
||||
│ offscreen.html │ (Zwischenablage schreiben/leeren)
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht
|
|||
| `popup.html` / `popup.js` | Toolbar-Popup: Liste, Suche, Detailansicht, Anlegen + Generator, PIN-Schirm. |
|
||||
| `options.html` / `options.js` | Einstellungen: Server-URL, SSO-Anmeldung, PIN-Sperrdauer, Zwischenablage. |
|
||||
| `offscreen.html` / `offscreen.js` | Minimaldokument, das ausschließlich die Zwischenablage beschreibt bzw. leert (MV3-konform). |
|
||||
| `urlmatch.js` | Gemeinsame Zuordnung Eintrag ↔ Seite (`VaultUrl`), geladen in allen drei Kontexten – damit Vorschlagsliste und Sicherheitswarnung dieselbe Regel anwenden. |
|
||||
|
||||
## Nachrichten (Auszug)
|
||||
|
||||
|
|
@ -38,7 +39,8 @@ Die Erweiterung ist ein **Manifest-V3-Client** ohne eigenen Server. Sie besteht
|
|||
| `GET_LOCK` / `DO_UNLOCK` / `LOCK_NOW` | popup → bg | PIN-Sperre abfragen/entsperren/sperren |
|
||||
| `GET_ENTRIES` / `GET_MATCHING_ENTRIES` | popup/content → bg | Einträge (alle / passend zur URL) |
|
||||
| `GET_PASSWORD` / `GET_TOTP` | popup/content → bg | Secret **on demand** |
|
||||
| `CREATE_ENTRY` | popup → bg | Neuen Eintrag anlegen |
|
||||
| `CREATE_ENTRY` / `UPDATE_ENTRY` / `DELETE_ENTRY` | popup → bg | Eintrag anlegen / ändern / löschen |
|
||||
| `SET_PENDING_FILL` / `TAKE_PENDING_FILL` | content → bg | Passwort für den zweiten Login-Schritt hinterlegen bzw. abholen (nur im Speicher, je Tab) |
|
||||
| `GET_FAVICON` | popup/content → bg | Favicon als Data-URL (serverseitig gecacht) |
|
||||
| `VAULT_FILL` | popup → content | Aktives Tab-Formular ausfüllen |
|
||||
| `SCHEDULE_CLIP_CLEAR` | popup/content → bg | Zwischenablage-Leerung planen |
|
||||
|
|
@ -53,6 +55,8 @@ Alle Endpunkte unter `/api/vault/extension/` mit `Authorization: Bearer <token>`
|
|||
- `GET /entries/{id}/totp` – aktueller TOTP-Code + Restsekunden
|
||||
- `GET /entries/{id}/favicon?fetch=1` – gecachtes Favicon (bei Bedarf serverseitig geholt)
|
||||
- `POST /entries` – neuen Eintrag anlegen
|
||||
- `POST /entries/{id}` – Eintrag ändern (leeres Passwortfeld = unverändert)
|
||||
- `POST /entries/{id}/delete` – Eintrag löschen
|
||||
- `GET /status` – Token gültig? + `pin_enabled` / `pin_lock_secs`
|
||||
- `POST /unlock` – Tresor-PIN verifizieren + serverseitiges Entsperr-Fenster für den Token setzen
|
||||
- `POST /lock` – Token sofort wieder sperren (Entsperr-Fenster zurücksetzen)
|
||||
|
|
@ -76,5 +80,7 @@ Fensterdauer fest; „Bis der Browser geschlossen wird" nutzt ein langes Serverf
|
|||
|
||||
- **Kein Remote-Code** – alle Skripte im Paket (MV3-CSP-konform, keine Inline-Skripte).
|
||||
- **Secrets on demand** – Passwörter/TOTP erst bei Nutzung, nie in der Liste.
|
||||
- **Kein persistentes Secret** – nur URL, Sitzungstoken, Einstellungen in `chrome.storage`.
|
||||
- **Kein persistentes Secret** – nur URL, Sitzungstoken, Einstellungen in `chrome.storage`. Das Passwort
|
||||
für einen mehrstufigen Login liegt ausschließlich im Speicher des Service Workers (je Tab, 30 s),
|
||||
nie in `chrome.storage`, das auf die Festplatte geschrieben würde.
|
||||
- **Server-seitige Krypto** – Ver-/Entschlüsselung im OpenNIT-Server, nicht im Browser.
|
||||
|
|
|
|||
|
|
@ -16,15 +16,6 @@ Browser nicht. Es findet kein Tracking und keine Analyse statt.
|
|||
**Warum:** Das In-Seite-Dropdown mit Vorschlägen und die Felderkennung laufen als Content-Script.
|
||||
Notwendig für Autofill und die 2FA-Erkennung (inkl. Shadow-DOM und mehrstufiger Logins).
|
||||
|
||||
## `scripting`
|
||||
|
||||
**Warum:** Werte werden über den nativen Value-Setter gesetzt und Events ausgelöst, damit auch
|
||||
React/Vue/Angular-Formulare die Eingaben übernehmen.
|
||||
|
||||
## `activeTab`
|
||||
|
||||
**Warum:** Zugriff auf den aktiven Tab beim Ausfüllen aus dem Popup („Auf dieser Seite ausfüllen").
|
||||
|
||||
## `storage`
|
||||
|
||||
**Warum:** Lokale Speicherung von Server-URL, Zugriffstoken (per SSO) und Einstellungen; Entsperr-Status in
|
||||
|
|
@ -49,6 +40,10 @@ OpenNIT-Login-Seite und empfängt die Weiterleitung an `https://<extension-id>.c
|
|||
|
||||
## Bewusst NICHT angefordert
|
||||
|
||||
- **`scripting` / `activeTab`** – nicht nötig: Das Content-Script wird über `content_scripts` geladen,
|
||||
das Ausfüllen aus dem Popup läuft über eine Nachricht an das bereits laufende Script. Der Zugriff auf
|
||||
den aktiven Tab ist bereits durch `host_permissions` abgedeckt.
|
||||
|
||||
- **`tabs`** – entfällt: Die aktive Tab-Adresse ist bereits über `host_permissions` verfügbar. Dadurch
|
||||
erscheint **keine** „Browserverlauf lesen"-Warnung.
|
||||
- Keine `cookies`, `history`, `webRequest`, `downloads`, `notifications` o. Ä.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
importScripts('urlmatch.js');
|
||||
|
||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||
let cachedEntries = null;
|
||||
let cacheTime = 0;
|
||||
|
|
@ -100,13 +102,15 @@ async function setUnlockedLocal(dur) {
|
|||
}
|
||||
async function clearUnlocked() {
|
||||
await chrome.storage.session.remove('unlock');
|
||||
await chrome.storage.local.remove(['__armedTotp', '__pendingFill']);
|
||||
await chrome.storage.local.remove('__armedTotp');
|
||||
clearPendingFills();
|
||||
cachedEntries = null; cacheTime = 0; faviconCache.clear();
|
||||
try { await apiFetch('/api/vault/extension/lock', { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
}
|
||||
async function onServerLocked() {
|
||||
await chrome.storage.session.remove('unlock');
|
||||
await chrome.storage.local.remove(['__armedTotp', '__pendingFill']);
|
||||
await chrome.storage.local.remove('__armedTotp');
|
||||
clearPendingFills();
|
||||
cachedEntries = null; cacheTime = 0;
|
||||
}
|
||||
async function doUnlock(pin) {
|
||||
|
|
@ -153,8 +157,7 @@ async function fetchEntries(force = false) {
|
|||
* Legt einen persönlichen Eintrag im Tresor an.
|
||||
*
|
||||
* Läuft bewusst über `apiFetch`, damit derselbe Zugang wie für alle übrigen
|
||||
* Aufrufe gilt: SSO-Access-Token (inkl. automatischer Erneuerung) oder – falls
|
||||
* gesetzt – der manuelle Token.
|
||||
* Aufrufe gilt, inklusive automatischer Erneuerung des Access-Tokens.
|
||||
*
|
||||
* @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields
|
||||
* @return {Promise<{ok:boolean,id?:number,locked?:boolean,error?:string}>}
|
||||
|
|
@ -163,11 +166,42 @@ async function createEntry(fields) {
|
|||
if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' };
|
||||
const body = new URLSearchParams();
|
||||
['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? ''));
|
||||
return writeEntry('/api/vault/extension/entries', body.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert einen bestehenden Eintrag. Ein leeres Passwortfeld lässt das gespeicherte
|
||||
* Passwort unangetastet; 2FA-Secret und Ordner bleiben serverseitig erhalten.
|
||||
*
|
||||
* @param {number|string} entryId
|
||||
* @param {{title?:string,username?:string,password?:string,url?:string,notes?:string}} fields
|
||||
* @return {Promise<{ok:boolean,locked?:boolean,error?:string}>}
|
||||
*/
|
||||
async function updateEntry(entryId, fields) {
|
||||
if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' };
|
||||
const body = new URLSearchParams();
|
||||
['title', 'username', 'password', 'url', 'notes'].forEach(k => body.append(k, fields?.[k] ?? ''));
|
||||
return writeEntry(`/api/vault/extension/entries/${entryId}`, body.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Eintrag (persönlich oder Team, sofern Schreibrecht besteht).
|
||||
*
|
||||
* @param {number|string} entryId
|
||||
* @return {Promise<{ok:boolean,locked?:boolean,error?:string}>}
|
||||
*/
|
||||
async function deleteEntry(entryId) {
|
||||
if (!(await isUnlocked())) return { ok: false, locked: true, error: 'Tresor gesperrt.' };
|
||||
return writeEntry(`/api/vault/extension/entries/${entryId}/delete`, '');
|
||||
}
|
||||
|
||||
// Gemeinsame Auswertung der schreibenden Endpunkte.
|
||||
async function writeEntry(path, body) {
|
||||
try {
|
||||
const res = await apiFetch('/api/vault/extension/entries', {
|
||||
const res = await apiFetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
body: body,
|
||||
});
|
||||
if (!res) return { ok: false, error: 'Nicht konfiguriert.' };
|
||||
if (res.status === 423) { await onServerLocked(); return { ok: false, locked: true, error: 'Tresor gesperrt.' }; }
|
||||
|
|
@ -221,21 +255,28 @@ async function fetchFavicon(entryId) {
|
|||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function matchUrl(entryUrl, pageUrl) {
|
||||
if (!entryUrl) return false;
|
||||
let pHost;
|
||||
try { pHost = new URL(pageUrl).hostname.replace(/^www\./, ''); } catch { return false; }
|
||||
return String(entryUrl).split('\n').some(line => {
|
||||
const raw = line.trim();
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const eu = raw.includes('://') ? raw : 'https://' + raw;
|
||||
let eHost = new URL(eu).hostname.replace(/^www\./, '');
|
||||
if (eHost.startsWith('*.')) eHost = eHost.slice(2);
|
||||
return pHost === eHost || pHost.endsWith('.' + eHost);
|
||||
} catch { return false; }
|
||||
});
|
||||
|
||||
// ── Ausstehendes Ausfüllen (mehrstufiger Login) ─────────────────────────────
|
||||
// Bei Logins, die Benutzername und Passwort auf zwei Schritte verteilen, muss das
|
||||
// Passwort den Seitenwechsel überdauern. Es bleibt dafür ausschließlich im
|
||||
// Speicher des Service Workers – niemals in `chrome.storage`, das auf die
|
||||
// Festplatte geschrieben wird. Je Tab ein Auftrag, mit harter Verfallszeit.
|
||||
const PENDING_FILL_TTL = 30 * 1000;
|
||||
const pendingFills = new Map(); // tabId -> { id, pw, user, ts }
|
||||
|
||||
function setPendingFill(tabId, data) {
|
||||
if (tabId == null) return;
|
||||
pendingFills.set(tabId, Object.assign({ ts: Date.now() }, data));
|
||||
}
|
||||
function takePendingFill(tabId) {
|
||||
if (tabId == null) return null;
|
||||
const p = pendingFills.get(tabId);
|
||||
if (!p) return null;
|
||||
pendingFills.delete(tabId);
|
||||
return (Date.now() - p.ts > PENDING_FILL_TTL) ? null : p;
|
||||
}
|
||||
function clearPendingFills() { pendingFills.clear(); }
|
||||
chrome.tabs.onRemoved.addListener(tabId => pendingFills.delete(tabId));
|
||||
|
||||
// ── Zwischenablage automatisch leeren (Offscreen) ───────────────────────────
|
||||
async function scheduleClipClear(text) {
|
||||
|
|
@ -283,12 +324,14 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||
}
|
||||
if (msg.type === 'GET_MATCHING_ENTRIES') {
|
||||
fetchEntries().then(r => {
|
||||
const matched = (r.entries || []).filter(e => matchUrl(e.url, msg.url));
|
||||
const matched = (r.entries || []).filter(e => VaultUrl.matches(e.url, msg.url));
|
||||
sendResponse({ entries: matched, locked: r.locked });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'CREATE_ENTRY') { createEntry(msg.entry || {}).then(sendResponse); return true; }
|
||||
if (msg.type === 'UPDATE_ENTRY') { updateEntry(msg.id, msg.entry || {}).then(sendResponse); return true; }
|
||||
if (msg.type === 'DELETE_ENTRY') { deleteEntry(msg.id).then(sendResponse); return true; }
|
||||
if (msg.type === 'GET_PASSWORD') { fetchPassword(msg.id).then(password => sendResponse({ password })); return true; }
|
||||
if (msg.type === 'GET_TOTP') { fetchTotp(msg.id).then(result => sendResponse(result)); return true; }
|
||||
if (msg.type === 'GET_FAVICON') { fetchFavicon(msg.id).then(dataUrl => sendResponse({ dataUrl })); return true; }
|
||||
|
|
@ -298,6 +341,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||
}
|
||||
if (msg.type === 'DO_UNLOCK') { doUnlock(msg.pin || '').then(sendResponse); return true; }
|
||||
if (msg.type === 'LOCK_NOW') { clearUnlocked().then(() => sendResponse({ ok: true })); return true; }
|
||||
if (msg.type === 'SET_PENDING_FILL') {
|
||||
setPendingFill(sender.tab?.id, { id: msg.id, pw: msg.pw || '', user: msg.user || '' });
|
||||
sendResponse({ ok: true });
|
||||
return true;
|
||||
}
|
||||
if (msg.type === 'TAKE_PENDING_FILL') { sendResponse({ fill: takePendingFill(sender.tab?.id) }); return true; }
|
||||
if (msg.type === 'CLIP_WRITE') { writeClipboard(msg.text || '').then(() => sendResponse({ ok: true })); return true; }
|
||||
if (msg.type === 'SCHEDULE_CLIP_CLEAR') { scheduleClipClear(msg.text || ''); sendResponse({ ok: true }); return true; }
|
||||
if (msg.type === 'CHECK_STATUS') { checkStatus().then(sendResponse); return true; }
|
||||
|
|
@ -306,8 +355,10 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||
|
||||
// Der manuell eingetragene API-Token wird nicht mehr unterstützt; ein aus einer
|
||||
// früheren Version übernommener Wert wird beim Update aus dem Speicher entfernt.
|
||||
// Der manuelle API-Token entfaellt, und ein ausstehender Fuellauftrag liegt nicht
|
||||
// mehr im Speicher auf der Platte – Reste frueherer Versionen hier entfernen.
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.storage.local.remove('apiToken');
|
||||
chrome.storage.local.remove(['apiToken', '__pendingFill']);
|
||||
});
|
||||
|
||||
// Cache alle 5 Minuten leeren; Zwischenablage-Clear nach Timeout.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ let currentField = null;
|
|||
let showGen = 0;
|
||||
let typedField = null; // Feld, in das der Nutzer zuletzt selbst getippt hat
|
||||
let fillingInProgress = false; // unterdrückt die Tipp-Erkennung beim Autofill
|
||||
let pendingFillInFlight = false; // der Auftrag wird beim Abholen verbraucht – nur einmal gleichzeitig
|
||||
|
||||
// ── Heuristik-Muster ────────────────────────────────────────────────────────
|
||||
const RE_USER = /(user(name|id)?|login|logon|sign[-_ ]?in|account|konto|benutzer|kennung|anmeld|e[-_ ]?mail|email|mail|uid|userid|handle|identifier|ident\b|loginid)/i;
|
||||
|
|
@ -206,26 +207,6 @@ function findOtpFields(ref) {
|
|||
return collectInputs(scopeOf(ref)).filter(el => isVisible(el) && isOtpField(el));
|
||||
}
|
||||
|
||||
// ── URL-Matching ────────────────────────────────────────────────────────────
|
||||
function normalizeHost(raw) {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const s = raw.includes('://') ? raw : 'https://' + raw;
|
||||
return new URL(s).hostname.replace(/^www\./, '').toLowerCase();
|
||||
} catch { return lc(raw).replace(/^www\./, ''); }
|
||||
}
|
||||
function matchUrl(entryUrls, pageUrl) {
|
||||
const pageHost = normalizeHost(pageUrl);
|
||||
if (!pageHost) return false;
|
||||
const urls = typeof entryUrls === 'string' ? entryUrls.split('\n') : [entryUrls];
|
||||
return urls.some(u => {
|
||||
let eh = normalizeHost((u || '').trim());
|
||||
if (!eh) return false;
|
||||
if (eh.startsWith('*.')) eh = eh.slice(2);
|
||||
return pageHost === eh || pageHost.endsWith('.' + eh);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Events ──────────────────────────────────────────────────────────────────
|
||||
function init() {
|
||||
document.addEventListener('focusin', onFocusIn, true);
|
||||
|
|
@ -256,7 +237,7 @@ function fillFromPopup(msg) {
|
|||
|
||||
if (userField && msg.username) setFieldValue(userField, msg.username);
|
||||
if (passField && msg.password) setFieldValue(passField, msg.password);
|
||||
else if (msg.password) chrome.storage.local.set({ __pendingFill: { id: msg.id, pw: msg.password, user: msg.username || '', ts: Date.now() } });
|
||||
else if (msg.password) chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: msg.id, pw: msg.password, user: msg.username || '' });
|
||||
|
||||
if (msg.has_totp && msg.id != null) {
|
||||
armTotp(msg.id);
|
||||
|
|
@ -336,7 +317,7 @@ function showSuggestions(field) {
|
|||
const mode = fieldKind(field) === 'otp' ? 'otp' : 'login';
|
||||
chrome.runtime.sendMessage({ type: 'GET_MATCHING_ENTRIES', url: location.href }, resp => {
|
||||
if (gen !== showGen) return;
|
||||
let entries = (resp && resp.entries || []).filter(e => matchUrl(e.url, location.href));
|
||||
let entries = (resp && resp.entries || []).filter(e => VaultUrl.matches(e.url, location.href));
|
||||
if (mode === 'otp') entries = entries.filter(e => e.has_totp);
|
||||
if (!entries.length) { hideDrop(); return; }
|
||||
if (document.contains(field) && isVisible(field)) renderDrop(field, entries, mode);
|
||||
|
|
@ -489,7 +470,7 @@ async function fillEntry(entry, focused) {
|
|||
|
||||
if (userField && entry.username) setFieldValue(userField, entry.username);
|
||||
if (passField) setFieldValue(passField, pw);
|
||||
else chrome.storage.local.set({ __pendingFill: { id: entry.id, pw: pw, user: entry.username || '', ts: Date.now() } });
|
||||
else chrome.runtime.sendMessage({ type: 'SET_PENDING_FILL', id: entry.id, pw: pw, user: entry.username || '' });
|
||||
|
||||
if (entry.has_totp) {
|
||||
// Der 2FA-Schritt folgt oft erst nach dem Absenden – ggf. auf der nächsten
|
||||
|
|
@ -603,16 +584,17 @@ function scanForPendingWork() {
|
|||
if (otps.length) serveArmedTotp(otps, true);
|
||||
|
||||
const pw = inputs.filter(isPasswordField);
|
||||
if (!pw.length) return;
|
||||
chrome.storage.local.get(['__pendingFill'], result => {
|
||||
const p = result.__pendingFill;
|
||||
if (!p || Date.now() - p.ts > 30000) return;
|
||||
if (!pw.length || pendingFillInFlight) return;
|
||||
pendingFillInFlight = true;
|
||||
chrome.runtime.sendMessage({ type: 'TAKE_PENDING_FILL' }, resp => {
|
||||
pendingFillInFlight = false;
|
||||
const p = resp && resp.fill;
|
||||
if (!p) return;
|
||||
pw.forEach(f => setFieldValue(f, p.pw));
|
||||
if (p.user) {
|
||||
const uf = findUsernameField(pw[0]);
|
||||
if (uf && !uf.value) setFieldValue(uf, p.user);
|
||||
}
|
||||
chrome.storage.local.remove('__pendingFill');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@
|
|||
"description": "OpenNIT Vault – Passwort-Manager mit Autofill für Benutzer-, Passwort- und 2FA-Felder direkt im Browser.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"alarms",
|
||||
"offscreen",
|
||||
"identity"
|
||||
|
|
@ -31,6 +29,7 @@
|
|||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"urlmatch.js",
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
|
|
|
|||
|
|
@ -147,6 +147,14 @@ body {
|
|||
.ne-pass-row .panel-input { flex: 1; }
|
||||
.ne-icon-btn { flex-shrink: 0; width: 36px; height: 36px; border: 1.5px solid var(--line); background: var(--card); border-radius: 9px; cursor: pointer; color: #5b6478; display: inline-flex; align-items: center; justify-content: center; transition: background .12s, color .12s, border-color .12s; }
|
||||
.ne-icon-btn:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.gen-opts { display: flex; align-items: center; gap: 14px; margin: -2px 0 2px; font-size: 11px; color: var(--muted); }
|
||||
.gen-opt { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
|
||||
.gen-select { border: 1.5px solid var(--line); border-radius: 7px; padding: 2px 4px; font-size: 11px; font-family: inherit; background: var(--card); color: var(--ink); cursor: pointer; }
|
||||
.detail-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.detail-action { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--card); border: 1.5px solid var(--line); border-radius: 9px; padding: 8px 10px; font-size: 11.5px; font-weight: 600; font-family: inherit; color: #5b6478; cursor: pointer; transition: background .12s, color .12s, border-color .12s; }
|
||||
.detail-action:hover { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.detail-action.danger:hover { background: #dc3545; border-color: #dc3545; }
|
||||
.detail-action:disabled { opacity: .55; cursor: default; }
|
||||
.field-notes { font-size: 12px; color: var(--ink); white-space: pre-wrap; word-break: break-word; max-height: 90px; overflow-y: auto; margin-top: 2px; }
|
||||
|
||||
/* ── Lock Screen ────────────────────────────────────────── */
|
||||
|
|
@ -204,7 +212,7 @@ body {
|
|||
<!-- New Entry Panel (initially hidden) -->
|
||||
<div id="newEntryPanel">
|
||||
<div class="panel-hd">
|
||||
<span class="panel-hd-title">Neuen Eintrag anlegen</span>
|
||||
<span class="panel-hd-title" id="panelTitle">Neuen Eintrag anlegen</span>
|
||||
<button class="panel-close" id="btnCloseNew" title="Schließen">✕</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
|
|
@ -219,6 +227,19 @@ body {
|
|||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="gen-opts">
|
||||
<label class="gen-opt" for="genLen">Länge
|
||||
<select class="gen-select" id="genLen">
|
||||
<option>12</option>
|
||||
<option>16</option>
|
||||
<option selected>20</option>
|
||||
<option>24</option>
|
||||
<option>32</option>
|
||||
<option>48</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="gen-opt"><input type="checkbox" id="genSymbols" checked> Sonderzeichen</label>
|
||||
</div>
|
||||
<input class="panel-input" id="neUrl" type="url" placeholder="URL (https://…)" autocomplete="off">
|
||||
<textarea class="panel-input" id="neNotes" placeholder="Notizen" rows="2" style="resize:vertical;font-family:inherit;"></textarea>
|
||||
<button class="panel-save" id="btnSaveNew">Speichern</button>
|
||||
|
|
@ -298,6 +319,16 @@ body {
|
|||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
||||
Auf dieser Seite ausfüllen
|
||||
</button>
|
||||
<div class="detail-actions">
|
||||
<button class="detail-action" id="btnDetailEdit">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button class="detail-action danger" id="btnDetailDelete">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -342,6 +373,7 @@ body {
|
|||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="urlmatch.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,6 +5,7 @@ let pageMatches = [];
|
|||
let entryIndex = {}; // id -> entry
|
||||
let detailState = null; // aktiver Eintrag im Detail-Panel
|
||||
let selIndex = -1; // Tastatur-Auswahl in der Liste
|
||||
let editingId = null; // gesetzt, solange das Panel einen bestehenden Eintrag bearbeitet
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
|
|
@ -33,8 +34,10 @@ async function init() {
|
|||
$('btnRevealPass').addEventListener('click', toggleRevealPass);
|
||||
$('btnCopyPass').addEventListener('click', copyDetailPassword);
|
||||
$('btnCopyTotp').addEventListener('click', () => { const c = $('detailTotp').dataset.code || ''; if (c) copySecret(c, 'TOTP kopiert'); });
|
||||
$('btnCopyNotes').addEventListener('click', () => copyToClipboard($('detailNotes').dataset.value || '', 'Notiz kopiert'));
|
||||
$('btnCopyNotes').addEventListener('click', () => copySecret($('detailNotes').dataset.value || '', 'Notiz kopiert'));
|
||||
$('btnDetailFill').addEventListener('click', fillActiveTab);
|
||||
$('btnDetailEdit').addEventListener('click', openEditPanel);
|
||||
$('btnDetailDelete').addEventListener('click', deleteCurrentEntry);
|
||||
|
||||
// Lock-Screen
|
||||
$('lockSubmit').addEventListener('click', submitPin);
|
||||
|
|
@ -211,6 +214,9 @@ function setSel(items, idx) {
|
|||
|
||||
// ── New Entry ─────────────────────────────────────────────────────────────
|
||||
function openNewPanel() {
|
||||
editingId = null;
|
||||
$('panelTitle').textContent = 'Neuen Eintrag anlegen';
|
||||
$('nePassword').placeholder = 'Passwort';
|
||||
$('listWrap').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
$('detailPanel').style.display = 'none';
|
||||
|
|
@ -232,35 +238,92 @@ function openNewPanel() {
|
|||
}
|
||||
|
||||
function closeNewPanel() {
|
||||
editingId = null;
|
||||
$('newEntryPanel').style.display = 'none';
|
||||
$('listWrap').style.display = '';
|
||||
$('search').closest('.search-wrap').style.display = '';
|
||||
}
|
||||
|
||||
// Gleichverteilte Zufallszahl aus [0, max) – verwirft die Werte des obersten,
|
||||
// unvollständigen Blocks, damit kein Rest-Modulo einzelne Zeichen bevorzugt.
|
||||
function randomBelow(max) {
|
||||
const limit = Math.floor(0x100000000 / max) * max;
|
||||
const buf = new Uint32Array(1);
|
||||
do { crypto.getRandomValues(buf); } while (buf[0] >= limit);
|
||||
return buf[0] % max;
|
||||
}
|
||||
|
||||
/** Zeichensätze ohne optisch verwechselbare Zeichen (l/I/1, O/0). */
|
||||
const GEN_SETS = [
|
||||
'abcdefghijkmnopqrstuvwxyz',
|
||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
'23456789',
|
||||
];
|
||||
const GEN_SYMBOLS = '!@#$%^&*()-_=+[]{}';
|
||||
|
||||
function generatePassword() {
|
||||
const len = 20;
|
||||
const sets = [
|
||||
'abcdefghijkmnopqrstuvwxyz',
|
||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
'23456789',
|
||||
'!@#$%^&*()-_=+[]{}',
|
||||
];
|
||||
const all = sets.join('');
|
||||
const buf = new Uint32Array(len);
|
||||
crypto.getRandomValues(buf);
|
||||
let out = [];
|
||||
// Mindestens ein Zeichen je Set
|
||||
sets.forEach((s, i) => { out.push(s[buf[i] % s.length]); });
|
||||
for (let i = sets.length; i < len; i++) out.push(all[buf[i] % all.length]);
|
||||
// mischen
|
||||
const len = Math.max(8, parseInt($('genLen').value, 10) || 20);
|
||||
const sets = $('genSymbols').checked ? GEN_SETS.concat(GEN_SYMBOLS) : GEN_SETS.slice();
|
||||
const all = sets.join('');
|
||||
|
||||
// Je Satz ein Zeichen garantieren, den Rest frei ziehen …
|
||||
const out = sets.map(s => s[randomBelow(s.length)]);
|
||||
while (out.length < len) out.push(all[randomBelow(all.length)]);
|
||||
|
||||
// … und danach mischen, damit die garantierten Zeichen nicht vorne stehen.
|
||||
// Der Zufall dafür wird frisch gezogen und nicht aus der Zeichenwahl wiederverwendet.
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = buf[i] % (i + 1);
|
||||
const j = randomBelow(i + 1);
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
|
||||
$('nePassword').value = out.join('');
|
||||
$('nePassword').type = 'text';
|
||||
}
|
||||
|
||||
// Bestehenden Eintrag im selben Panel bearbeiten. Das Passwortfeld bleibt leer –
|
||||
// leer bedeutet serverseitig „unverändert", sodass das Passwort das Popup nie verlässt.
|
||||
function openEditPanel() {
|
||||
if (!detailState) return;
|
||||
const e = entryIndex[detailState.id];
|
||||
if (!e) return;
|
||||
|
||||
editingId = detailState.id;
|
||||
closeDetail();
|
||||
|
||||
$('panelTitle').textContent = 'Eintrag bearbeiten';
|
||||
$('neTitle').value = e.title || '';
|
||||
$('neUsername').value = e.username || '';
|
||||
$('nePassword').value = '';
|
||||
$('nePassword').type = 'password';
|
||||
$('nePassword').placeholder = 'Passwort (leer = unverändert)';
|
||||
$('neUrl').value = e.url || '';
|
||||
$('neNotes').value = e.notes || '';
|
||||
$('newEntryMsg').textContent = '';
|
||||
|
||||
$('listWrap').style.display = 'none';
|
||||
$('search').closest('.search-wrap').style.display = 'none';
|
||||
$('detailPanel').style.display = 'none';
|
||||
$('newEntryPanel').style.display = 'block';
|
||||
$('neTitle').focus();
|
||||
}
|
||||
|
||||
function deleteCurrentEntry() {
|
||||
if (!detailState) return;
|
||||
const e = entryIndex[detailState.id];
|
||||
if (!e) return;
|
||||
if (!window.confirm('Eintrag „' + (e.title || '') + '" wirklich löschen?')) return;
|
||||
|
||||
const btn = $('btnDetailDelete');
|
||||
btn.disabled = true;
|
||||
chrome.runtime.sendMessage({ type: 'DELETE_ENTRY', id: detailState.id }, resp => {
|
||||
btn.disabled = false;
|
||||
if (resp?.ok) { closeDetail(); reload(true); showToast('Eintrag gelöscht'); return; }
|
||||
if (resp?.locked) { showLockScreen(); return; }
|
||||
showToast(resp?.error || 'Löschen fehlgeschlagen');
|
||||
});
|
||||
}
|
||||
|
||||
// Speichern läuft – wie alle anderen Aufrufe – über den Background-Service-Worker,
|
||||
// der den gültigen Zugang beisteuert.
|
||||
function saveNewEntry() {
|
||||
|
|
@ -278,14 +341,18 @@ function saveNewEntry() {
|
|||
url: $('neUrl').value.trim(),
|
||||
notes: $('neNotes').value.trim(),
|
||||
};
|
||||
const msg = editingId
|
||||
? { type: 'UPDATE_ENTRY', id: editingId, entry }
|
||||
: { type: 'CREATE_ENTRY', entry };
|
||||
const wasEditing = !!editingId;
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'CREATE_ENTRY', entry }, resp => {
|
||||
chrome.runtime.sendMessage(msg, resp => {
|
||||
$('btnSaveNew').disabled = false;
|
||||
$('btnSaveNew').textContent = 'Speichern';
|
||||
if (resp?.ok) {
|
||||
closeNewPanel();
|
||||
reload(true);
|
||||
showToast('Eintrag gespeichert');
|
||||
showToast(wasEditing ? 'Eintrag aktualisiert' : 'Eintrag gespeichert');
|
||||
return;
|
||||
}
|
||||
if (resp?.locked) { showLockScreen(); return; }
|
||||
|
|
@ -579,30 +646,12 @@ async function fillActiveTab() {
|
|||
}
|
||||
|
||||
// ── Helfer ────────────────────────────────────────────────────────────────
|
||||
function hostOf(u) {
|
||||
try {
|
||||
const s = String(u || '');
|
||||
return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase();
|
||||
} catch { return ''; }
|
||||
}
|
||||
function hostOf(u) { return VaultUrl.host(u); }
|
||||
// True, wenn eine der (mehrzeiligen) Eintrags-URLs zur Seiten-Domain passt –
|
||||
// oder wenn im Eintrag gar keine URL hinterlegt ist (dann keine Warnung).
|
||||
function fillDomainMatches(entryUrls, pageUrl) {
|
||||
const pageHost = hostOf(pageUrl);
|
||||
const list = String(entryUrls || '').split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (!list.length) return true;
|
||||
if (!pageHost) return false;
|
||||
return list.some(u => {
|
||||
let eh = hostOf(u);
|
||||
if (eh.startsWith('*.')) eh = eh.slice(2);
|
||||
return eh && (pageHost === eh || pageHost.endsWith('.' + eh) || eh.endsWith('.' + pageHost));
|
||||
});
|
||||
}
|
||||
function copyToClipboard(text, msg) {
|
||||
navigator.clipboard.writeText(text).then(() => showToast(msg)).catch(() => showToast('Fehler'));
|
||||
}
|
||||
// Wie copyToClipboard, plant aber zusätzlich das automatische Leeren der
|
||||
// Zwischenablage (für Zugangsdaten/2FA).
|
||||
function fillDomainMatches(entryUrls, pageUrl) { return VaultUrl.matchesOrUnset(entryUrls, pageUrl); }
|
||||
// Alles, was aus einem Eintrag kommt, gilt als Geheimnis – Notizen enthalten in
|
||||
// der Praxis ebenso oft Wiederherstellungscodes wie das Passwortfeld selbst.
|
||||
function copySecret(text, msg) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(msg);
|
||||
|
|
@ -616,6 +665,6 @@ function showToast(msg) {
|
|||
setTimeout(() => t.classList.remove('show'), 1800);
|
||||
}
|
||||
function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function escAttr(s) { return String(s||'').replace(/"/g,'"'); }
|
||||
function escAttr(s) { return esc(s).replace(/"/g, '"').replace(/'/g, '''); }
|
||||
|
||||
init();
|
||||
63
extension/urlmatch.js
Normal file
63
extension/urlmatch.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Gemeinsame Zuordnung Eintrag ↔ Seite.
|
||||
*
|
||||
* Wird in allen drei Kontexten geladen (Service Worker via importScripts,
|
||||
* Popup via <script>, Seiten via content_scripts) – damit Vorschlagsliste und
|
||||
* Sicherheitswarnung dieselbe Regel anwenden.
|
||||
*
|
||||
* Regel: Ein Eintrag passt zu einer Seite, wenn deren Host dem hinterlegten Host
|
||||
* entspricht oder eine Subdomain davon ist. Die Gegenrichtung gilt bewusst nicht –
|
||||
* ein Eintrag für `vpn.firma.de` passt nicht zu `firma.de`.
|
||||
*/
|
||||
var VaultUrl = (function () {
|
||||
/**
|
||||
* Host einer Adresse in vergleichbarer Form (klein, ohne `www.`).
|
||||
* @param {string} raw Adresse mit oder ohne Schema
|
||||
* @return {string} Host oder '' wenn nicht bestimmbar
|
||||
*/
|
||||
function host(raw) {
|
||||
const s = String(raw || '').trim();
|
||||
if (!s) return '';
|
||||
try {
|
||||
return new URL(s.includes('://') ? s : 'https://' + s).hostname.replace(/^www\./, '').toLowerCase();
|
||||
} catch {
|
||||
return s.replace(/^www\./, '').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
/** Hinterlegte Adressen eines Eintrags (mehrzeilig) als Liste. */
|
||||
function list(entryUrls) {
|
||||
if (Array.isArray(entryUrls)) return entryUrls.map(u => String(u || '').trim()).filter(Boolean);
|
||||
return String(entryUrls || '').split('\n').map(u => u.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Passt einer der hinterlegten Hosts zur Seite?
|
||||
* @param {string|string[]} entryUrls Adressen des Eintrags
|
||||
* @param {string} pageUrl Adresse der Seite
|
||||
* @return {boolean}
|
||||
*/
|
||||
function matches(entryUrls, pageUrl) {
|
||||
const pageHost = host(pageUrl);
|
||||
if (!pageHost) return false;
|
||||
return list(entryUrls).some(raw => {
|
||||
let eh = host(raw);
|
||||
if (eh.startsWith('*.')) eh = eh.slice(2);
|
||||
if (!eh) return false;
|
||||
return pageHost === eh || pageHost.endsWith('.' + eh);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wie `matches`, wertet einen Eintrag ohne hinterlegte Adresse aber als passend. */
|
||||
function matchesOrUnset(entryUrls, pageUrl) {
|
||||
if (!list(entryUrls).length) return true;
|
||||
return matches(entryUrls, pageUrl);
|
||||
}
|
||||
|
||||
return { host, list, matches, matchesOrUnset };
|
||||
})();
|
||||
|
||||
// Im Service Worker steht `self` zur Verfügung, im Popup/Content-Script `window`.
|
||||
if (typeof self !== 'undefined') self.VaultUrl = VaultUrl;
|
||||
Loading…
Add table
Add a link
Reference in a new issue