4 Commits
0.1.0 ... 0.3.1

23 changed files with 916 additions and 207 deletions

10
.gitignore vendored
View File

@@ -29,11 +29,13 @@
# 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
logsdu-*.zip
*.xpi # dist/ holds the publishable packages and is NOT ignored: 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 it.
# Local unsigned builds land there too; "make clean" removes the directory.
# Exclude sourcemaps # Exclude sourcemaps
*.map *.map

132
Makefile
View File

@@ -19,35 +19,57 @@
# * rcb@beco.cc * # * rcb@beco.cc *
# ************************************************************************** # **************************************************************************
# Makefile for logsdu - build the unpacked extension into build/. # Makefile for logsdu - build the extension for Firefox and Chrome.
# #
# Usage: # Usage:
# make # typecheck and bundle into build/ # make # everything: build and package both browsers
# make firefox # build Firefox only -> build/firefox/
# make chrome # build Chrome only -> build/chrome/
# make test # run the unit tests # make test # run the unit tests
# make xpi # build, then package build/ as logsdu-<version>.xpi # make smoke # check the background bundle works as a service worker
# make clean # remove build/ and the package # make xpi # package Firefox -> dist/logsdu-<version>-firefox.xpi
# make crx # package Chrome -> dist/logsdu-<version>-chrome.zip
# make packages # both of the above (same as plain "make")
# make clean # remove build/, dist/ and stray packages
# make distclean # clean, plus node_modules/
#
# build/<target>/ holds the unpacked extension for one browser; the two targets
# never share a directory, so neither can be left stale by the other. dist/
# holds the packages meant to be published, and is kept out of build/ so that
# packaging never tries to include its own output.
# #
# Dependencies are installed with pnpm, never npm: # Dependencies are installed with pnpm, never npm:
# corepack pnpm install # corepack pnpm install
# #
# Permanent install (Firefox ESR, Developer Edition or Nightly): # Load the unpacked build while developing:
# set xpinstall.signatures.required=false in about:config, then # Firefox about:debugging -> This Firefox -> Load Temporary Add-on ->
# about:addons -> gear -> Install Add-on From File -> pick the .xpi # build/firefox/manifest.json (dropped when Firefox restarts)
# Chrome chrome://extensions -> Developer mode -> Load unpacked ->
# build/chrome/
# #
# Release Firefox refuses unsigned add-ons whatever that pref says. There the # Publishing:
# same .xpi has to go through addons.mozilla.org as an unlisted add-on first, # Firefox upload dist/*-firefox.xpi at addons.mozilla.org
# which signs it automatically without publishing or reviewing it. # Chrome upload dist/*-chrome.zip at chrome.google.com/webstore/devconsole
# #
# Throwaway install for development: about:debugging -> This Firefox -> # Every package filename names its browser. The two are not interchangeable --
# Load Temporary Add-on -> pick build/manifest.json (dropped on restart). # they differ in the manifest's background key -- and uploading the wrong one
# fails in ways that are not obvious from the error.
EXT_ID := logsdu EXT_ID := logsdu
VERSION := $(shell node -p "require('./package.json').version") VERSION := $(shell node -p "require('./package.json').version")
XPI := $(EXT_ID)-$(VERSION).xpi FIREFOX_DIR := build/firefox
CHROME_DIR := build/chrome
DIST := dist
XPI := $(DIST)/$(EXT_ID)-$(VERSION)-firefox.xpi
CRX := $(DIST)/$(EXT_ID)-$(VERSION)-chrome.zip
.PHONY: all build test xpi clean check-deps .PHONY: all firefox chrome typecheck test smoke xpi crx packages clean \
distclean check-deps
all: build # The default does the lot: build both browsers and package both. Packaging is
# only a zip of a directory that was going to be built anyway, so making it the
# default costs nothing and means dist/ is never quietly out of date with src/.
all: packages
# Unit tests for the pure logic: the input formatters and the decision that # Unit tests for the pure logic: the input formatters and the decision that
# says whether a page load may press "Entrar". Run straight through Node's # says whether a page load may press "Entrar". Run straight through Node's
@@ -55,25 +77,85 @@ all: build
test: test:
node --test "src/**/*.test.ts" node --test "src/**/*.test.ts"
# Call the local toolchain directly, so this works regardless of how pnpm is # Runs the built background bundle in a service-worker-shaped sandbox, which
# is where a Chrome-only breakage would otherwise hide until runtime. The
# bundle is identical for both targets, so checking one covers both.
smoke: chrome
node tools/sw-smoke.mjs $(CHROME_DIR)/background.js
# Typecheck once. Both build targets depend on it rather than each running tsc,
# which halves the work when building both.
#
# Calls the local toolchain directly, so this works regardless of how pnpm is
# provided (corepack vs standalone). Run "corepack pnpm install" first. # provided (corepack vs standalone). Run "corepack pnpm install" first.
build: check-deps typecheck: check-deps
node_modules/.bin/tsc -noEmit -skipLibCheck node_modules/.bin/tsc -noEmit -skipLibCheck
firefox: typecheck
node esbuild.config.mjs production node esbuild.config.mjs production
@echo "Firefox build: $(CURDIR)/$(FIREFOX_DIR)"
# The same sources with Chrome's manifest. Firefox and Chrome disagree on the
# background key and on the gecko block, so the manifest is generated per
# target rather than forked.
chrome: typecheck
TARGET=chrome node esbuild.config.mjs production
@echo
@echo "Chrome build: $(CURDIR)/$(CHROME_DIR)"
@echo "Load it with chrome://extensions -> Developer mode -> Load unpacked."
@echo "Select the folder itself; Chrome wants the directory holding manifest.json."
@echo
# 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.
# directly on ESR and uploads to AMO for signing. xpi: firefox
xpi: build @mkdir -p $(DIST)
rm -f $(XPI) rm -f $(XPI)
cd build && zip -qr ../$(XPI) . cd $(FIREFOX_DIR) && zip -qr $(CURDIR)/$(XPI) .
@echo "Package: $(XPI)" @echo
@echo "Install: about:addons -> gear -> Install Add-on From File" @echo "Built: $(CURDIR)/$(XPI)"
@echo "Needs xpinstall.signatures.required=false on ESR/Developer/Nightly." @echo
@echo "This file is UNSIGNED. Two ways to use it:"
@echo
@echo " Publish -- upload it at addons.mozilla.org/developers/addon/submit/"
@echo " Listed add-ons are signed once review approves them; unlisted"
@echo " ones are signed straight away."
@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
# The Chrome Web Store takes a plain zip, and does the packing into .crx itself.
crx: chrome
@mkdir -p $(DIST)
rm -f $(CRX)
cd $(CHROME_DIR) && zip -qr $(CURDIR)/$(CRX) .
@echo
@echo "Built: $(CURDIR)/$(CRX)"
@echo "Upload it at chrome.google.com/webstore/devconsole"
@echo
# Both packages. Order no longer matters: each target has its own directory.
packages: xpi crx
# Removes everything the build produces, including packages from earlier
# versions, whose filenames carry their own version number and so are never
# overwritten by a later build. dist/ is tracked in git, so a clean shows the
# packages as deleted until the next "make packages" puts them back.
clean: clean:
rm -rf build rm -rf build $(DIST)
rm -f $(EXT_ID)-*.xpi $(EXT_ID)-*.zip rm -f $(EXT_ID)-*.xpi $(EXT_ID)-*.zip
find . -name '*.map' -not -path './node_modules/*' -delete
@echo "Removed build/, $(DIST)/ and any stray packages."
# Everything clean removes, plus the installed dependencies. Recover with
# "corepack pnpm install" -- never with npm, see the note in README.md.
distclean: clean
rm -rf node_modules
@echo "Removed node_modules/. Run: corepack pnpm install"
# Fail with a useful message rather than a confusing "tsc: not found". # Fail with a useful message rather than a confusing "tsc: not found".
check-deps: check-deps:

