version 0.2 ready for upload

This commit is contained in:
2026-08-10 15:23:17 -03:00
parent 9826dc1263
commit 17ad302a6c
15 changed files with 402 additions and 143 deletions

12
.gitignore vendored
View File

@@ -29,11 +29,17 @@
# dependencies # dependencies
node_modules node_modules
# Build output. The unpacked extension and the signing package are release # Unpacked build output. Regenerated by "make", never edited by hand.
# artifacts, not sources.
build build
# Intermediate packages.
logsdu-*.zip 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 # Exclude sourcemaps
*.map *.map

View File

@@ -45,7 +45,7 @@ EXT_ID := logsdu
VERSION := $(shell node -p "require('./package.json').version") VERSION := $(shell node -p "require('./package.json').version")
XPI := $(EXT_ID)-$(VERSION).xpi XPI := $(EXT_ID)-$(VERSION).xpi
.PHONY: all build test xpi clean check-deps .PHONY: all build chrome test xpi clean check-deps
all: build all: build
@@ -61,6 +61,13 @@ build: check-deps
node_modules/.bin/tsc -noEmit -skipLibCheck node_modules/.bin/tsc -noEmit -skipLibCheck
node esbuild.config.mjs production 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 # 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 # top level rather than inside a wrapper folder. The same file installs
# directly on ESR and uploads to AMO for signing. # directly on ESR and uploads to AMO for signing.
@@ -70,12 +77,15 @@ xpi: build
@echo @echo
@echo "Built: $(CURDIR)/$(XPI)" @echo "Built: $(CURDIR)/$(XPI)"
@echo @echo
@echo "To install, in Firefox:" @echo "This file is UNSIGNED. Two ways to use it:"
@echo " 1. about:config -> set xpinstall.signatures.required = false" @echo
@echo " (only ESR, Developer Edition and Nightly honour this)" @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 " 2. about:addons -> gear icon -> Install Add-on From File"
@echo " 3. paste this path into the file picker:" @echo " 3. paste this path into the file picker:"
@echo
@echo " $(CURDIR)/$(XPI)" @echo " $(CURDIR)/$(XPI)"
@echo @echo

193
README.md
View File

