From 17ad302a6c5ef14f5e4635efb9395bc7dfd4dad5 Mon Sep 17 00:00:00 2001 From: Ruben Carlo Benante Date: Mon, 10 Aug 2026 15:23:17 -0300 Subject: [PATCH] version 0.2 ready for upload --- .gitignore | 12 ++- Makefile | 24 ++++-- README.md | 193 +++++++++++++++++++++++++-------------------- VERSION | 2 +- esbuild.config.mjs | 40 +++++++++- logsdu-0.2.0.xpi | Bin 0 -> 14606 bytes package.json | 2 +- src/config.test.ts | 40 ++++++++++ src/config.ts | 23 +++++- src/content.ts | 23 +++--- src/injected.ts | 25 +++--- src/manifest.json | 6 +- src/options.html | 45 ++++++++++- src/options.ts | 96 ++++++++++++++++++---- src/popup.ts | 14 +++- 15 files changed, 402 insertions(+), 143 deletions(-) create mode 100644 logsdu-0.2.0.xpi diff --git a/.gitignore b/.gitignore index 28983f0..294efab 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,17 @@ # dependencies node_modules -# Build output. The unpacked extension and the signing package are release -# artifacts, not sources. +# Unpacked build output. Regenerated by "make", never edited by hand. build + +# Intermediate packages. logsdu-*.zip -*.xpi + +# Packaged .xpi files are NOT ignored. They are the published artifact: the +# signed add-on is what people download and install, so it belongs in the +# repository (or attached to a release) rather than being rebuilt by everyone +# who wants to install it. An unsigned local build can be removed with +# "make clean". # Exclude sourcemaps *.map diff --git a/Makefile b/Makefile index d99ffa7..caf1f77 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ EXT_ID := logsdu VERSION := $(shell node -p "require('./package.json').version") XPI := $(EXT_ID)-$(VERSION).xpi -.PHONY: all build test xpi clean check-deps +.PHONY: all build chrome test xpi clean check-deps all: build @@ -61,6 +61,13 @@ build: check-deps node_modules/.bin/tsc -noEmit -skipLibCheck node esbuild.config.mjs production +# The same sources with Chrome's background key. Untested against Chrome; it +# exists so the port is a build flag rather than a fork. +chrome: check-deps + node_modules/.bin/tsc -noEmit -skipLibCheck + TARGET=chrome node esbuild.config.mjs production + @echo "Chrome build in build/ -- load it via chrome://extensions (Developer mode)." + # An .xpi is just a zip of the extension directory, with the manifest at the # top level rather than inside a wrapper folder. The same file installs # directly on ESR and uploads to AMO for signing. @@ -70,13 +77,16 @@ xpi: build @echo @echo "Built: $(CURDIR)/$(XPI)" @echo - @echo "To install, in Firefox:" - @echo " 1. about:config -> set xpinstall.signatures.required = false" - @echo " (only ESR, Developer Edition and Nightly honour this)" - @echo " 2. about:addons -> gear icon -> Install Add-on From File" - @echo " 3. paste this path into the file picker:" + @echo "This file is UNSIGNED. Two ways to use it:" @echo - @echo " $(CURDIR)/$(XPI)" + @echo " Publish -- upload it at addons.mozilla.org/developers/addon/submit/" + @echo " Mozilla signs it; the signed file installs on any Firefox." + @echo + @echo " Install locally -- only on ESR, Developer Edition or Nightly:" + @echo " 1. about:config -> xpinstall.signatures.required = false" + @echo " 2. about:addons -> gear icon -> Install Add-on From File" + @echo " 3. paste this path into the file picker:" + @echo " $(CURDIR)/$(XPI)" @echo clean: diff --git a/README.md b/README.md index e072079..3f8c549 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,81 @@ # logsdu -A Firefox extension that logs you into an academic portal whose sign-in form is -three fields -- registration number, birth date and national ID -- rather than -the usual user and password. +A Firefox extension for logins that password managers cannot save: the ones +made of a registration number, a date of birth and a document number, instead +of a username and a password. -That shape defeats password managers. The form is marked `autocomplete="off"`, -none of the three inputs is a `password` field, and Bitwarden, Firefox and -Chrome all decline to remember it. Since the values never change and the -institution does not let you pick a different login method, the only option -left is copying three values by hand, every time. +Academic portals do this a lot. The form is usually marked `autocomplete="off"`, +none of the fields is a `password` field, and Bitwarden, Firefox and Chrome all +decline to remember it. When the values never change and the institution offers +no other way in, the only option left is copying three values by hand, every +single time -- including the registration number nobody has memorised. -logsdu stores them once and fills them in. By default it also presses "Entrar", -so the normal case is zero clicks. +logsdu stores them once and fills them in. By default it also presses the submit +button, so the normal case is zero clicks. ## Install -Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below). -Node 22+ ships `corepack`, so pnpm does not have to be installed globally. +Once it is published, from addons.mozilla.org. Until then, build it yourself: ``` corepack pnpm install # first time, or after a dependency change make xpi # typecheck, bundle, and package logsdu-.xpi ``` -Then install it permanently. Firefox will not load an unsigned add-on unless -you tell it to, and only the ESR, Developer Edition and Nightly builds accept -being told: +`make xpi` prints the full path of the file and how to install it. The package +it produces is unsigned, and Firefox only accepts unsigned add-ons on the ESR, +Developer Edition and Nightly builds, after setting +`xpinstall.signatures.required` to `false` in `about:config`. On release Firefox +the file has to be signed by Mozilla first -- see "Publishing". -1. Open `about:config`, accept the warning -2. Set `xpinstall.signatures.required` to **false** -3. Open `about:addons`, click the **gear** icon, choose **Install Add-on From - File**, and pick `logsdu-.xpi` +Dependencies are managed with **pnpm**, never npm. See "Why pnpm" below. -It survives restarts. On **release** Firefox that pref is ignored -- see -"Release Firefox" below. +## Setting it up -Finally, open the extension's options page, fill in the four values, and save. - -## The four values +Open the extension's options page and fill in four values: | Field | Example | Notes | | --- | --- | --- | | Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored | | Registration number | `2000101010` | Digits only | -| Birth date | `01/01/2000` | Reformatted as you type | -| National ID | `000.000.000-00` | Reformatted as you type | +| Date of birth | `01/01/2000` | Reformatted as you type | +| Document number | `000.000.000-00` | Reformatted as you type | Paste raw digits if you like -- the options page inserts the separators, because -the portal's input masks expect the values in exactly that shape. +input masks on these forms expect the values in exactly that shape. -**The portal address is configuration, not code.** No institution is named -anywhere in the extension: not in the manifest, not in the source, not in the -build output. Be clear about what that does and does not buy you. It stops -someone who reads the extension from learning which portal it is for. It does -not hide anything from someone who can read the extension's storage -- and that -is the same access that would expose your national ID and birth date anyway. +When you press Save, Firefox asks whether logsdu may access the address you +entered. That prompt names one site. Accept it and the extension starts working +there; decline and nothing is stored as usable. + +If the portal's markup differs from the common shape, open **Ajustes avançados** +in the options page and adjust the CSS selectors. Invalid selectors are rejected +on save rather than failing silently later. + +## What it can access + +Nothing, until you say so. + +The manifest requests **no host permissions at all**. There is no content script +declared against any site. When you save an address, the extension asks for that +single origin through `permissions.request()`, and a background script then +registers the content script for that one origin and no other. + +That means a fresh install can read no pages, the permission prompt names one +site, and you can revoke it whenever you like in `about:addons` -> Permissions. +Clearing your data in the options page hands the permission back automatically. + +The `optional_host_permissions` entry in the manifest is `*://*/*`, because the +address is not known until you type it. It is the set the extension may *ask* +from, not what it holds -- nothing is granted without your click, and what is +granted is one origin. ## How it behaves - **Fills and submits** on the login page, with no interaction. -- **At most one automatic submit per hour.** After an attempt, the extension - drops back to filling only, so a wrong value cannot resubmit itself on every - page load and lock you out. Correct the values and save; saving clears the +- **At most one automatic submit per hour.** After an attempt it drops back to + filling only, so a wrong value cannot resubmit itself on every page load and + lock you out of your account. Correct the values and save; saving clears the timer, so the next visit tries again immediately. - **Logging out keeps you logged out.** Clicking the portal's logout control suppresses the automatic submit for five minutes -- otherwise the logout @@ -72,32 +86,44 @@ is the same access that would expose your national ID and birth date anyway. submitting, for when you want to check the values before sending them. - Automatic submission can be turned off entirely in the options. -## How the values are stored +## Where your data goes -In `storage.local`: private to this browser profile, never synced, never sent -anywhere. That is the same protection a browser-saved password gets, and it has -the same limit -- anyone with your unlocked account can read it. If that is not -good enough for your threat model, this extension is the wrong tool. +Nowhere. It is written to `storage.local`: private to your browser profile, +never synced, never transmitted. The extension makes no network requests of its +own and contains no analytics. -## Release Firefox +Be clear about the limit, though. This is the same protection a browser-saved +password gets, and it has the same weakness -- anyone with your unlocked +computer can read it. If you need protection at rest, this is the wrong tool. -`xpinstall.signatures.required` only works on ESR, Developer Edition and -Nightly. Release Firefox ignores it and refuses unsigned add-ons outright, so -there the same `.xpi` has to be signed first: upload it to -[addons.mozilla.org](https://addons.mozilla.org) as an **unlisted** add-on. -Signing is automated -- nothing is published publicly or reviewed by hand -- and -you install the signed file it hands back. +The interface is in Portuguese, matching the portals it was written for. -The `browser_specific_settings.gecko.id` in the manifest is what gives the -add-on a stable identity across both routes, so settings survive an upgrade -from one to the other. +## Publishing + +`make xpi` produces the file to upload at +[addons.mozilla.org](https://addons.mozilla.org/developers/addon/submit/). +Two distribution choices: + +- **Listed** -- public on addons.mozilla.org, searchable, installable by anyone, + and updates are delivered by Mozilla automatically. +- **Unlisted** -- signed but not published. You distribute the signed file + yourself. Updates need a self-hosted update manifest, or resending the file. + +Builds are never minified, which is deliberate: AMO requires a separate +source-code submission for any add-on whose uploaded code is machine-generated, +and that obligation would apply to every future release. The whole extension is +about 33 KB, so the saving would not pay for the process, and readable code is +easier for a reviewer -- or anyone auditing what handles their credentials -- to +check. ## Chrome -Not yet. The source deliberately avoids anything Firefox-specific: it uses the -`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build -should be a manifest question rather than a rewrite. It has not been tried, so -do not assume it works. +`make chrome` builds it. The only difference is the background key: Firefox MV3 +uses an event page, Chrome MV3 requires a service worker, so the manifest is +generated per target rather than duplicated. Everything else -- `chrome.*` +namespace, MV3, no Firefox-only APIs -- is already shared. + +It has not been tested against Chrome. Do not assume it works. ## Development @@ -109,54 +135,49 @@ corepack pnpm run dev # rebuild on change make clean ``` -While iterating, reinstalling an `.xpi` for every edit is tedious. Load the -unpacked directory instead: `about:debugging` -> **This Firefox** -> **Load -Temporary Add-on** -> `build/manifest.json`, then press **Reload** there after -each rebuild. That copy disappears on restart, which is the point -- it is for -development, not for daily use. +While iterating, load the unpacked directory rather than reinstalling an `.xpi` +each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** -> +`build/manifest.json`, then press **Reload** there after each rebuild. That copy +disappears on restart, which is the point -- it is for development, not daily +use. -`corepack pnpm run dev` watches and rebuilds, static files included, but -Firefox still needs the Reload click to pick anything up. +`corepack pnpm run dev` watches and rebuilds, static files included, but Firefox +still needs the Reload click to pick anything up. ### Layout | Path | Role | | --- | --- | -| `src/manifest.json` | MV3 manifest. Names no site | +| `src/manifest.json` | MV3 manifest. Requests no host access | +| `src/background.ts` | Registers the content script for the granted origin | | `src/content.ts` | Isolated world. Decides whether to act, then delegates | | `src/injected.ts` | Page world. Does the actual filling and clicking | -| `src/portal.ts` | Every selector the extension knows about the form | +| `src/portal.ts` | Default selectors and the shape of a fill request | | `src/config.ts` | Stored values, the rate limit and the logout cooldown | | `src/format.ts` | Input normalisers for the three masked fields | | `src/options.*`, `src/popup.*` | The two bits of UI | ### Why two scripts instead of one -The portal drives its inputs with Inputmask, which replaces each element's -`value` property with its own accessor. A content script assigning -`input.value` from the isolated world writes through Xrays to the *native* -setter and skips that accessor: the field looks right on screen, but the mask's -buffer is unchanged, and the page's submit handler reads the stale buffer back -out through jQuery. So the filling happens inside the page, through the page's -own jQuery and Inputmask. +These portals drive their inputs with Inputmask, which replaces each element's +`value` property with its own accessor. A content script assigning `input.value` +from the isolated world writes through Xrays to the *native* setter and skips +that accessor: the field looks right on screen, but the mask's buffer is +unchanged, and the page's submit handler reads the stale buffer back out through +jQuery. So the filling happens inside the page, through the page's own jQuery +and Inputmask, in `injected.ts`. -Submitting is a real click on the button, never `form.submit()`. The portal -intercepts the submit event, cancels it, and posts by AJAX with a CSRF token -taken from a meta tag. `form.submit()` would bypass that handler and lose the -token; a click reproduces exactly what a person pressing "Entrar" does. +Submitting is a real click on the button, never `form.submit()`. These portals +intercept the submit event, cancel it, and post by AJAX with a CSRF token taken +from a meta tag. `form.submit()` would bypass that handler and lose the token; a +click reproduces exactly what a person pressing the button does. -### Why the content script matches every URL +### Why the field selectors are configurable -The address is configured at runtime, so it cannot also be a manifest match -pattern -- putting it there is exactly what would name the institution in the -shipped code. The script therefore loads everywhere and stops immediately -unless the page's origin equals the configured one. Origins are compared whole, -so `portal.example.br.evil.tld` does not match. - -The honest cost: the extension holds read access to every page you visit. The -alternative -- registering the content script at runtime with -`scripting.registerContentScripts` and an optional host permission -- avoids -that at the price of a background script and a permission prompt. +Hardcoding them would tie the extension to one institution while pretending to +be general. They live in `portal.ts` as defaults and can be overridden per +installation, so the same build works for any portal of this shape -- and no +institution is named anywhere in the source, the manifest or the build output. ### Why pnpm, not npm diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/esbuild.config.mjs b/esbuild.config.mjs index c3b2a20..7422852 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -21,7 +21,7 @@ import esbuild from 'esbuild'; import process from 'process'; -import { cp, mkdir, readdir } from 'node:fs/promises'; +import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; const banner = `/* * logsdu - fills and submits a three-field academic portal login. @@ -37,16 +37,40 @@ const banner = `/* const prod = process.argv[2] === 'production'; const outdir = 'build'; +// Target browser. Firefox and Chrome disagree on exactly one manifest key, so +// the manifest is written per target rather than duplicated in the tree. +const target = process.env.TARGET === 'chrome' ? 'chrome' : 'firefox'; + +/** + * Write the manifest for the target browser. + * + * Firefox MV3 runs the background as an event page ("scripts"); Chrome MV3 + * requires a service worker and rejects "scripts" outright, so the two cannot + * simply coexist in one file. Chrome also has no use for the gecko block. + */ +async function writeManifest() { + const manifest = JSON.parse(await readFile('src/manifest.json', 'utf8')); + if (target === 'chrome') { + delete manifest.browser_specific_settings; + manifest.background = { service_worker: 'background.js' }; + } + await writeFile( + `${outdir}/manifest.json`, + `${JSON.stringify(manifest, null, '\t')}\n`, + ); +} + // Everything that is not TypeScript is copied verbatim into build/, so that // the directory can be handed straight to about:debugging. async function copyStatic() { await mkdir(outdir, { recursive: true }); for (const name of await readdir('src')) { - if (name.endsWith('.json') || name.endsWith('.html') || name.endsWith('.css')) { + if (name.endsWith('.html') || name.endsWith('.css')) { await cp(`src/${name}`, `${outdir}/${name}`); } } await cp('icons', `${outdir}/icons`, { recursive: true }); + await writeManifest(); } await copyStatic(); @@ -65,10 +89,11 @@ const context = await esbuild.context({ js: banner, }, plugins: [staticPlugin], - // Four independent entry points: no shared runtime, no imports at load + // Five independent entry points: no shared runtime, no imports at load // time. Content scripts and page-world scripts cannot be ES modules, so // every bundle has to stand alone. entryPoints: [ + 'src/background.ts', 'src/content.ts', 'src/injected.ts', 'src/options.ts', @@ -81,7 +106,14 @@ const context = await esbuild.context({ sourcemap: prod ? false : 'inline', treeShaking: true, outdir, - minify: prod, + // Deliberately never minified. addons.mozilla.org requires a separate + // source-code submission for any add-on whose uploaded code is minified or + // otherwise machine-generated, and that obligation would apply to every + // release from now on. The whole extension is a few tens of kilobytes, so + // the saving would not pay for the process, and shipping readable code + // makes the review -- and anyone auditing what handles their credentials -- + // straightforward. + minify: false, }); if (prod) { diff --git a/logsdu-0.2.0.xpi b/logsdu-0.2.0.xpi new file mode 100644 index 0000000000000000000000000000000000000000..27205f09749ecead1f4af297dcc484c2c93ea982 GIT binary patch literal 14606 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 v9ng;Y{|xv)Uy(nv*x%7k7y", "type": "module", diff --git a/src/config.test.ts b/src/config.test.ts index 1722ce3..66898bf 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -28,8 +28,11 @@ import { decideSubmit, isConfigured, matchesSite, + sitePattern, + withDefaults, } from './config.ts'; import type { Config, State } from './config.ts'; +import { DEFAULT_SELECTORS } from './portal.ts'; const CONFIG: Config = { url: 'https://portal.example.br/', @@ -37,6 +40,7 @@ const CONFIG: Config = { dn: '01/02/1999', cpf: '123.456.789-01', autoSubmit: true, + selectors: { ...DEFAULT_SELECTORS }, }; const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 }; @@ -51,6 +55,42 @@ test('isConfigured requires all four values', () => { assert.equal(isConfigured({ ...CONFIG, cpf: '' }), false); }); +test('sitePattern pins the grant to one origin', () => { + assert.equal(sitePattern(CONFIG), 'https://portal.example.br/*'); + assert.equal( + sitePattern({ ...CONFIG, url: 'https://portal.example.br/login?a=1' }), + 'https://portal.example.br/*', + 'path and query must not widen or narrow the grant', + ); + assert.equal(sitePattern({ ...CONFIG, url: '' }), null); +}); + +test('withDefaults fills selectors a stored config never had', () => { + // A config written by 0.1.x has no selectors key at all. It must come back + // complete, not half-built, or every lookup silently becomes undefined. + const upgraded = withDefaults({ + url: 'https://portal.example.br/', + ra: '1', + dn: '01/02/1999', + cpf: '123.456.789-01', + }); + assert.deepEqual(upgraded.selectors, DEFAULT_SELECTORS); + assert.equal(upgraded.autoSubmit, true); +}); + +test('withDefaults keeps a partial selector override and backfills the rest', () => { + const custom = withDefaults({ selectors: { form: '#login' } as never }); + assert.equal(custom.selectors.form, '#login'); + assert.equal(custom.selectors.ra, DEFAULT_SELECTORS.ra); + assert.equal(custom.selectors.logout, DEFAULT_SELECTORS.logout); +}); + +test('withDefaults on nothing stored is the empty config', () => { + const empty = withDefaults(undefined); + assert.equal(empty.url, ''); + assert.deepEqual(empty.selectors, DEFAULT_SELECTORS); +}); + test('matchesSite compares origins, not prefixes', () => { assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true); assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true); diff --git a/src/config.ts b/src/config.ts index 4d23482..77db00f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,6 +47,11 @@ export interface Config { dn: string; cpf: string; autoSubmit: boolean; + /** + * How to find the form on that site. Defaulted, and adjustable from the + * options page for portals whose markup differs. + */ + selectors: Selectors; } /** Bookkeeping that enforces the rate limit and the logout cooldown. */ @@ -61,13 +66,29 @@ export const EMPTY_CONFIG: Config = { dn: '', cpf: '', autoSubmit: true, + selectors: { ...DEFAULT_SELECTORS }, }; const EMPTY_STATE: State = { lastSubmitAt: 0, logoutAt: 0 }; +/** + * Merge stored values over the defaults. + * + * Selectors are merged one level deeper than the rest: a config saved by an + * older version, or one that only overrides the form selector, must still come + * back with every key present rather than a half-built object. + */ +export function withDefaults(stored: Partial | undefined): Config { + return { + ...EMPTY_CONFIG, + ...(stored ?? {}), + selectors: { ...DEFAULT_SELECTORS, ...(stored?.selectors ?? {}) }, + }; +} + export async function loadConfig(): Promise { const stored = await chrome.storage.local.get(CONFIG_KEY); - return { ...EMPTY_CONFIG, ...((stored[CONFIG_KEY] as Partial) ?? {}) }; + return withDefaults(stored[CONFIG_KEY] as Partial | undefined); } export async function saveConfig(config: Config): Promise { diff --git a/src/content.ts b/src/content.ts index d19cc0e..858777c 100644 --- a/src/content.ts +++ b/src/content.ts @@ -33,7 +33,7 @@ import { saveState, } from './config.ts'; import type { Config } from './config.ts'; -import { EVENT_RESULT, FORM, LOGOUT } from './portal.ts'; +import { EVENT_RESULT } from './portal.ts'; import type { FillRequest, FillResult } from './portal.ts'; const FORM_WAIT_MS = 10000; @@ -76,13 +76,13 @@ function fill(request: FillRequest): Promise { } /** Resolve once the login form exists, or null if it never shows up. */ -function awaitForm(): Promise { - const existing = document.querySelector(FORM); +function awaitForm(formSelector: string): Promise { + const existing = document.querySelector(formSelector); if (existing) return Promise.resolve(existing); return new Promise((resolve) => { const observer = new MutationObserver(() => { - const found = document.querySelector(FORM); + const found = document.querySelector(formSelector); if (found) { observer.disconnect(); window.clearTimeout(timer); @@ -128,12 +128,12 @@ function showNotice(text: string): void { * Without this the logout redirect lands on the login page and the extension * immediately logs them back in, which makes logging out impossible. */ -function watchLogout(): void { +function watchLogout(logoutSelector: string): void { document.addEventListener( 'click', (event) => { const target = event.target as Element | null; - if (!target?.closest?.(LOGOUT)) return; + if (!target?.closest?.(logoutSelector)) return; void loadState().then((state) => saveState({ ...state, logoutAt: Date.now() }), ); @@ -143,7 +143,7 @@ function watchLogout(): void { } async function autoLogin(config: Config): Promise { - const form = await awaitForm(); + const form = await awaitForm(config.selectors.form); if (!form) return; const state = await loadState(); @@ -161,6 +161,7 @@ async function autoLogin(config: Config): Promise { dn: config.dn, cpf: config.cpf, submit: decision.submit, + selectors: config.selectors, }); if (result.error) { @@ -177,7 +178,7 @@ async function main(): Promise { if (!isConfigured(config)) return; if (!matchesSite(config, location.href)) return; - watchLogout(); + watchLogout(config.selectors.logout); // The popup's "Preencher agora" button, for when the automatic submit is // off or rate limited. @@ -190,6 +191,7 @@ async function main(): Promise { dn: fresh.dn, cpf: fresh.cpf, submit: (message as { submit?: boolean }).submit === true, + selectors: fresh.selectors, }), ) .then(sendResponse); @@ -200,7 +202,10 @@ async function main(): Promise { // still loading and could still produce one. On the pages behind the login // this returns immediately instead of holding an observer open for ten // seconds on every navigation. - if (document.querySelector(FORM) || document.readyState !== 'complete') { + if ( + document.querySelector(config.selectors.form) || + document.readyState !== 'complete' + ) { await autoLogin(config); } } diff --git a/src/injected.ts b/src/injected.ts index faf3e40..32c204e 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -37,8 +37,8 @@ // before that handler exists would trigger a plain browser form POST without // the CSRF header, which fails. -import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal.ts'; -import type { FillRequest, FillResult } from './portal.ts'; +import { EVENT_RESULT } from './portal.ts'; +import type { FillRequest, FillResult, Selectors } from './portal.ts'; const POLL_INTERVAL_MS = 100; const POLL_TIMEOUT_MS = 15000; @@ -56,10 +56,10 @@ function report(result: FillResult): void { } /** jQuery, the form, and (when submitting) the page's own submit handler. */ -function readiness(needSubmit: boolean): JQueryLike | null { +function readiness(selectors: Selectors, needSubmit: boolean): JQueryLike | null { const $ = (window as unknown as { jQuery?: JQueryLike }).jQuery; if (!$) return null; - const form = $(FORM); + const form = $(selectors.form); if (!form.length) return null; if (needSubmit) { // jQuery keeps its handler registry in the private _data store. If the @@ -71,11 +71,11 @@ function readiness(needSubmit: boolean): JQueryLike | null { return $; } -function waitFor(needSubmit: boolean): Promise { +function waitFor(selectors: Selectors, needSubmit: boolean): Promise { return new Promise((resolve, reject) => { const deadline = Date.now() + POLL_TIMEOUT_MS; const tick = (): void => { - const $ = readiness(needSubmit); + const $ = readiness(selectors, needSubmit); if ($) { resolve($); return; @@ -110,13 +110,14 @@ function setField($: JQueryLike, element: HTMLInputElement, value: string): void } async function run(request: FillRequest): Promise { - const $ = await waitFor(request.submit); - const form = $(FORM); + const { selectors } = request; + const $ = await waitFor(selectors, request.submit); + const form = $(selectors.form); const targets: Array<[string, string]> = [ - [FIELDS.ra, request.ra], - [FIELDS.dn, request.dn], - [FIELDS.cpf, request.cpf], + [selectors.ra, request.ra], + [selectors.dn, request.dn], + [selectors.cpf, request.cpf], ]; for (const [selector, value] of targets) { @@ -135,7 +136,7 @@ async function run(request: FillRequest): Promise { return; } - const button = form.find(SUBMIT)[0] as HTMLButtonElement | undefined; + const button = form.find(selectors.submit)[0] as HTMLButtonElement | undefined; if (!button) { report({ filled: true, submitted: false, error: 'missing submit button' }); return; diff --git a/src/manifest.json b/src/manifest.json index a3dfcb6..ad2c188 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,8 +1,10 @@ { "manifest_version": 3, "name": "logsdu", - "version": "0.1.0", - "description": "Fills and submits a three-field academic portal login.", + "version": "0.2.0", + "description": "Saves and fills logins that password managers cannot: registration number, date of birth and document number.", + "author": "Ruben Carlo Benante (Dr. Beco)", + "homepage_url": "https://code.beco.cc/beco/logsdu", "icons": { "48": "icons/logsdu.svg", "96": "icons/logsdu.svg" diff --git a/src/options.html b/src/options.html index f768dd0..6808f2b 100644 --- a/src/options.html +++ b/src/options.html @@ -39,6 +39,39 @@ Entrar automaticamente (no máximo uma tentativa por hora) +
+ Ajustes avançados: como encontrar o formulário +