243
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 # bundle and package dist/logsdu-<version>-firefox.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,91 +86,140 @@ 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 ```
`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build make chrome # unpacked Chrome build in build/chrome/
should be a manifest question rather than a rewrite. It has not been tried, so make crx # package it as dist/logsdu-<version>-chrome.zip
do not assume it works. ```
Load `build/chrome/` via `chrome://extensions` -> Developer mode -> **Load
unpacked** (select the folder itself), or upload the zip at
[the Web Store dashboard](https://chrome.google.com/webstore/devconsole).
One codebase, two manifests. Chrome MV3 requires a background *service worker*
and rejects Firefox's event-page `background.scripts`; Firefox needs the gecko
block that Chrome has no use for. `esbuild.config.mjs` writes the right manifest
per target, so the port is a build flag rather than a fork. Everything else --
the `chrome.*` namespace, MV3, the permission model -- is shared.
Two things that differ in practice, both handled:
- **Icons must be raster.** Chrome does not accept SVG in `icons`, so the PNGs
in `icons/` are generated from `logsdu.svg` and both browsers use those.
- **Service workers have no `window` or `document`.** A stray reference through
a shared import would break Chrome only, silently, at runtime. `make smoke`
runs the built background bundle in a worker-shaped sandbox to catch that.
What has been verified: Chrome 151 loads the build without errors, and the
background bundle registers exactly one content script for exactly the
configured origin. What has **not** been verified is a real login against a live
portal in Chrome.
A caveat that applies to both browsers: the page-world filler is injected as a
`<script src>` tag, which a site's Content-Security-Policy can refuse. Portals
that send no CSP -- the common case for this kind of form -- are unaffected. A
portal that does would need the filler registered as a `MAIN` world content
script instead.
## Development ## Development
``` ```
corepack pnpm install corepack pnpm install
make # everything: build and package both browsers
make firefox # build Firefox only -> build/firefox/
make chrome # build Chrome only -> build/chrome/
make test # unit tests make test # unit tests
make # typecheck and bundle into build/ make smoke # background bundle under a service worker
corepack pnpm run dev # rebuild on change corepack pnpm run dev # rebuild Firefox on change
make clean make clean # remove build/, dist/ and stray packages
make distclean # clean, plus node_modules/
``` ```
While iterating, reinstalling an `.xpi` for every edit is tedious. Load the A bare `make` typechecks once, bundles for both browsers into `build/`, and
unpacked directory instead: `about:debugging` -> **This Firefox** -> **Load packages both into `dist/`. Packaging is only a zip of a directory that was
Temporary Add-on** -> `build/manifest.json`, then press **Reload** there after going to be built anyway, so it costs nothing and keeps `dist/` from drifting
each rebuild. That copy disappears on restart, which is the point -- it is for out of step with the sources.
development, not for daily use.
`corepack pnpm run dev` watches and rebuilds, static files included, but Each browser gets its own directory under `build/`, so the two can coexist and
Firefox still needs the Reload click to pick anything up. neither is ever left stale by the other. `dist/` holds the packages meant to be
published, and is kept out of `build/` so that packaging never tries to include
its own output.
While iterating, load the unpacked directory rather than reinstalling an `.xpi`
each time: `about:debugging` -> **This Firefox** -> **Load Temporary Add-on** ->
`build/firefox/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.
### 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 |
| `tools/sw-smoke.mjs` | Checks the background bundle survives a service worker |
### 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.3.1

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.
@@ -35,24 +35,54 @@ const banner = `/*
`; `;
const prod = process.argv[2] === 'production'; const prod = process.argv[2] === 'production';
const outdir = 'build';
// Everything that is not TypeScript is copied verbatim into build/, so that // Target browser. Firefox and Chrome disagree on exactly one manifest key, so
// the directory can be handed straight to about:debugging. // the manifest is written per target rather than duplicated in the tree.
const target = process.env.TARGET === 'chrome' ? 'chrome' : 'firefox';
// Each target gets its own directory. They used to share one, and the result
// was that whichever build ran last silently won: loading the other browser's
// output then failed with a confusing manifest error. Separate directories
// mean both can exist at once and neither can be stale by accident.
const outdir = `build/${target}`;
/**
* 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 the output, so
// that the directory can be handed straight to about:debugging or to Chrome's
// "Load unpacked".
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();
// In watch mode the static files must follow every rebuild, otherwise editing // In watch mode the static files must follow every rebuild, otherwise editing
// manifest.json or a .html file silently changes nothing in build/. // manifest.json or a .html file silently changes nothing in the output.
const staticPlugin = { const staticPlugin = {
name: 'copy-static', name: 'copy-static',
setup(build) { setup(build) {
@@ -65,10 +95,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 +112,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
icons/logsdu-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
icons/logsdu-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

BIN
icons/logsdu-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

BIN
icons/logsdu-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
icons/logsdu-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,6 +1,6 @@
{ {
"name": "logsdu", "name": "logsdu",
"version": "0.1.0", "version": "0.3.1",
"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",

107
src/background.ts Normal file
View File

@@ -0,0 +1,107 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
// Decides where the content script is allowed to run, at runtime.
//
// The manifest declares no content script and no host permission at all, so a
// fresh install can read nothing. The single site the user configures is
// granted through permissions.request() from the options page, and only then
// does this register the content script against that one origin. Removing the
// permission, or clearing the address, unregisters it again.
//
// The alternative was a manifest matching every URL with the script bailing
// out on the wrong origin. That works, but it means holding read access to
// every page the user visits in order to act on one of them.
import { isConfigured, loadConfig, sitePattern } from './config.ts';
const SCRIPT_ID = 'logsdu-portal';
async function unregister(): Promise<void> {
const existing = await chrome.scripting.getRegisteredContentScripts({
ids: [SCRIPT_ID],
});
if (existing.length > 0) {
await chrome.scripting.unregisterContentScripts({ ids: [SCRIPT_ID] });
}
}
/**
* Bring the registered script in line with the stored config.
*
* Called on install, on startup, whenever the config changes, and whenever a
* host permission is revoked. It always tears down first and rebuilds, which
* is cheap and avoids reasoning about the previous state.
*/
async function sync(): Promise<void> {
await unregister();
const config = await loadConfig();
const pattern = sitePattern(config);
if (!isConfigured(config) || pattern === null) return;
// Registering without the host permission throws, and the user is free to
// revoke it from about:addons at any time.
const granted = await chrome.permissions.contains({ origins: [pattern] });
if (!granted) return;
await chrome.scripting.registerContentScripts([
{
id: SCRIPT_ID,
matches: [pattern],
js: ['content.js'],
runAt: 'document_idle',
allFrames: false,
// Firefox keeps registrations across restarts, so without this the
// script would be registered twice on the next startup.
persistAcrossSessions: false,
},
]);
}
function resync(): void {
void sync().catch((error: unknown) => {
console.warn('logsdu: could not register content script:', error);
});
}
chrome.runtime.onInstalled.addListener(resync);
chrome.runtime.onStartup.addListener(resync);
chrome.permissions.onRemoved.addListener(resync);
chrome.storage.onChanged.addListener((changes, area) => {
// Only the address matters here. Ignore the state key, which is written on
// every login attempt and would otherwise re-register constantly.
if (area === 'local' && 'config' in changes) resync();
});
// The event page is also woken by the options page after a successful
// permission request, so that the script starts working without a restart.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if ((message as { type?: string })?.type !== 'resync') return undefined;
void sync().then(
() => sendResponse({ ok: true }),
(error: unknown) => sendResponse({ ok: false, error: String(error) }),
);
return true;
});
resync();

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

