5 Commits
0.1.0 ... 0.3.2

25 changed files with 927 additions and 207 deletions

10
.gitignore vendored
View File

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

143
Makefile
View File

@@ -19,35 +19,58 @@
# * rcb@beco.cc *
# **************************************************************************
# Makefile for logsdu - build the unpacked extension into build/.
# Makefile for logsdu - build the extension for Firefox and Chrome.
#
# 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 xpi # build, then package build/ as logsdu-<version>.xpi
# make clean # remove build/ and the package
# make smoke # check the background bundle works as a service worker
# 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 prune # drop packages left in dist/ by earlier versions
# 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:
# corepack pnpm install
#
# Permanent install (Firefox ESR, Developer Edition or Nightly):
# set xpinstall.signatures.required=false in about:config, then
# about:addons -> gear -> Install Add-on From File -> pick the .xpi
# Load the unpacked build while developing:
# Firefox about:debugging -> This Firefox -> Load Temporary Add-on ->
# 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
# same .xpi has to go through addons.mozilla.org as an unlisted add-on first,
# which signs it automatically without publishing or reviewing it.
# Publishing:
# Firefox upload dist/*-firefox.xpi at addons.mozilla.org
# Chrome upload dist/*-chrome.zip at chrome.google.com/webstore/devconsole
#
# Throwaway install for development: about:debugging -> This Firefox ->
# Load Temporary Add-on -> pick build/manifest.json (dropped on restart).
# Every package filename names its browser. The two are not interchangeable --
# 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
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 prune 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
# says whether a page load may press "Entrar". Run straight through Node's
@@ -55,25 +78,95 @@ all: build
test:
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.
build: check-deps
typecheck: check-deps
node_modules/.bin/tsc -noEmit -skipLibCheck
firefox: typecheck
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
# Drop packages left over from earlier versions.
#
# Package names carry their version, so a bump does not overwrite the previous
# build -- without this, dist/ accumulates every version ever built and it
# becomes easy to upload the wrong file to a store. dist/ is regenerated
# output: keep signed downloads from AMO somewhere else, not here.
prune:
@mkdir -p $(DIST)
@find $(DIST) -maxdepth 1 -type f \( -name '$(EXT_ID)-*.xpi' -o -name '$(EXT_ID)-*.zip' \) \
! -name '$(notdir $(XPI))' ! -name '$(notdir $(CRX))' \
-print -delete | sed 's/^/removed stale package: /'
# An .xpi is just a zip of the extension directory, with the manifest at the
# top level rather than inside a wrapper folder. The same file installs
# directly on ESR and uploads to AMO for signing.
xpi: build
# top level rather than inside a wrapper folder.
xpi: firefox prune
rm -f $(XPI)
cd build && zip -qr ../$(XPI) .
@echo "Package: $(XPI)"
@echo "Install: about:addons -> gear -> Install Add-on From File"
@echo "Needs xpinstall.signatures.required=false on ESR/Developer/Nightly."
cd $(FIREFOX_DIR) && zip -qr $(CURDIR)/$(XPI) .
@echo
@echo "Built: $(CURDIR)/$(XPI)"
@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 prune
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:
rm -rf build
rm -rf build $(DIST)
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".
check-deps:

243
README.md
View File