@@ -1,67 +1,81 @@
# logsdu # logsdu
A Firefox extension that logs you into an academic portal whose sign-in form is A Firefox extension for logins that password managers cannot save: the ones
three fields -- registration number, birth date and national ID -- rather than made of a registration number, a date of birth and a document number, instead
the usual user and password. of a username and a password.
That shape defeats password managers. The form is marked `autocomplete="off"`, Academic portals do this a lot. The form is usually marked `autocomplete="off"`,
none of the three inputs is a `password` field, and Bitwarden, Firefox and none of the fields is a `password` field, and Bitwarden, Firefox and Chrome all
Chrome all decline to remember it. Since the values never change and the decline to remember it. When the values never change and the institution offers
institution does not let you pick a different login method, the only option no other way in, the only option left is copying three values by hand, every
left is copying three values by hand, every time. single time -- including the registration number nobody has memorised.
logsdu stores them once and fills them in. By default it also presses "Entrar", logsdu stores them once and fills them in. By default it also presses the submit
so the normal case is zero clicks. button, so the normal case is zero clicks.
## Install ## Install
Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below). Once it is published, from addons.mozilla.org. Until then, build it yourself:
Node 22+ ships `corepack`, so pnpm does not have to be installed globally.
``` ```
corepack pnpm install # first time, or after a dependency change corepack pnpm install # first time, or after a dependency change
make xpi # typecheck, bundle, and package logsdu-<version>.xpi make xpi # typecheck, bundle, and package logsdu-<version>.xpi
``` ```
Then install it permanently. Firefox will not load an unsigned add-on unless `make xpi` prints the full path of the file and how to install it. The package
you tell it to, and only the ESR, Developer Edition and Nightly builds accept it produces is unsigned, and Firefox only accepts unsigned add-ons on the ESR,
being told: 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 Dependencies are managed with **pnpm**, never npm. See "Why pnpm" below.
2. Set `xpinstall.signatures.required` to **false**
3. Open `about:addons`, click the **gear** icon, choose **Install Add-on From
File**, and pick `logsdu-<version>.xpi`
It survives restarts. On **release** Firefox that pref is ignored -- see ## Setting it up
"Release Firefox" below.
Finally, open the extension's options page, fill in the four values, and save. Open the extension's options page and fill in four values:
## The four values
| Field | Example | Notes | | Field | Example | Notes |
| --- | --- | --- | | --- | --- | --- |
| Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored | | Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored |
| Registration number | `2000101010` | Digits only | | Registration number | `2000101010` | Digits only |
| Birth date | `01/01/2000` | Reformatted as you type | | Date of birth | `01/01/2000` | Reformatted as you type |
| National ID | `000.000.000-00` | 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 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 When you press Save, Firefox asks whether logsdu may access the address you
anywhere in the extension: not in the manifest, not in the source, not in the entered. That prompt names one site. Accept it and the extension starts working
build output. Be clear about what that does and does not buy you. It stops there; decline and nothing is stored as usable.
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 If the portal's markup differs from the common shape, open **Ajustes avançados**
is the same access that would expose your national ID and birth date anyway. 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 ## How it behaves
- **Fills and submits** on the login page, with no interaction. - **Fills and submits** on the login page, with no interaction.
- **At most one automatic submit per hour.** After an attempt, the extension - **At most one automatic submit per hour.** After an attempt it drops back to
drops back to filling only, so a wrong value cannot resubmit itself on every filling only, so a wrong value cannot resubmit itself on every page load and
page load and lock you out. Correct the values and save; saving clears the lock you out of your account. Correct the values and save; saving clears the
timer, so the next visit tries again immediately. timer, so the next visit tries again immediately.
- **Logging out keeps you logged out.** Clicking the portal's logout control - **Logging out keeps you logged out.** Clicking the portal's logout control
suppresses the automatic submit for five minutes -- otherwise the logout 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. submitting, for when you want to check the values before sending them.
- Automatic submission can be turned off entirely in the options. - 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 Nowhere. It is written to `storage.local`: private to your browser profile,
anywhere. That is the same protection a browser-saved password gets, and it has never synced, never transmitted. The extension makes no network requests of its
the same limit -- anyone with your unlocked account can read it. If that is not own and contains no analytics.
good enough for your threat model, this extension is the wrong tool.
## 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 The interface is in Portuguese, matching the portals it was written for.
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 `browser_specific_settings.gecko.id` in the manifest is what gives the ## Publishing
add-on a stable identity across both routes, so settings survive an upgrade
from one to the other. `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 ## Chrome
Not yet. The source deliberately avoids anything Firefox-specific: it uses the `make chrome` builds it. The only difference is the background key: Firefox MV3
`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build uses an event page, Chrome MV3 requires a service worker, so the manifest is
should be a manifest question rather than a rewrite. It has not been tried, so generated per target rather than duplicated. Everything else -- `chrome.*`
do not assume it works. namespace, MV3, no Firefox-only APIs -- is already shared.
It has not been tested against Chrome. Do not assume it works.
## Development ## Development
@@ -109,54 +135,49 @@ corepack pnpm run dev # rebuild on change
make clean make clean
``` ```
While iterating, reinstalling an `.xpi` for every edit is tedious. Load the While iterating, load the unpacked directory rather than reinstalling an `.xpi`
unpacked directory instead: `about:debugging` -> **This Firefox** -> **Load each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** ->
Temporary Add-on** -> `build/manifest.json`, then press **Reload** there after `build/manifest.json`, then press **Reload** there after each rebuild. That copy
each rebuild. That copy disappears on restart, which is the point -- it is for disappears on restart, which is the point -- it is for development, not daily
development, not for daily use. use.
`corepack pnpm run dev` watches and rebuilds, static files included, but `corepack pnpm run dev` watches and rebuilds, static files included, but Firefox
Firefox still needs the Reload click to pick anything up. still needs the Reload click to pick anything up.
### Layout ### Layout
| Path | Role | | 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/content.ts` | Isolated world. Decides whether to act, then delegates |
| `src/injected.ts` | Page world. Does the actual filling and clicking | | `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/config.ts` | Stored values, the rate limit and the logout cooldown |
| `src/format.ts` | Input normalisers for the three masked fields | | `src/format.ts` | Input normalisers for the three masked fields |
| `src/options.*`, `src/popup.*` | The two bits of UI | | `src/options.*`, `src/popup.*` | The two bits of UI |
### Why two scripts instead of one ### Why two scripts instead of one
The portal drives its inputs with Inputmask, which replaces each element's These portals drive their inputs with Inputmask, which replaces each element's
`value` property with its own accessor. A content script assigning `value` property with its own accessor. A content script assigning `input.value`
`input.value` from the isolated world writes through Xrays to the *native* from the isolated world writes through Xrays to the *native* setter and skips
setter and skips that accessor: the field looks right on screen, but the mask's that accessor: the field looks right on screen, but the mask's buffer is
buffer is unchanged, and the page's submit handler reads the stale buffer back unchanged, and the page's submit handler reads the stale buffer back out through
out through jQuery. So the filling happens inside the page, through the page's jQuery. So the filling happens inside the page, through the page's own jQuery
own jQuery and Inputmask. and Inputmask, in `injected.ts`.
Submitting is a real click on the button, never `form.submit()`. The portal Submitting is a real click on the button, never `form.submit()`. These portals
intercepts the submit event, cancels it, and posts by AJAX with a CSRF token intercept the submit event, cancel it, and post by AJAX with a CSRF token taken
taken from a meta tag. `form.submit()` would bypass that handler and lose the from a meta tag. `form.submit()` would bypass that handler and lose the token; a
token; a click reproduces exactly what a person pressing "Entrar" does. 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 Hardcoding them would tie the extension to one institution while pretending to
pattern -- putting it there is exactly what would name the institution in the be general. They live in `portal.ts` as defaults and can be overridden per
shipped code. The script therefore loads everywhere and stops immediately installation, so the same build works for any portal of this shape -- and no
unless the page's origin equals the configured one. Origins are compared whole, institution is named anywhere in the source, the manifest or the build output.
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.
### Why pnpm, not npm ### Why pnpm, not npm

View File

@@ -1 +1 @@
0.1.0 0.2.0

View File

@@ -21,7 +21,7 @@
import esbuild from 'esbuild'; import esbuild from 'esbuild';
import process from 'process'; import process from 'process';
import { cp, mkdir, readdir } from 'node:fs/promises'; import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
const banner = `/* const banner = `/*
* logsdu - fills and submits a three-field academic portal login. * logsdu - fills and submits a three-field academic portal login.
@@ -37,16 +37,40 @@ const banner = `/*
const prod = process.argv[2] === 'production'; const prod = process.argv[2] === 'production';
const outdir = 'build'; 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 // Everything that is not TypeScript is copied verbatim into build/, so that
// the directory can be handed straight to about:debugging. // the directory can be handed straight to about:debugging.
async function copyStatic() { async function copyStatic() {
await mkdir(outdir, { recursive: true }); await mkdir(outdir, { recursive: true });
for (const name of await readdir('src')) { 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(`src/${name}`, `${outdir}/${name}`);
} }
} }
await cp('icons', `${outdir}/icons`, { recursive: true }); await cp('icons', `${outdir}/icons`, { recursive: true });
await writeManifest();
} }
await copyStatic(); await copyStatic();
@@ -65,10 +89,11 @@ const context = await esbuild.context({
js: banner, js: banner,
}, },
plugins: [staticPlugin], 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 // time. Content scripts and page-world scripts cannot be ES modules, so
// every bundle has to stand alone. // every bundle has to stand alone.
entryPoints: [ entryPoints: [
'src/background.ts',
'src/content.ts', 'src/content.ts',
'src/injected.ts', 'src/injected.ts',
'src/options.ts', 'src/options.ts',
@@ -81,7 +106,14 @@ const context = await esbuild.context({
sourcemap: prod ? false : 'inline', sourcemap: prod ? false : 'inline',
treeShaking: true, treeShaking: true,
outdir, 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) { if (prod) {

BIN
logsdu-0.2.0.xpi Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{ {
"name": "logsdu", "name": "logsdu",
"version": "0.1.0", "version": "0.2.0",
"description": "Browser extension that fills and submits a three-field academic portal login.", "description": "Browser extension that fills and submits a three-field academic portal login.",
"author": "Ruben Carlo Benante <rcb@beco.cc>", "author": "Ruben Carlo Benante <rcb@beco.cc>",
"type": "module", "type": "module",

View File

@@ -28,8 +28,11 @@ import {
decideSubmit, decideSubmit,
isConfigured, isConfigured,
matchesSite, matchesSite,
sitePattern,
withDefaults,
} from './config.ts'; } from './config.ts';
import type { Config, State } from './config.ts'; import type { Config, State } from './config.ts';
import { DEFAULT_SELECTORS } from './portal.ts';
const CONFIG: Config = { const CONFIG: Config = {
url: 'https://portal.example.br/', url: 'https://portal.example.br/',
@@ -37,6 +40,7 @@ const CONFIG: Config = {
dn: '01/02/1999', dn: '01/02/1999',
cpf: '123.456.789-01', cpf: '123.456.789-01',
autoSubmit: true, autoSubmit: true,
selectors: { ...DEFAULT_SELECTORS },
}; };
const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 }; const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 };
@@ -51,6 +55,42 @@ test('isConfigured requires all four values', () => {
assert.equal(isConfigured({ ...CONFIG, cpf: '' }), false); 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', () => { test('matchesSite compares origins, not prefixes', () => {
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true); assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true);
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true); assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true);

View File

@@ -47,6 +47,11 @@ export interface Config {
dn: string; dn: string;
cpf: string; cpf: string;
autoSubmit: boolean; 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. */ /** Bookkeeping that enforces the rate limit and the logout cooldown. */
@@ -61,13 +66,29 @@ export const EMPTY_CONFIG: Config = {
dn: '', dn: '',
cpf: '', cpf: '',
autoSubmit: true, autoSubmit: true,
selectors: { ...DEFAULT_SELECTORS },
}; };
const EMPTY_STATE: State = { lastSubmitAt: 0, logoutAt: 0 }; 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<Config> | undefined): Config {
return {
...EMPTY_CONFIG,
...(stored ?? {}),
selectors: { ...DEFAULT_SELECTORS, ...(stored?.selectors ?? {}) },
};
}
export async function loadConfig(): Promise<Config> { export async function loadConfig(): Promise<Config> {
const stored = await chrome.storage.local.get(CONFIG_KEY); const stored = await chrome.storage.local.get(CONFIG_KEY);
return { ...EMPTY_CONFIG, ...((stored[CONFIG_KEY] as Partial<Config>) ?? {}) }; return withDefaults(stored[CONFIG_KEY] as Partial<Config> | undefined);
} }
export async function saveConfig(config: Config): Promise<void> { export async function saveConfig(config: Config): Promise<void> {

View File

@@ -33,7 +33,7 @@ import {
saveState, saveState,
} from './config.ts'; } from './config.ts';
import type { Config } 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'; import type { FillRequest, FillResult } from './portal.ts';
const FORM_WAIT_MS = 10000; const FORM_WAIT_MS = 10000;
@@ -76,13 +76,13 @@ function fill(request: FillRequest): Promise<FillResult> {
} }
/** Resolve once the login form exists, or null if it never shows up. */ /** Resolve once the login form exists, or null if it never shows up. */
function awaitForm(): Promise<HTMLFormElement | null> { function awaitForm(formSelector: string): Promise<HTMLFormElement | null> {
const existing = document.querySelector<HTMLFormElement>(FORM); const existing = document.querySelector<HTMLFormElement>(formSelector);
if (existing) return Promise.resolve(existing); if (existing) return Promise.resolve(existing);
return new Promise((resolve) => { return new Promise((resolve) => {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
const found = document.querySelector<HTMLFormElement>(FORM); const found = document.querySelector<HTMLFormElement>(formSelector);
if (found) { if (found) {
observer.disconnect(); observer.disconnect();
window.clearTimeout(timer); 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 * Without this the logout redirect lands on the login page and the extension
* immediately logs them back in, which makes logging out impossible. * immediately logs them back in, which makes logging out impossible.
*/ */
function watchLogout(): void { function watchLogout(logoutSelector: string): void {
document.addEventListener( document.addEventListener(
'click', 'click',
(event) => { (event) => {
const target = event.target as Element | null; const target = event.target as Element | null;
if (!target?.closest?.(LOGOUT)) return; if (!target?.closest?.(logoutSelector)) return;
void loadState().then((state) => void loadState().then((state) =>
saveState({ ...state, logoutAt: Date.now() }), saveState({ ...state, logoutAt: Date.now() }),
); );
@@ -143,7 +143,7 @@ function watchLogout(): void {
} }
async function autoLogin(config: Config): Promise<void> { async function autoLogin(config: Config): Promise<void> {
const form = await awaitForm(); const form = await awaitForm(config.selectors.form);
if (!form) return; if (!form) return;
const state = await loadState(); const state = await loadState();
@@ -161,6 +161,7 @@ async function autoLogin(config: Config): Promise<void> {
dn: config.dn, dn: config.dn,
cpf: config.cpf, cpf: config.cpf,
submit: decision.submit, submit: decision.submit,
selectors: config.selectors,
}); });
if (result.error) { if (result.error) {
@@ -177,7 +178,7 @@ async function main(): Promise<void> {
if (!isConfigured(config)) return; if (!isConfigured(config)) return;
if (!matchesSite(config, location.href)) return; if (!matchesSite(config, location.href)) return;
watchLogout(); watchLogout(config.selectors.logout);
// The popup's "Preencher agora" button, for when the automatic submit is // The popup's "Preencher agora" button, for when the automatic submit is
// off or rate limited. // off or rate limited.
@@ -190,6 +191,7 @@ async function main(): Promise<void> {
dn: fresh.dn, dn: fresh.dn,
cpf: fresh.cpf, cpf: fresh.cpf,
submit: (message as { submit?: boolean }).submit === true, submit: (message as { submit?: boolean }).submit === true,
selectors: fresh.selectors,
}), }),
) )
.then(sendResponse); .then(sendResponse);
@@ -200,7 +202,10 @@ async function main(): Promise<void> {
// still loading and could still produce one. On the pages behind the login // still loading and could still produce one. On the pages behind the login
// this returns immediately instead of holding an observer open for ten // this returns immediately instead of holding an observer open for ten
// seconds on every navigation. // seconds on every navigation.
if (document.querySelector(FORM) || document.readyState !== 'complete') { if (
document.querySelector(config.selectors.form) ||
document.readyState !== 'complete'
) {
await autoLogin(config); await autoLogin(config);
} }
} }

View File

@@ -37,8 +37,8 @@
// before that handler exists would trigger a plain browser form POST without // before that handler exists would trigger a plain browser form POST without
// the CSRF header, which fails. // the CSRF header, which fails.
import { EVENT_RESULT, FIELDS, FORM, SUBMIT } from './portal.ts'; import { EVENT_RESULT } from './portal.ts';
import type { FillRequest, FillResult } from './portal.ts'; import type { FillRequest, FillResult, Selectors } from './portal.ts';
const POLL_INTERVAL_MS = 100; const POLL_INTERVAL_MS = 100;
const POLL_TIMEOUT_MS = 15000; 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. */ /** 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; const $ = (window as unknown as { jQuery?: JQueryLike }).jQuery;
if (!$) return null; if (!$) return null;
const form = $(FORM); const form = $(selectors.form);
if (!form.length) return null; if (!form.length) return null;
if (needSubmit) { if (needSubmit) {
// jQuery keeps its handler registry in the private _data store. If the // jQuery keeps its handler registry in the private _data store. If the
@@ -71,11 +71,11 @@ function readiness(needSubmit: boolean): JQueryLike | null {
return $; return $;
} }
function waitFor(needSubmit: boolean): Promise<JQueryLike> { function waitFor(selectors: Selectors, needSubmit: boolean): Promise<JQueryLike> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const deadline = Date.now() + POLL_TIMEOUT_MS; const deadline = Date.now() + POLL_TIMEOUT_MS;
const tick = (): void => { const tick = (): void => {
const $ = readiness(needSubmit); const $ = readiness(selectors, needSubmit);
if ($) { if ($) {
resolve($); resolve($);
return; return;
@@ -110,13 +110,14 @@ function setField($: JQueryLike, element: HTMLInputElement, value: string): void
} }
async function run(request: FillRequest): Promise<void> { async function run(request: FillRequest): Promise<void> {
const $ = await waitFor(request.submit); const { selectors } = request;
const form = $(FORM); const $ = await waitFor(selectors, request.submit);
const form = $(selectors.form);
const targets: Array<[string, string]> = [ const targets: Array<[string, string]> = [
[FIELDS.ra, request.ra], [selectors.ra, request.ra],
[FIELDS.dn, request.dn], [selectors.dn, request.dn],
[FIELDS.cpf, request.cpf], [selectors.cpf, request.cpf],
]; ];
for (const [selector, value] of targets) { for (const [selector, value] of targets) {
@@ -135,7 +136,7 @@ async function run(request: FillRequest): Promise<void> {
return; return;
} }
const button = form.find(SUBMIT)[0] as HTMLButtonElement | undefined; const button = form.find(selectors.submit)[0] as HTMLButtonElement | undefined;
if (!button) { if (!button) {
report({ filled: true, submitted: false, error: 'missing submit button' }); report({ filled: true, submitted: false, error: 'missing submit button' });
return; return;

View File

@@ -1,8 +1,10 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "logsdu", "name": "logsdu",
"version": "0.1.0", "version": "0.2.0",
"description": "Fills and submits a three-field academic portal login.", "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": { "icons": {
"48": "icons/logsdu.svg", "48": "icons/logsdu.svg",
"96": "icons/logsdu.svg" "96": "icons/logsdu.svg"

View File

@@ -39,6 +39,39 @@
<span>Entrar automaticamente (no máximo uma tentativa por hora)</span> <span>Entrar automaticamente (no máximo uma tentativa por hora)</span>
</label> </label>
<details id="advanced">
<summary>Ajustes avançados: como encontrar o formulário</summary>
<p class="hint">
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.
</p>
<label for="sel-form">Formulário de login</label>
<input id="sel-form" type="text" spellcheck="false" />
<label for="sel-ra">Campo da matrícula</label>
<input id="sel-ra" type="text" spellcheck="false" />
<label for="sel-dn">Campo da data de nascimento</label>
<input id="sel-dn" type="text" spellcheck="false" />
<label for="sel-cpf">Campo do CPF</label>
<input id="sel-cpf" type="text" spellcheck="false" />
<label for="sel-submit">Botão de entrar</label>
<input id="sel-submit" type="text" spellcheck="false" />
<label for="sel-logout">Botão de sair</label>
<input id="sel-logout" type="text" spellcheck="false" />
<div class="row">
<button id="defaults" type="button" class="ghost">
Restaurar padrões
</button>
</div>
</details>
<div class="row"> <div class="row">
<button id="save" type="submit">Salvar</button> <button id="save" type="submit">Salvar</button>
<button id="clear" type="button" class="ghost">Apagar dados</button> <button id="clear" type="button" class="ghost">Apagar dados</button>
@@ -46,10 +79,18 @@
</div> </div>
</form> </form>
<h2>Permissão de acesso</h2>
<p class="hint">
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
<code>about:addons</code> &rarr; Permissões.
</p>
<h2>Como isso é guardado</h2> <h2>Como isso é guardado</h2>
<p class="hint"> <p class="hint">
Os quatro valores ficam na memória local desta extensão, neste perfil Os valores ficam na memória local desta extensão, neste perfil do
do navegador. Não são sincronizados nem enviados para lugar nenhum. A 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 proteção é a mesma de uma senha guardada no navegador: quem tiver a
sua sessão do sistema aberta consegue lê-los. sua sessão do sistema aberta consegue lê-los.
</p> </p>

View File

@@ -19,7 +19,13 @@
// * rcb@beco.cc * // * rcb@beco.cc *
// ************************************************************************* // *************************************************************************
import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config.ts'; import {
EMPTY_CONFIG,
loadConfig,
resetState,
saveConfig,
sitePattern,
} from './config.ts';
import { import {
formatCpf, formatCpf,
formatDate, formatDate,
@@ -29,6 +35,8 @@ import {
isCompleteRa, isCompleteRa,
toOrigin, toOrigin,
} from './format.ts'; } from './format.ts';
import { DEFAULT_SELECTORS, SELECTOR_KEYS, isValidSelector } from './portal.ts';
import type { Selectors } from './portal.ts';
function el<T extends HTMLElement>(id: string): T { function el<T extends HTMLElement>(id: string): T {
const found = document.getElementById(id); const found = document.getElementById(id);
@@ -46,6 +54,20 @@ const autoSubmit = el<HTMLInputElement>('autoSubmit');
const status = el<HTMLSpanElement>('status'); const status = el<HTMLSpanElement>('status');
const form = el<HTMLFormElement>('form'); const form = el<HTMLFormElement>('form');
const selectorFields = Object.fromEntries(
SELECTOR_KEYS.map((key) => [key, el<HTMLInputElement>(`sel-${key}`)]),
) as Record<keyof Selectors, HTMLInputElement>;
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 { function setStatus(message: string, isError = false): void {
status.textContent = message; status.textContent = message;
status.classList.toggle('error', isError); status.classList.toggle('error', isError);
@@ -79,6 +101,7 @@ async function load(): Promise<void> {
fields.dn.value = config.dn; fields.dn.value = config.dn;
fields.cpf.value = config.cpf; fields.cpf.value = config.cpf;
autoSubmit.checked = config.autoSubmit; autoSubmit.checked = config.autoSubmit;
writeSelectors(config.selectors);
} }
/** Mark the offending inputs and return the first complaint, if any. */ /** Mark the offending inputs and return the first complaint, if any. */
@@ -102,6 +125,18 @@ function validate(): string | null {
fields.cpf.classList.add('invalid'); fields.cpf.classList.add('invalid');
return 'CPF incompleto.'; 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<HTMLDetailsElement>('advanced').open = true;
return 'Seletor CSS inválido nos ajustes avançados.';
}
}
return null; return null;
} }
@@ -113,25 +148,58 @@ form.addEventListener('submit', (event) => {
return; return;
} }
void (async () => { const config = {
await saveConfig({
url: fields.url.value.trim(), url: fields.url.value.trim(),
ra: fields.ra.value, ra: fields.ra.value,
dn: fields.dn.value, dn: fields.dn.value,
cpf: fields.cpf.value, cpf: fields.cpf.value,
autoSubmit: autoSubmit.checked, autoSubmit: autoSubmit.checked,
}); selectors: readSelectors(),
// 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.
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(); await resetState();
setStatus('Salvo.'); setStatus('Salvo.');
})(); })
.catch((error: unknown) => {
setStatus(`Falha ao salvar: ${String(error)}`, true);
});
});
el<HTMLButtonElement>('defaults').addEventListener('click', () => {
writeSelectors(DEFAULT_SELECTORS);
setStatus('Padrões restaurados. Salve para aplicar.');
}); });
el<HTMLButtonElement>('clear').addEventListener('click', () => { el<HTMLButtonElement>('clear').addEventListener('click', () => {
void (async () => { void (async () => {
await saveConfig({ ...EMPTY_CONFIG }); const previous = sitePattern(await loadConfig());
await saveConfig({ ...EMPTY_CONFIG, selectors: { ...DEFAULT_SELECTORS } });
await resetState(); 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(); await load();
setStatus('Dados apagados.'); setStatus('Dados apagados.');
})(); })();

View File

@@ -23,7 +23,7 @@
// the automatic submit is switched off, rate limited, or suppressed after a // the automatic submit is switched off, rate limited, or suppressed after a
// logout. // logout.
import { isConfigured, loadConfig, matchesSite } from './config.ts'; import { isConfigured, loadConfig, matchesSite, sitePattern } from './config.ts';
const state = document.getElementById('state') as HTMLParagraphElement; const state = document.getElementById('state') as HTMLParagraphElement;
const detail = document.getElementById('detail') as HTMLParagraphElement; const detail = document.getElementById('detail') as HTMLParagraphElement;
@@ -50,6 +50,18 @@ async function refresh(): Promise<void> {
return; 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(); const tabId = await activeTabOnSite();
if (tabId === null) { if (tabId === null) {
state.textContent = 'Configurado'; state.textContent = 'Configurado';