@@ -20,6 +20,8 @@
// ************************************************************************* // *************************************************************************
import { toOrigin } from './format.ts'; import { toOrigin } from './format.ts';
import { DEFAULT_SELECTORS } from './portal.ts';
import type { Selectors } from './portal.ts';
const CONFIG_KEY = 'config'; const CONFIG_KEY = 'config';
const STATE_KEY = 'state'; const STATE_KEY = 'state';
@@ -45,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. */
@@ -59,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> {
@@ -112,6 +135,18 @@ export function matchesSite(config: Config, href: string): boolean {
} }
} }
/**
* The configured site as a match pattern, for permissions.request() and for
* scripting.registerContentScripts(). Null when the address is unusable.
*
* The pattern is pinned to one origin -- scheme, host and port -- so granting
* it never widens beyond the single site the user typed.
*/
export function sitePattern(config: Config): string | null {
const origin = toOrigin(config.url);
return origin === null ? null : `${origin}/*`;
}
export type SubmitDecision = export type SubmitDecision =
| { submit: true } | { submit: true }
| { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' }; | { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' };

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,11 +1,16 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "logsdu", "name": "logsdu",
"version": "0.1.0", "version": "0.3.1",
"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", "16": "icons/logsdu-16.png",
"96": "icons/logsdu.svg" "32": "icons/logsdu-32.png",
"48": "icons/logsdu-48.png",
"96": "icons/logsdu-96.png",
"128": "icons/logsdu-128.png"
}, },
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
@@ -16,16 +21,11 @@
} }
} }
}, },
"permissions": ["storage"], "permissions": ["storage", "scripting"],
"host_permissions": ["*://*/*"], "optional_host_permissions": ["*://*/*"],
"content_scripts": [ "background": {
{ "scripts": ["background.js"]
"matches": ["*://*/*"], },
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["injected.js"], "resources": ["injected.js"],
@@ -39,6 +39,9 @@
"action": { "action": {
"default_popup": "popup.html", "default_popup": "popup.html",
"default_title": "logsdu", "default_title": "logsdu",
"default_icon": "icons/logsdu.svg" "default_icon": {
"16": "icons/logsdu-16.png",
"32": "icons/logsdu-32.png"
}
} }
} }

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(),
};
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(); el<HTMLButtonElement>('defaults').addEventListener('click', () => {
setStatus('Salvo.'); 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';

View File

@@ -19,41 +19,71 @@
// * rcb@beco.cc * // * rcb@beco.cc *
// ************************************************************************* // *************************************************************************
// Every selector the extension knows about the target page, in one place. // How to find the login form on a page.
// //
// These describe a form shape, not a site: the address itself is configured // These are defaults, not constants. They describe the shape of one common
// at runtime and lives only in local storage, so nothing here names the // portal, and every one of them can be overridden per installation from the
// institution. // options page, so the extension is not silently tied to a single institution
// it never names.
/** export interface Selectors {
* The login form. Every field lookup is scoped to it on purpose. /**
* * The login form. Every field lookup is scoped to it on purpose.
* The same page carries a "forgot your registration number" modal whose *
* inputs are id="CPF" and id="DTNASC". The three login inputs have no id at * Portals of this kind routinely carry a second "forgot your registration
* all, so an unscoped lookup for a CPF field finds the modal's copy and * number" form in a modal on the same page, using the same field names or
* writes the value into the wrong form. * ids. An unscoped lookup finds that copy and writes into the wrong form.
*/ */
export const FORM = 'form[name="LoginAP"]'; form: string;
/** Registration number or code, relative to the form. */
ra: string;
/** Birth date, relative to the form. */
dn: string;
/** Document number, relative to the form. */
cpf: string;
/** The button that submits the login, relative to the form. */
submit: string;
/**
* The logout control on the pages behind the login. Clicking it is the
* signal that the user wants to stay out, which suppresses the auto-submit
* that would otherwise fire when the logout redirect lands back here.
*/
logout: string;
}
/** The three credential inputs, relative to FORM. */ export const DEFAULT_SELECTORS: Selectors = {
export const FIELDS = { form: 'form[name="LoginAP"]',
ra: 'input[name="RA"]', ra: 'input[name="RA"]',
dn: 'input[name="DN"]', dn: 'input[name="DN"]',
cpf: 'input[name="CPF"]', cpf: 'input[name="CPF"]',
} as const; submit: 'button[type="submit"]',
logout: '.js_logout',
};
/** The "Entrar" button, relative to FORM. */ export const SELECTOR_KEYS = [
export const SUBMIT = 'button[type="submit"]'; 'form',
'ra',
'dn',
'cpf',
'submit',
'logout',
] as const;
/** /**
* The logout control, present on the pages behind the login. Clicking it is * Reject anything the browser cannot parse as a selector, so a typo in the
* the signal that the user wants to stay logged out, which suppresses the * options page fails there instead of silently never matching a page.
* auto-submit that would otherwise fire the moment the logout redirect lands
* back on the login page.
*/ */
export const LOGOUT = '.js_logout'; export function isValidSelector(value: string): boolean {
if (value.trim() === '') return false;
try {
document.querySelector(value);
return true;
} catch {
return false;
}
}
/** Event names used to talk to the page-world filler. */ /** Event name used to talk back from the page-world filler. */
export const EVENT_RESULT = 'logsdu:result'; export const EVENT_RESULT = 'logsdu:result';
/** Payload handed to the page-world script through its own dataset. */ /** Payload handed to the page-world script through its own dataset. */
@@ -62,6 +92,7 @@ export interface FillRequest {
dn: string; dn: string;
cpf: string; cpf: string;
submit: boolean; submit: boolean;
selectors: Selectors;
} }
export interface FillResult { export interface FillResult {

45
src/webext.d.ts vendored
View File

@@ -29,10 +29,16 @@
// rather than a source concern. // rather than a source concern.
declare namespace chrome { declare namespace chrome {
interface Event0 {
addListener(callback: () => void): void;
}
namespace runtime { namespace runtime {
const lastError: { message?: string } | undefined; const lastError: { message?: string } | undefined;
function getURL(path: string): string; function getURL(path: string): string;
function openOptionsPage(): Promise<void>; function openOptionsPage(): Promise<void>;
const onInstalled: Event0;
const onStartup: Event0;
const onMessage: { const onMessage: {
addListener( addListener(
callback: ( callback: (
@@ -51,6 +57,45 @@ declare namespace chrome {
remove(keys: string | string[]): Promise<void>; remove(keys: string | string[]): Promise<void>;
} }
const local: StorageArea; const local: StorageArea;
const onChanged: {
addListener(
callback: (
changes: Record<string, { oldValue?: unknown; newValue?: unknown }>,
areaName: string,
) => void,
): void;
};
}
namespace permissions {
interface Permissions {
origins?: string[];
permissions?: string[];
}
function request(permissions: Permissions): Promise<boolean>;
function contains(permissions: Permissions): Promise<boolean>;
function remove(permissions: Permissions): Promise<boolean>;
const onRemoved: {
addListener(callback: (permissions: Permissions) => void): void;
};
}
namespace scripting {
interface RegisteredContentScript {
id: string;
matches?: string[];
js?: string[];
runAt?: 'document_start' | 'document_end' | 'document_idle';
allFrames?: boolean;
persistAcrossSessions?: boolean;
}
function registerContentScripts(
scripts: RegisteredContentScript[],
): Promise<void>;
function getRegisteredContentScripts(filter?: {
ids?: string[];
}): Promise<RegisteredContentScript[]>;
function unregisterContentScripts(filter?: { ids?: string[] }): Promise<void>;
} }
namespace tabs { namespace tabs {

136
tools/sw-smoke.mjs Normal file
View File

@@ -0,0 +1,136 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
// Smoke test for the background bundle under Chrome's execution model.
//
// Chrome MV3 runs the background as a service worker, where there is no
// `window` and no `document`, and the global object is `self`. Firefox runs
// the same file as an event page, where those do exist -- so a reference that
// creeps in through a shared import breaks Chrome only, and breaks it silently
// at runtime rather than at build time.
//
// This evaluates the built bundle in a worker-shaped sandbox with a stubbed
// extension API, and asserts that it registers exactly one content script, for
// exactly the configured origin.
//
// make smoke
// node tools/sw-smoke.mjs [path/to/background.js]
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const BUNDLE = process.argv[2] ?? 'build/chrome/background.js';
const CONFIGURED_ORIGIN = 'https://portal.example.br';
const permissionChecks = [];
const registered = [];
const listeners = {
onInstalled: 0,
onStartup: 0,
onRemoved: 0,
onChanged: 0,
onMessage: 0,
};
const chrome = {
runtime: {
onInstalled: { addListener: () => listeners.onInstalled++ },
onStartup: { addListener: () => listeners.onStartup++ },
onMessage: { addListener: () => listeners.onMessage++ },
},
permissions: {
onRemoved: { addListener: () => listeners.onRemoved++ },
contains: async (p) => {
permissionChecks.push(p.origins);
return true;
},
},
storage: {
local: {
get: async () => ({
config: {
url: `${CONFIGURED_ORIGIN}/`,
ra: '2000101010',
dn: '01/02/1999',
cpf: '123.456.789-01',
autoSubmit: true,
},
}),
set: async () => {},
},
onChanged: { addListener: () => listeners.onChanged++ },
},
scripting: {
getRegisteredContentScripts: async () => [],
unregisterContentScripts: async () => {},
registerContentScripts: async (scripts) => registered.push(...scripts),
},
};
// Deliberately no window and no document: referencing either must fail here
// exactly as it would inside a service worker.
const sandbox = { chrome, console, setTimeout, clearTimeout, queueMicrotask, URL };
sandbox.self = sandbox;
vm.createContext(sandbox);
const failures = [];
try {
vm.runInContext(readFileSync(BUNDLE, 'utf8'), sandbox, { filename: BUNDLE });
} catch (error) {
console.error(`FAIL: ${BUNDLE} threw on evaluation: ${error.message}`);
process.exit(1);
}
// Give the top-level resync() a turn of the event loop to settle.
await new Promise((resolve) => setTimeout(resolve, 50));
for (const [name, count] of Object.entries(listeners)) {
if (count !== 1) failures.push(`${name} registered ${count} times, expected 1`);
}
if (registered.length !== 1) {
failures.push(`registered ${registered.length} content scripts, expected 1`);
} else {
const script = registered[0];
const expected = `${CONFIGURED_ORIGIN}/*`;
if (script.matches?.length !== 1 || script.matches[0] !== expected) {
failures.push(`matches ${JSON.stringify(script.matches)}, expected ["${expected}"]`);
}
if (script.js?.[0] !== 'content.js') {
failures.push(`js ${JSON.stringify(script.js)}, expected ["content.js"]`);
}
if (script.persistAcrossSessions !== false) {
failures.push('persistAcrossSessions must be false, or startup registers twice');
}
}
if (permissionChecks.length !== 1) {
failures.push(`checked permissions ${permissionChecks.length} times, expected 1`);
}
if (failures.length > 0) {
for (const failure of failures) console.error(`FAIL: ${failure}`);
process.exit(1);
}
console.log('ok - background bundle runs as a service worker');
console.log(`ok - registers content.js for ${CONFIGURED_ORIGIN}/* and nothing else`);