@@ -1,67 +1,81 @@
# logsdu
A Firefox extension that logs you into an academic portal whose sign-in form is
three fields -- registration number, birth date and national ID -- rather than
the usual user and password.
A Firefox extension for logins that password managers cannot save: the ones
made of a registration number, a date of birth and a document number, instead
of a username and a password.
That shape defeats password managers. The form is marked `autocomplete="off"`,
none of the three inputs is a `password` field, and Bitwarden, Firefox and
Chrome all decline to remember it. Since the values never change and the
institution does not let you pick a different login method, the only option
left is copying three values by hand, every time.
Academic portals do this a lot. The form is usually marked `autocomplete="off"`,
none of the fields is a `password` field, and Bitwarden, Firefox and Chrome all
decline to remember it. When the values never change and the institution offers
no other way in, the only option left is copying three values by hand, every
single time -- including the registration number nobody has memorised.
logsdu stores them once and fills them in. By default it also presses "Entrar",
so the normal case is zero clicks.
logsdu stores them once and fills them in. By default it also presses the submit
button, so the normal case is zero clicks.
## Install
Dependencies are managed with **pnpm**, never npm (see "Why pnpm" below).
Node 22+ ships `corepack`, so pnpm does not have to be installed globally.
Once it is published, from addons.mozilla.org. Until then, build it yourself:
```
corepack pnpm install # first time, or after a dependency change
make xpi # typecheck, bundle, and package logsdu-<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
you tell it to, and only the ESR, Developer Edition and Nightly builds accept
being told:
`make xpi` prints the full path of the file and how to install it. The package
it produces is unsigned, and Firefox only accepts unsigned add-ons on the ESR,
Developer Edition and Nightly builds, after setting
`xpinstall.signatures.required` to `false` in `about:config`. On release Firefox
the file has to be signed by Mozilla first -- see "Publishing".
1. Open `about:config`, accept the warning
2. Set `xpinstall.signatures.required` to **false**
3. Open `about:addons`, click the **gear** icon, choose **Install Add-on From
File**, and pick `logsdu-<version>.xpi`
Dependencies are managed with **pnpm**, never npm. See "Why pnpm" below.
It survives restarts. On **release** Firefox that pref is ignored -- see
"Release Firefox" below.
## Setting it up
Finally, open the extension's options page, fill in the four values, and save.
## The four values
Open the extension's options page and fill in four values:
| Field | Example | Notes |
| --- | --- | --- |
| Portal address | `https://portal.example.br/` | Only the origin matters; the path is ignored |
| Registration number | `2000101010` | Digits only |
| Birth date | `01/01/2000` | Reformatted as you type |
| National ID | `000.000.000-00` | Reformatted as you type |
| Date of birth | `01/01/2000` | Reformatted as you type |
| Document number | `000.000.000-00` | Reformatted as you type |
Paste raw digits if you like -- the options page inserts the separators, because
the portal's input masks expect the values in exactly that shape.
input masks on these forms expect the values in exactly that shape.
**The portal address is configuration, not code.** No institution is named
anywhere in the extension: not in the manifest, not in the source, not in the
build output. Be clear about what that does and does not buy you. It stops
someone who reads the extension from learning which portal it is for. It does
not hide anything from someone who can read the extension's storage -- and that
is the same access that would expose your national ID and birth date anyway.
When you press Save, Firefox asks whether logsdu may access the address you
entered. That prompt names one site. Accept it and the extension starts working
there; decline and nothing is stored as usable.
If the portal's markup differs from the common shape, open **Ajustes avançados**
in the options page and adjust the CSS selectors. Invalid selectors are rejected
on save rather than failing silently later.
## What it can access
Nothing, until you say so.
The manifest requests **no host permissions at all**. There is no content script
declared against any site. When you save an address, the extension asks for that
single origin through `permissions.request()`, and a background script then
registers the content script for that one origin and no other.
That means a fresh install can read no pages, the permission prompt names one
site, and you can revoke it whenever you like in `about:addons` -> Permissions.
Clearing your data in the options page hands the permission back automatically.
The `optional_host_permissions` entry in the manifest is `*://*/*`, because the
address is not known until you type it. It is the set the extension may *ask*
from, not what it holds -- nothing is granted without your click, and what is
granted is one origin.
## How it behaves
- **Fills and submits** on the login page, with no interaction.
- **At most one automatic submit per hour.** After an attempt, the extension
drops back to filling only, so a wrong value cannot resubmit itself on every
page load and lock you out. Correct the values and save; saving clears the
- **At most one automatic submit per hour.** After an attempt it drops back to
filling only, so a wrong value cannot resubmit itself on every page load and
lock you out of your account. Correct the values and save; saving clears the
timer, so the next visit tries again immediately.
- **Logging out keeps you logged out.** Clicking the portal's logout control
suppresses the automatic submit for five minutes -- otherwise the logout
@@ -72,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.
- Automatic submission can be turned off entirely in the options.
## How the values are stored
## Where your data goes
In `storage.local`: private to this browser profile, never synced, never sent
anywhere. That is the same protection a browser-saved password gets, and it has
the same limit -- anyone with your unlocked account can read it. If that is not
good enough for your threat model, this extension is the wrong tool.
Nowhere. It is written to `storage.local`: private to your browser profile,
never synced, never transmitted. The extension makes no network requests of its
own and contains no analytics.
## Release Firefox
Be clear about the limit, though. This is the same protection a browser-saved
password gets, and it has the same weakness -- anyone with your unlocked
computer can read it. If you need protection at rest, this is the wrong tool.
`xpinstall.signatures.required` only works on ESR, Developer Edition and
Nightly. Release Firefox ignores it and refuses unsigned add-ons outright, so
there the same `.xpi` has to be signed first: upload it to
[addons.mozilla.org](https://addons.mozilla.org) as an **unlisted** add-on.
Signing is automated -- nothing is published publicly or reviewed by hand -- and
you install the signed file it hands back.
The interface is in Portuguese, matching the portals it was written for.
The `browser_specific_settings.gecko.id` in the manifest is what gives the
add-on a stable identity across both routes, so settings survive an upgrade
from one to the other.
## Publishing
`make xpi` produces the file to upload at
[addons.mozilla.org](https://addons.mozilla.org/developers/addon/submit/).
Two distribution choices:
- **Listed** -- public on addons.mozilla.org, searchable, installable by anyone,
and updates are delivered by Mozilla automatically.
- **Unlisted** -- signed but not published. You distribute the signed file
yourself. Updates need a self-hosted update manifest, or resending the file.
Builds are never minified, which is deliberate: AMO requires a separate
source-code submission for any add-on whose uploaded code is machine-generated,
and that obligation would apply to every future release. The whole extension is
about 33 KB, so the saving would not pay for the process, and readable code is
easier for a reviewer -- or anyone auditing what handles their credentials -- to
check.
## Chrome
Not yet. The source deliberately avoids anything Firefox-specific: it uses the
`chrome.*` namespace, Manifest V3, and no APIs Chrome lacks, so a Chrome build
should be a manifest question rather than a rewrite. It has not been tried, so
do not assume it works.
```
make chrome # unpacked Chrome build in build/chrome/
make crx # package it as dist/logsdu-<version>-chrome.zip
```
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
```
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 # typecheck and bundle into build/
corepack pnpm run dev # rebuild on change
make clean
make smoke # background bundle under a service worker
corepack pnpm run dev # rebuild Firefox on change
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
unpacked directory instead: `about:debugging` -> **This Firefox** -> **Load
Temporary Add-on** -> `build/manifest.json`, then press **Reload** there after
each rebuild. That copy disappears on restart, which is the point -- it is for
development, not for daily use.
A bare `make` typechecks once, bundles for both browsers into `build/`, and
packages both into `dist/`. Packaging is only a zip of a directory that was
going to be built anyway, so it costs nothing and keeps `dist/` from drifting
out of step with the sources.
`corepack pnpm run dev` watches and rebuilds, static files included, but
Firefox still needs the Reload click to pick anything up.
Each browser gets its own directory under `build/`, so the two can coexist and
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
| Path | Role |
| --- | --- |
| `src/manifest.json` | MV3 manifest. Names no site |
| `src/manifest.json` | MV3 manifest. Requests no host access |
| `src/background.ts` | Registers the content script for the granted origin |
| `src/content.ts` | Isolated world. Decides whether to act, then delegates |
| `src/injected.ts` | Page world. Does the actual filling and clicking |
| `src/portal.ts` | Every selector the extension knows about the form |
| `src/portal.ts` | Default selectors and the shape of a fill request |
| `src/config.ts` | Stored values, the rate limit and the logout cooldown |
| `src/format.ts` | Input normalisers for the three masked fields |
| `src/options.*`, `src/popup.*` | The two bits of UI |
| `tools/sw-smoke.mjs` | Checks the background bundle survives a service worker |
### Why two scripts instead of one
The portal drives its inputs with Inputmask, which replaces each element's
`value` property with its own accessor. A content script assigning
`input.value` from the isolated world writes through Xrays to the *native*
setter and skips that accessor: the field looks right on screen, but the mask's
buffer is unchanged, and the page's submit handler reads the stale buffer back
out through jQuery. So the filling happens inside the page, through the page's
own jQuery and Inputmask.
These portals drive their inputs with Inputmask, which replaces each element's
`value` property with its own accessor. A content script assigning `input.value`
from the isolated world writes through Xrays to the *native* setter and skips
that accessor: the field looks right on screen, but the mask's buffer is
unchanged, and the page's submit handler reads the stale buffer back out through
jQuery. So the filling happens inside the page, through the page's own jQuery
and Inputmask, in `injected.ts`.
Submitting is a real click on the button, never `form.submit()`. The portal
intercepts the submit event, cancels it, and posts by AJAX with a CSRF token
taken from a meta tag. `form.submit()` would bypass that handler and lose the
token; a click reproduces exactly what a person pressing "Entrar" does.
Submitting is a real click on the button, never `form.submit()`. These portals
intercept the submit event, cancel it, and post by AJAX with a CSRF token taken
from a meta tag. `form.submit()` would bypass that handler and lose the token; a
click reproduces exactly what a person pressing the button does.
### Why the content script matches every URL
### Why the field selectors are configurable
The address is configured at runtime, so it cannot also be a manifest match
pattern -- putting it there is exactly what would name the institution in the
shipped code. The script therefore loads everywhere and stops immediately
unless the page's origin equals the configured one. Origins are compared whole,
so `portal.example.br.evil.tld` does not match.
The honest cost: the extension holds read access to every page you visit. The
alternative -- registering the content script at runtime with
`scripting.registerContentScripts` and an optional host permission -- avoids
that at the price of a background script and a permission prompt.
Hardcoding them would tie the extension to one institution while pretending to
be general. They live in `portal.ts` as defaults and can be overridden per
installation, so the same build works for any portal of this shape -- and no
institution is named anywhere in the source, the manifest or the build output.
### Why pnpm, not npm

View File

@@ -1 +1 @@
0.1.0
0.3.2

BIN
dist/logsdu-0.3.2-chrome.zip vendored Normal file

Binary file not shown.

BIN
dist/logsdu-0.3.2-firefox.xpi vendored Normal file

Binary file not shown.

View File

@@ -21,7 +21,7 @@
import esbuild from 'esbuild';
import process from 'process';
import { cp, mkdir, readdir } from 'node:fs/promises';
import { cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
const banner = `/*
* logsdu - fills and submits a three-field academic portal login.
@@ -35,24 +35,54 @@ const banner = `/*
`;
const prod = process.argv[2] === 'production';
const outdir = 'build';
// Everything that is not TypeScript is copied verbatim into build/, so that
// the directory can be handed straight to about:debugging.
// 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';
// 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() {
await mkdir(outdir, { recursive: true });
for (const name of await readdir('src')) {
if (name.endsWith('.json') || name.endsWith('.html') || name.endsWith('.css')) {
if (name.endsWith('.html') || name.endsWith('.css')) {
await cp(`src/${name}`, `${outdir}/${name}`);
}
}
await cp('icons', `${outdir}/icons`, { recursive: true });
await writeManifest();
}
await copyStatic();
// 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 = {
name: 'copy-static',
setup(build) {
@@ -65,10 +95,11 @@ const context = await esbuild.context({
js: banner,
},
plugins: [staticPlugin],
// Four independent entry points: no shared runtime, no imports at load
// Five independent entry points: no shared runtime, no imports at load
// time. Content scripts and page-world scripts cannot be ES modules, so
// every bundle has to stand alone.
entryPoints: [
'src/background.ts',
'src/content.ts',
'src/injected.ts',
'src/options.ts',
@@ -81,7 +112,14 @@ const context = await esbuild.context({
sourcemap: prod ? false : 'inline',
treeShaking: true,
outdir,
minify: prod,
// Deliberately never minified. addons.mozilla.org requires a separate
// source-code submission for any add-on whose uploaded code is minified or
// otherwise machine-generated, and that obligation would apply to every
// release from now on. The whole extension is a few tens of kilobytes, so
// the saving would not pay for the process, and shipping readable code
// makes the review -- and anyone auditing what handles their credentials --
// straightforward.
minify: false,
});
if (prod) {

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",
"version": "0.1.0",
"version": "0.3.2",
"description": "Browser extension that fills and submits a three-field academic portal login.",
"author": "Ruben Carlo Benante <rcb@beco.cc>",
"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,
isConfigured,
matchesSite,
sitePattern,
withDefaults,
} from './config.ts';
import type { Config, State } from './config.ts';
import { DEFAULT_SELECTORS } from './portal.ts';
const CONFIG: Config = {
url: 'https://portal.example.br/',
@@ -37,6 +40,7 @@ const CONFIG: Config = {
dn: '01/02/1999',
cpf: '123.456.789-01',
autoSubmit: true,
selectors: { ...DEFAULT_SELECTORS },
};
const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 };
@@ -51,6 +55,42 @@ test('isConfigured requires all four values', () => {
assert.equal(isConfigured({ ...CONFIG, cpf: '' }), false);
});
test('sitePattern pins the grant to one origin', () => {
assert.equal(sitePattern(CONFIG), 'https://portal.example.br/*');
assert.equal(
sitePattern({ ...CONFIG, url: 'https://portal.example.br/login?a=1' }),
'https://portal.example.br/*',
'path and query must not widen or narrow the grant',
);
assert.equal(sitePattern({ ...CONFIG, url: '' }), null);
});
test('withDefaults fills selectors a stored config never had', () => {
// A config written by 0.1.x has no selectors key at all. It must come back
// complete, not half-built, or every lookup silently becomes undefined.
const upgraded = withDefaults({
url: 'https://portal.example.br/',
ra: '1',
dn: '01/02/1999',
cpf: '123.456.789-01',
});
assert.deepEqual(upgraded.selectors, DEFAULT_SELECTORS);
assert.equal(upgraded.autoSubmit, true);
});
test('withDefaults keeps a partial selector override and backfills the rest', () => {
const custom = withDefaults({ selectors: { form: '#login' } as never });
assert.equal(custom.selectors.form, '#login');
assert.equal(custom.selectors.ra, DEFAULT_SELECTORS.ra);
assert.equal(custom.selectors.logout, DEFAULT_SELECTORS.logout);
});
test('withDefaults on nothing stored is the empty config', () => {
const empty = withDefaults(undefined);
assert.equal(empty.url, '');
assert.deepEqual(empty.selectors, DEFAULT_SELECTORS);
});
test('matchesSite compares origins, not prefixes', () => {
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true);
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true);

View File

@@ -20,6 +20,8 @@
// *************************************************************************
import { toOrigin } from './format.ts';
import { DEFAULT_SELECTORS } from './portal.ts';
import type { Selectors } from './portal.ts';
const CONFIG_KEY = 'config';
const STATE_KEY = 'state';
@@ -45,6 +47,11 @@ export interface Config {
dn: string;
cpf: string;
autoSubmit: boolean;
/**
* How to find the form on that site. Defaulted, and adjustable from the
* options page for portals whose markup differs.
*/
selectors: Selectors;
}
/** Bookkeeping that enforces the rate limit and the logout cooldown. */
@@ -59,13 +66,29 @@ export const EMPTY_CONFIG: Config = {
dn: '',
cpf: '',
autoSubmit: true,
selectors: { ...DEFAULT_SELECTORS },
};
const EMPTY_STATE: State = { lastSubmitAt: 0, logoutAt: 0 };
/**
* Merge stored values over the defaults.
*
* Selectors are merged one level deeper than the rest: a config saved by an
* older version, or one that only overrides the form selector, must still come
* back with every key present rather than a half-built object.
*/
export function withDefaults(stored: Partial<Config> | undefined): Config {
return {
...EMPTY_CONFIG,
...(stored ?? {}),
selectors: { ...DEFAULT_SELECTORS, ...(stored?.selectors ?? {}) },
};
}
export async function loadConfig(): Promise<Config> {
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> {
@@ -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 =
| { submit: true }
| { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' };

View File

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

View File

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

View File

@@ -1,11 +1,16 @@
{
"manifest_version": 3,
"name": "logsdu",
"version": "0.1.0",
"description": "Fills and submits a three-field academic portal login.",
"version": "0.3.2",
"description": "Saves and fills logins that password managers cannot: registration number, date of birth and document number.",
"author": "Ruben Carlo Benante (Dr. Beco)",
"homepage_url": "https://code.beco.cc/beco/logsdu",
"icons": {
"48": "icons/logsdu.svg",
"96": "icons/logsdu.svg"
"16": "icons/logsdu-16.png",
"32": "icons/logsdu-32.png",
"48": "icons/logsdu-48.png",
"96": "icons/logsdu-96.png",
"128": "icons/logsdu-128.png"
},
"browser_specific_settings": {
"gecko": {
@@ -16,16 +21,11 @@
}
}
},
"permissions": ["storage"],
"host_permissions": ["*://*/*"],
"content_scripts": [
{
"matches": ["*://*/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"permissions": ["storage", "scripting"],
"optional_host_permissions": ["*://*/*"],
"background": {
"scripts": ["background.js"]
},
"web_accessible_resources": [
{
"resources": ["injected.js"],
@@ -39,6 +39,9 @@
"action": {
"default_popup": "popup.html",
"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>
</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">
<button id="save" type="submit">Salvar</button>
<button id="clear" type="button" class="ghost">Apagar dados</button>
@@ -46,10 +79,18 @@
</div>
</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>
<p class="hint">
Os quatro valores ficam na memória local desta extensão, neste perfil
do navegador. Não são sincronizados nem enviados para lugar nenhum. A
Os valores ficam na memória local desta extensão, neste perfil do
navegador. Não são sincronizados nem enviados para lugar nenhum. A
proteção é a mesma de uma senha guardada no navegador: quem tiver a
sua sessão do sistema aberta consegue lê-los.
</p>

View File

@@ -19,7 +19,13 @@
// * rcb@beco.cc *
// *************************************************************************
import { EMPTY_CONFIG, loadConfig, resetState, saveConfig } from './config.ts';
import {
EMPTY_CONFIG,
loadConfig,
resetState,
saveConfig,
sitePattern,
} from './config.ts';
import {
formatCpf,
formatDate,
@@ -29,6 +35,8 @@ import {
isCompleteRa,
toOrigin,
} from './format.ts';
import { DEFAULT_SELECTORS, SELECTOR_KEYS, isValidSelector } from './portal.ts';
import type { Selectors } from './portal.ts';
function el<T extends HTMLElement>(id: string): T {
const found = document.getElementById(id);
@@ -46,6 +54,20 @@ const autoSubmit = el<HTMLInputElement>('autoSubmit');
const status = el<HTMLSpanElement>('status');
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 {
status.textContent = message;
status.classList.toggle('error', isError);
@@ -79,6 +101,7 @@ async function load(): Promise<void> {
fields.dn.value = config.dn;
fields.cpf.value = config.cpf;
autoSubmit.checked = config.autoSubmit;
writeSelectors(config.selectors);
}
/** Mark the offending inputs and return the first complaint, if any. */
@@ -102,6 +125,18 @@ function validate(): string | null {
fields.cpf.classList.add('invalid');
return 'CPF incompleto.';
}
// A selector that the browser cannot parse would never match anything, and
// the failure would show up much later as "nothing happened" on the portal.
for (const key of SELECTOR_KEYS) {
const input = selectorFields[key];
input.classList.remove('invalid');
if (!isValidSelector(input.value)) {
input.classList.add('invalid');
el<HTMLDetailsElement>('advanced').open = true;
return 'Seletor CSS inválido nos ajustes avançados.';
}
}
return null;
}
@@ -113,25 +148,58 @@ form.addEventListener('submit', (event) => {
return;
}
void (async () => {
await saveConfig({
const config = {
url: fields.url.value.trim(),
ra: fields.ra.value,
dn: fields.dn.value,
cpf: fields.cpf.value,
autoSubmit: autoSubmit.checked,
});
// 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.
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);
});
});
el<HTMLButtonElement>('defaults').addEventListener('click', () => {
writeSelectors(DEFAULT_SELECTORS);
setStatus('Padrões restaurados. Salve para aplicar.');
});
el<HTMLButtonElement>('clear').addEventListener('click', () => {
void (async () => {
await saveConfig({ ...EMPTY_CONFIG });
const previous = sitePattern(await loadConfig());
await saveConfig({ ...EMPTY_CONFIG, selectors: { ...DEFAULT_SELECTORS } });
await resetState();
// Hand the site permission back as well. Leaving it granted after the
// user has wiped their data would keep access they no longer use.
if (previous !== null) {
await chrome.permissions.remove({ origins: [previous] }).catch(() => false);
}
await load();
setStatus('Dados apagados.');
})();

View File

@@ -23,7 +23,7 @@
// the automatic submit is switched off, rate limited, or suppressed after a
// logout.
import { isConfigured, loadConfig, matchesSite } from './config.ts';
import { isConfigured, loadConfig, matchesSite, sitePattern } from './config.ts';
const state = document.getElementById('state') as HTMLParagraphElement;
const detail = document.getElementById('detail') as HTMLParagraphElement;
@@ -50,6 +50,18 @@ async function refresh(): Promise<void> {
return;
}
// Configured but not permitted is its own state, and the one most likely to
// look like a bug: everything is filled in, yet nothing ever happens.
const pattern = sitePattern(config);
const granted =
pattern !== null && (await chrome.permissions.contains({ origins: [pattern] }));
if (!granted) {
state.textContent = 'Sem permissão';
detail.textContent =
'A extensão ainda não tem acesso ao site. Abra as opções e salve novamente para conceder.';
return;
}
const tabId = await activeTabOnSite();
if (tabId === null) {
state.textContent = 'Configurado';

View File

@@ -19,41 +19,71 @@
// * 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
// at runtime and lives only in local storage, so nothing here names the
// institution.
// These are defaults, not constants. They describe the shape of one common
// portal, and every one of them can be overridden per installation from the
// 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 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
* all, so an unscoped lookup for a CPF field finds the modal's copy and
* writes the value into the wrong form.
* Portals of this kind routinely carry a second "forgot your registration
* number" form in a modal on the same page, using the same field names or
* 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 FIELDS = {
export const DEFAULT_SELECTORS: Selectors = {
form: 'form[name="LoginAP"]',
ra: 'input[name="RA"]',
dn: 'input[name="DN"]',
cpf: 'input[name="CPF"]',
} as const;
submit: 'button[type="submit"]',
logout: '.js_logout',
};
/** The "Entrar" button, relative to FORM. */
export const SUBMIT = 'button[type="submit"]';
export const SELECTOR_KEYS = [
'form',
'ra',
'dn',
'cpf',
'submit',
'logout',
] as const;
/**
* The logout control, present on the pages behind the login. Clicking it is
* the signal that the user wants to stay logged out, which suppresses the
* auto-submit that would otherwise fire the moment the logout redirect lands
* back on the login page.
* Reject anything the browser cannot parse as a selector, so a typo in the
* options page fails there instead of silently never matching a 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';
/** Payload handed to the page-world script through its own dataset. */
@@ -62,6 +92,7 @@ export interface FillRequest {
dn: string;
cpf: string;
submit: boolean;
selectors: Selectors;
}
export interface FillResult {

45
src/webext.d.ts vendored
View File

@@ -29,10 +29,16 @@
// rather than a source concern.
declare namespace chrome {
interface Event0 {
addListener(callback: () => void): void;
}
namespace runtime {
const lastError: { message?: string } | undefined;
function getURL(path: string): string;
function openOptionsPage(): Promise<void>;
const onInstalled: Event0;
const onStartup: Event0;
const onMessage: {
addListener(
callback: (
@@ -51,6 +57,45 @@ declare namespace chrome {
remove(keys: string | string[]): Promise<void>;
}
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 {

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`);