+ Seletores CSS usados para localizar os campos na página. Os + valores padrão servem para os portais mais comuns; mude-os + apenas se o preenchimento não funcionar no seu. +

+ + + + + + + + + + + + + + + + + + + +
+ +
+
+
@@ -46,10 +79,18 @@
+

Permissão de acesso

+

+ Ao salvar, o navegador pergunta se a extensão pode ler o endereço que + você informou. Ela não pede acesso a nenhum outro site, e a permissão + pode ser revogada a qualquer momento em + about:addons → Permissões. +

+

Como isso é guardado

- Os quatro valores ficam na memória local desta extensão, neste perfil - do navegador. Não são sincronizados nem enviados para lugar nenhum. A + Os valores ficam na memória local desta extensão, neste perfil do + navegador. Não são sincronizados nem enviados para lugar nenhum. A proteção é a mesma de uma senha guardada no navegador: quem tiver a sua sessão do sistema aberta consegue lê-los.

diff --git a/src/options.ts b/src/options.ts index 2d5b1fe..bceb940 100644 --- a/src/options.ts +++ b/src/options.ts @@ -19,7 +19,13 @@ // * rcb@beco.cc * // ************************************************************************* -import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config.ts'; +import { + EMPTY_CONFIG, + loadConfig, + resetState, + saveConfig, + sitePattern, +} from './config.ts'; import { formatCpf, formatDate, @@ -29,6 +35,8 @@ import { isCompleteRa, toOrigin, } from './format.ts'; +import { DEFAULT_SELECTORS, SELECTOR_KEYS, isValidSelector } from './portal.ts'; +import type { Selectors } from './portal.ts'; function el(id: string): T { const found = document.getElementById(id); @@ -46,6 +54,20 @@ const autoSubmit = el('autoSubmit'); const status = el('status'); const form = el('form'); +const selectorFields = Object.fromEntries( + SELECTOR_KEYS.map((key) => [key, el(`sel-${key}`)]), +) as Record; + +function readSelectors(): Selectors { + return Object.fromEntries( + SELECTOR_KEYS.map((key) => [key, selectorFields[key].value.trim()]), + ) as unknown as Selectors; +} + +function writeSelectors(selectors: Selectors): void { + for (const key of SELECTOR_KEYS) selectorFields[key].value = selectors[key]; +} + function setStatus(message: string, isError = false): void { status.textContent = message; status.classList.toggle('error', isError); @@ -79,6 +101,7 @@ async function load(): Promise { fields.dn.value = config.dn; fields.cpf.value = config.cpf; autoSubmit.checked = config.autoSubmit; + writeSelectors(config.selectors); } /** Mark the offending inputs and return the first complaint, if any. */ @@ -102,6 +125,18 @@ function validate(): string | null { fields.cpf.classList.add('invalid'); return 'CPF incompleto.'; } + + // A selector that the browser cannot parse would never match anything, and + // the failure would show up much later as "nothing happened" on the portal. + for (const key of SELECTOR_KEYS) { + const input = selectorFields[key]; + input.classList.remove('invalid'); + if (!isValidSelector(input.value)) { + input.classList.add('invalid'); + el('advanced').open = true; + return 'Seletor CSS inválido nos ajustes avançados.'; + } + } return null; } @@ -113,25 +148,58 @@ form.addEventListener('submit', (event) => { return; } - void (async () => { - await saveConfig({ - url: fields.url.value.trim(), - ra: fields.ra.value, - dn: fields.dn.value, - cpf: fields.cpf.value, - autoSubmit: autoSubmit.checked, + const config = { + url: fields.url.value.trim(), + ra: fields.ra.value, + dn: fields.dn.value, + cpf: fields.cpf.value, + autoSubmit: autoSubmit.checked, + selectors: readSelectors(), + }; + + const pattern = sitePattern(config); + if (pattern === null) { + setStatus('Endereço inválido.', true); + return; + } + + // permissions.request() must be the FIRST async call in this handler. + // Firefox only honours it while the user gesture from the click is still + // active, and awaiting anything beforehand -- even a storage write -- + // discards the gesture and the prompt is refused. + void chrome.permissions + .request({ origins: [pattern] }) + .then(async (granted) => { + if (!granted) { + setStatus('Permissão negada: a extensão não pode agir nesse site.', true); + return; + } + await saveConfig(config); + // Saving is how you correct a typo, so it also clears the hourly + // limit and the logout cooldown: the next visit may try again. + await resetState(); + setStatus('Salvo.'); + }) + .catch((error: unknown) => { + setStatus(`Falha ao salvar: ${String(error)}`, true); }); - // Saving is how you correct a typo, so it also clears the hourly limit - // and the logout cooldown: the next visit is allowed to try again. - await resetState(); - setStatus('Salvo.'); - })(); +}); + +el('defaults').addEventListener('click', () => { + writeSelectors(DEFAULT_SELECTORS); + setStatus('Padrões restaurados. Salve para aplicar.'); }); el('clear').addEventListener('click', () => { void (async () => { - await saveConfig({ ...EMPTY_CONFIG }); + const previous = sitePattern(await loadConfig()); + await saveConfig({ ...EMPTY_CONFIG, selectors: { ...DEFAULT_SELECTORS } }); await resetState(); + // Hand the site permission back as well. Leaving it granted after the + // user has wiped their data would keep access they no longer use. + if (previous !== null) { + await chrome.permissions.remove({ origins: [previous] }).catch(() => false); + } await load(); setStatus('Dados apagados.'); })(); diff --git a/src/popup.ts b/src/popup.ts index 3d9920b..b7a4ab1 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -23,7 +23,7 @@ // the automatic submit is switched off, rate limited, or suppressed after a // logout. -import { isConfigured, loadConfig, matchesSite } from './config.ts'; +import { isConfigured, loadConfig, matchesSite, sitePattern } from './config.ts'; const state = document.getElementById('state') as HTMLParagraphElement; const detail = document.getElementById('detail') as HTMLParagraphElement; @@ -50,6 +50,18 @@ async function refresh(): Promise { return; } + // Configured but not permitted is its own state, and the one most likely to + // look like a bug: everything is filled in, yet nothing ever happens. + const pattern = sitePattern(config); + const granted = + pattern !== null && (await chrome.permissions.contains({ origins: [pattern] })); + if (!granted) { + state.textContent = 'Sem permissão'; + detail.textContent = + 'A extensão ainda não tem acesso ao site. Abra as opções e salve novamente para conceder.'; + return; + } + const tabId = await activeTabOnSite(); if (tabId === null) { state.textContent = 'Configurado';