9 Commits

32 changed files with 2251 additions and 66 deletions

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# **************************************************************************
# * (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 *
# **************************************************************************
# vscode
.vscode
# Intellij
*.iml
.idea
# dependencies
node_modules
# Unpacked build output. Regenerated by "make", never edited by hand.
build
# 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
# Exclude macOS Finder (System Explorer) View States
.DS_Store

177
Makefile Normal file
View File

@@ -0,0 +1,177 @@
# **************************************************************************
# * (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 *
# **************************************************************************
# Makefile for logsdu - build the extension for Firefox and Chrome.
#
# Usage:
# 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 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
#
# 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/
#
# Publishing:
# Firefox upload dist/*-firefox.xpi at addons.mozilla.org
# Chrome upload dist/*-chrome.zip at chrome.google.com/webstore/devconsole
#
# 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")
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 firefox chrome typecheck test smoke prune xpi crx packages clean \
distclean check-deps
# 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
# built-in runner and type stripping, so there is no test framework to install.
test:
node --test "src/**/*.test.ts"
# 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.
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.
xpi: firefox prune
rm -f $(XPI)
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 $(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:
@test -x node_modules/.bin/tsc || { \
echo "ERROR: dependencies are not installed."; \
echo "Run: corepack pnpm install (do NOT use npm install in this tree)"; \
exit 1; \
}

243
README.md Normal file
View File

@@ -0,0 +1,243 @@
# logsdu
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.
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 the submit
button, so the normal case is zero clicks.
## Install
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 # bundle and package dist/logsdu-<version>-firefox.xpi
```
`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".
Dependencies are managed with **pnpm**, never npm. See "Why pnpm" below.
## Setting it up
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 |
| 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
input masks on these forms expect the values in exactly that shape.
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 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
redirect lands on the login page and you would be signed straight back in.
- When it fills without submitting, a small note at the bottom of the page says
why. Click it to dismiss.
- The toolbar popup has a **Preencher agora** button that fills without
submitting, for when you want to check the values before sending them.
- Automatic submission can be turned off entirely in the options.
## Where your data goes
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.
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.
The interface is in Portuguese, matching the portals it was written for.
## 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
```
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 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/
```
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.
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. 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` | 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
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()`. 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 field selectors are configurable
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
**Do not run `npm install` or `npm ci` in this repo.** This project is developed
on **ZFS**, and npm's installer renames many directories in parallel while
hoisting and deduping, which ZFS intermittently fails with:
```
npm error code ENOTEMPTY
npm error syscall rename
```
pnpm hard-links packages from a global content-addressable store and does not
perform that rename dance, so it is unaffected. Only `npm install` / `npm ci`
are the problem -- running *scripts* through npm is fine, since that just spawns
tsc and esbuild. The lockfile is `pnpm-lock.yaml`; there is no
`package-lock.json` and none should be created.
## Licence
GPL-3.0-or-later. See `LICENSE`.

View File

@@ -1 +1 @@
logsdu v0.1
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

7
icons/logsdu.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#2f6f4e"/>
<path d="M32 14a10 10 0 0 0-10 10v6h6v-6a4 4 0 0 1 8 0v6h6v-6a10 10 0 0 0-10-10z" fill="#e8f3ec"/>
<rect x="18" y="30" width="28" height="22" rx="4" fill="#e8f3ec"/>
<circle cx="32" cy="39" r="3.5" fill="#2f6f4e"/>
<rect x="30.5" y="41" width="3" height="7" rx="1.5" fill="#2f6f4e"/>
</svg>

After

Width:  |  Height:  |  Size: 434 B

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",
@@ -8,7 +8,9 @@
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "node --test \"src/**/*.test.ts\"",
"test:watch": "node --test --watch \"src/**/*.test.ts\""
},
"license": "GPL-3.0-or-later",
"devDependencies": {

285
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,285 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
esbuild:
specifier: 0.25.5
version: 0.25.5
typescript:
specifier: ^5.8.3
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.25.5':
resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.5':
resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.5':
resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.5':
resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.5':
resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.5':
resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.5':
resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.5':
resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.5':
resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.5':
resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.5':
resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.5':
resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.5':
resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.5':
resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.5':
resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.5':
resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.5':
resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.5':
resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.5':
resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.5':
resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.5':
resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.25.5':
resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.5':
resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.5':
resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.5':
resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
esbuild@0.25.5:
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
snapshots:
'@esbuild/aix-ppc64@0.25.5':
optional: true
'@esbuild/android-arm64@0.25.5':
optional: true
'@esbuild/android-arm@0.25.5':
optional: true
'@esbuild/android-x64@0.25.5':
optional: true
'@esbuild/darwin-arm64@0.25.5':
optional: true
'@esbuild/darwin-x64@0.25.5':
optional: true
'@esbuild/freebsd-arm64@0.25.5':
optional: true
'@esbuild/freebsd-x64@0.25.5':
optional: true
'@esbuild/linux-arm64@0.25.5':
optional: true
'@esbuild/linux-arm@0.25.5':
optional: true
'@esbuild/linux-ia32@0.25.5':
optional: true
'@esbuild/linux-loong64@0.25.5':
optional: true
'@esbuild/linux-mips64el@0.25.5':
optional: true
'@esbuild/linux-ppc64@0.25.5':
optional: true
'@esbuild/linux-riscv64@0.25.5':
optional: true
'@esbuild/linux-s390x@0.25.5':
optional: true
'@esbuild/linux-x64@0.25.5':
optional: true
'@esbuild/netbsd-arm64@0.25.5':
optional: true
'@esbuild/netbsd-x64@0.25.5':
optional: true
'@esbuild/openbsd-arm64@0.25.5':
optional: true
'@esbuild/openbsd-x64@0.25.5':
optional: true
'@esbuild/sunos-x64@0.25.5':
optional: true
'@esbuild/win32-arm64@0.25.5':
optional: true
'@esbuild/win32-ia32@0.25.5':
optional: true
'@esbuild/win32-x64@0.25.5':
optional: true
esbuild@0.25.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.5
'@esbuild/android-arm': 0.25.5
'@esbuild/android-arm64': 0.25.5
'@esbuild/android-x64': 0.25.5
'@esbuild/darwin-arm64': 0.25.5
'@esbuild/darwin-x64': 0.25.5
'@esbuild/freebsd-arm64': 0.25.5
'@esbuild/freebsd-x64': 0.25.5
'@esbuild/linux-arm': 0.25.5
'@esbuild/linux-arm64': 0.25.5
'@esbuild/linux-ia32': 0.25.5
'@esbuild/linux-loong64': 0.25.5
'@esbuild/linux-mips64el': 0.25.5
'@esbuild/linux-ppc64': 0.25.5
'@esbuild/linux-riscv64': 0.25.5
'@esbuild/linux-s390x': 0.25.5
'@esbuild/linux-x64': 0.25.5
'@esbuild/netbsd-arm64': 0.25.5
'@esbuild/netbsd-x64': 0.25.5
'@esbuild/openbsd-arm64': 0.25.5
'@esbuild/openbsd-x64': 0.25.5
'@esbuild/sunos-x64': 0.25.5
'@esbuild/win32-arm64': 0.25.5
'@esbuild/win32-ia32': 0.25.5
'@esbuild/win32-x64': 0.25.5
typescript@5.9.3: {}

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

150
src/config.test.ts Normal file
View File

@@ -0,0 +1,150 @@
// *************************************************************************
// * (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 *
// *************************************************************************
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
LOGOUT_COOLDOWN_MS,
SUBMIT_INTERVAL_MS,
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/',
ra: '2000101010',
dn: '01/02/1999',
cpf: '123.456.789-01',
autoSubmit: true,
selectors: { ...DEFAULT_SELECTORS },
};
const FRESH: State = { lastSubmitAt: 0, logoutAt: 0 };
const NOW = 1_000_000_000_000;
test('isConfigured requires all four values', () => {
assert.equal(isConfigured(CONFIG), true);
assert.equal(isConfigured({ ...CONFIG, url: '' }), false);
assert.equal(isConfigured({ ...CONFIG, url: 'not a url' }), false);
assert.equal(isConfigured({ ...CONFIG, ra: '' }), false);
assert.equal(isConfigured({ ...CONFIG, dn: '' }), 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', () => {
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/'), true);
assert.equal(matchesSite(CONFIG, 'https://portal.example.br/autenticacao/ap'), true);
});
test('matchesSite refuses lookalike hosts and scheme downgrades', () => {
// The content script runs on every page, so this guard is the only thing
// standing between the stored CPF and an attacker-chosen page.
assert.equal(matchesSite(CONFIG, 'https://portal.example.br.evil.tld/'), false);
assert.equal(matchesSite(CONFIG, 'https://evil.tld/portal.example.br'), false);
assert.equal(matchesSite(CONFIG, 'http://portal.example.br/'), false);
assert.equal(matchesSite(CONFIG, 'https://sub.portal.example.br/'), false);
assert.equal(matchesSite(CONFIG, 'about:blank'), false);
});
test('a fresh state submits', () => {
assert.deepEqual(decideSubmit(CONFIG, FRESH, NOW), { submit: true });
});
test('the toggle wins over everything else', () => {
assert.deepEqual(decideSubmit({ ...CONFIG, autoSubmit: false }, FRESH, NOW), {
submit: false,
reason: 'disabled',
});
});
test('one submit per hour, then autofill only', () => {
const justTried: State = { ...FRESH, lastSubmitAt: NOW - 1000 };
assert.deepEqual(decideSubmit(CONFIG, justTried, NOW), {
submit: false,
reason: 'rate-limited',
});
const anHourAgo: State = { ...FRESH, lastSubmitAt: NOW - SUBMIT_INTERVAL_MS - 1 };
assert.deepEqual(decideSubmit(CONFIG, anHourAgo, NOW), { submit: true });
});
test('a recent logout suppresses the submit, and expires', () => {
const justLoggedOut: State = { ...FRESH, logoutAt: NOW - 1000 };
assert.deepEqual(decideSubmit(CONFIG, justLoggedOut, NOW), {
submit: false,
reason: 'logout',
});
const staleLogout: State = { ...FRESH, logoutAt: NOW - LOGOUT_COOLDOWN_MS - 1 };
assert.deepEqual(decideSubmit(CONFIG, staleLogout, NOW), { submit: true });
});
test('logout is reported ahead of the rate limit', () => {
// Both apply after a login followed by a logout. The logout is the more
// useful explanation to show the user.
const both: State = { lastSubmitAt: NOW - 1000, logoutAt: NOW - 1000 };
assert.deepEqual(decideSubmit(CONFIG, both, NOW), {
submit: false,
reason: 'logout',
});
});

View File

@@ -19,7 +19,9 @@
// * rcb@beco.cc *
// *************************************************************************
import { toOrigin } from './format';
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' };

213
src/content.ts Normal file
View File

@@ -0,0 +1,213 @@
// *************************************************************************
// * (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 *
// *************************************************************************
// The content script is declared against every URL, because the one address
// it cares about is configured at runtime and deliberately absent from the
// manifest. Everything below is therefore written to bail out as early and as
// cheaply as possible: one storage read and an origin comparison, then return.
import {
decideSubmit,
isConfigured,
loadConfig,
loadState,
matchesSite,
saveState,
} from './config.ts';
import type { Config } from './config.ts';
import { EVENT_RESULT } from './portal.ts';
import type { FillRequest, FillResult } from './portal.ts';
const FORM_WAIT_MS = 10000;
const NOTICE_ID = 'logsdu-notice';
// User-facing strings follow the portal's language.
const NOTICE_TEXT: Record<string, string> = {
disabled: 'logsdu: campos preenchidos. O login automático está desligado.',
'rate-limited':
'logsdu: campos preenchidos, sem envio automático (já houve uma tentativa na última hora). Confira os dados e clique em Entrar.',
logout: 'logsdu: campos preenchidos, sem envio automático depois do logout.',
};
/**
* Hand the values to the page-world filler.
*
* The payload travels on the script tag's dataset rather than in a CustomEvent
* detail: an object created in the isolated world is not reliably readable
* from the page, and a string on the element is both simple and synchronous.
* The injected script removes the element as its first act.
*/
function fill(request: FillRequest): Promise<FillResult> {
return new Promise((resolve) => {
const onResult = (event: Event): void => {
window.removeEventListener(EVENT_RESULT, onResult);
const detail = (event as CustomEvent<string>).detail;
try {
resolve(JSON.parse(detail) as FillResult);
} catch {
resolve({ filled: false, submitted: false, error: 'bad result payload' });
}
};
window.addEventListener(EVENT_RESULT, onResult);
const script = document.createElement('script');
script.src = chrome.runtime.getURL('injected.js');
script.dataset.logsdu = JSON.stringify(request);
(document.head ?? document.documentElement).appendChild(script);
});
}
/** Resolve once the login form exists, or null if it never shows up. */
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>(formSelector);
if (found) {
observer.disconnect();
window.clearTimeout(timer);
resolve(found);
}
});
const timer = window.setTimeout(() => {
observer.disconnect();
resolve(null);
}, FORM_WAIT_MS);
observer.observe(document.documentElement, { childList: true, subtree: true });
});
}
/** A small, self-removing note explaining why nothing was submitted. */
function showNotice(text: string): void {
if (document.getElementById(NOTICE_ID)) return;
const notice = document.createElement('div');
notice.id = NOTICE_ID;
notice.textContent = text;
notice.style.cssText = [
'position:fixed',
'z-index:2147483647',
'left:50%',
'transform:translateX(-50%)',
'bottom:16px',
'max-width:min(90vw,520px)',
'padding:10px 14px',
'border-radius:8px',
'background:#222',
'color:#fff',
'font:14px/1.4 system-ui,sans-serif',
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
].join(';');
notice.addEventListener('click', () => notice.remove());
document.body?.appendChild(notice);
window.setTimeout(() => notice.remove(), 12000);
}
/**
* Remember that the user asked to be logged out.
*
* 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(logoutSelector: string): void {
document.addEventListener(
'click',
(event) => {
const target = event.target as Element | null;
if (!target?.closest?.(logoutSelector)) return;
void loadState().then((state) =>
saveState({ ...state, logoutAt: Date.now() }),
);
},
true,
);
}
async function autoLogin(config: Config): Promise<void> {
const form = await awaitForm(config.selectors.form);
if (!form) return;
const state = await loadState();
const decision = decideSubmit(config, state, Date.now());
// Record the attempt before it happens, not after. A submit that navigates
// away, crashes, or is interrupted still has to count against the hourly
// limit, otherwise a failing login could retry on every page load.
if (decision.submit) {
await saveState({ ...state, lastSubmitAt: Date.now() });
}
const result = await fill({
ra: config.ra,
dn: config.dn,
cpf: config.cpf,
submit: decision.submit,
selectors: config.selectors,
});
if (result.error) {
console.warn('logsdu:', result.error);
return;
}
if (!decision.submit && result.filled) {
showNotice(NOTICE_TEXT[decision.reason] ?? '');
}
}
async function main(): Promise<void> {
const config = await loadConfig();
if (!isConfigured(config)) return;
if (!matchesSite(config, location.href)) return;
watchLogout(config.selectors.logout);
// The popup's "Preencher agora" button, for when the automatic submit is
// off or rate limited.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if ((message as { type?: string })?.type !== 'fill') return undefined;
void loadConfig()
.then((fresh) =>
fill({
ra: fresh.ra,
dn: fresh.dn,
cpf: fresh.cpf,
submit: (message as { submit?: boolean }).submit === true,
selectors: fresh.selectors,
}),
)
.then(sendResponse);
return true;
});
// Only chase the form when it is already here, or when the document is
// 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(config.selectors.form) ||
document.readyState !== 'complete'
) {
await autoLogin(config);
}
}
void main();

94
src/format.test.ts Normal file
View File

@@ -0,0 +1,94 @@
// *************************************************************************
// * (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 *
// *************************************************************************
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
formatCpf,
formatDate,
formatRa,
isCompleteCpf,
isCompleteDate,
toOrigin,
} from './format.ts';
test('formatDate builds dd/mm/yyyy progressively', () => {
assert.equal(formatDate('0'), '0');
assert.equal(formatDate('01'), '01');
assert.equal(formatDate('0102'), '01/02');
assert.equal(formatDate('01021999'), '01/02/1999');
});
test('formatDate is idempotent and tolerates a pasted separator', () => {
assert.equal(formatDate('01/02/1999'), '01/02/1999');
assert.equal(formatDate(formatDate('01021999')), '01/02/1999');
assert.equal(formatDate('1-2-1999'), '12/19/99');
});
test('formatCpf builds 000.000.000-00 progressively', () => {
assert.equal(formatCpf('123'), '123');
assert.equal(formatCpf('123456'), '123.456');
assert.equal(formatCpf('12345678901'), '123.456.789-01');
assert.equal(formatCpf('123.456.789-01'), '123.456.789-01');
});
test('formatters drop overflow instead of growing without bound', () => {
assert.equal(formatCpf('123456789012345'), '123.456.789-01');
assert.equal(formatRa('20001010109999'), '2000101010');
});
test('isCompleteDate rejects impossible calendar dates', () => {
assert.equal(isCompleteDate('01/02/1999'), true);
assert.equal(isCompleteDate('29/02/2000'), true, 'leap year');
assert.equal(isCompleteDate('29/02/1999'), false, 'not a leap year');
assert.equal(isCompleteDate('31/04/1999'), false, 'April has 30 days');
assert.equal(isCompleteDate('00/01/1999'), false);
assert.equal(isCompleteDate('01/13/1999'), false);
assert.equal(isCompleteDate('1/2/1999'), false, 'must be zero padded');
});
test('isCompleteCpf counts digits, not characters', () => {
assert.equal(isCompleteCpf('123.456.789-01'), true);
assert.equal(isCompleteCpf('12345678901'), true);
assert.equal(isCompleteCpf('123.456.789-0'), false);
});
test('toOrigin assumes https and strips path, query and fragment', () => {
assert.equal(toOrigin('https://portal.example.br/'), 'https://portal.example.br');
assert.equal(toOrigin('portal.example.br'), 'https://portal.example.br');
assert.equal(
toOrigin('https://portal.example.br/login?a=1#x'),
'https://portal.example.br',
);
assert.equal(toOrigin(' portal.example.br '), 'https://portal.example.br');
});
test('toOrigin keeps the port, which is part of the origin', () => {
assert.equal(toOrigin('http://localhost:8080/x'), 'http://localhost:8080');
});
test('toOrigin rejects what cannot be a site', () => {
assert.equal(toOrigin(''), null);
assert.equal(toOrigin(' '), null);
assert.equal(toOrigin('javascript:alert(1)'), null);
assert.equal(toOrigin('file:///etc/passwd'), null);
});

View File

@@ -84,7 +84,7 @@ export function isCompleteCpf(value: string): boolean {
/**
* Origin of a user-typed site address, or null if it cannot be parsed.
* Accepts input without a scheme ("saladigital.example.com") by assuming
* Accepts input without a scheme ("portal.example.br") by assuming
* https, which is what someone pasting an address from the URL bar expects.
*/
export function toOrigin(value: string): string | null {

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';
import type { FillRequest, FillResult } from './portal';
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": {
"16": "icons/logsdu-16.png",
"32": "icons/logsdu-32.png",
"48": "icons/logsdu-48.png",
"96": "icons/logsdu-96.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"],
@@ -38,6 +38,10 @@
},
"action": {
"default_popup": "popup.html",
"default_title": "logsdu"
"default_title": "logsdu",
"default_icon": {
"16": "icons/logsdu-16.png",
"32": "icons/logsdu-32.png"
}
}
}

100
src/options.html Normal file
View File

@@ -0,0 +1,100 @@
<!--
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
* Licensed under the GNU General Public License v3.0 or later.
* See https://www.gnu.org/licenses/ and the LICENSE file.
-->
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<title>logsdu</title>
<link rel="stylesheet" href="ui.css" />
</head>
<body class="page">
<h1>logsdu</h1>
<p class="lede">
Guarde os dados de acesso uma vez. Ao abrir a página de login, o
preenchimento e o envio acontecem sozinhos.
</p>
<form id="form" autocomplete="off">
<label for="url">Endereço do portal</label>
<input id="url" type="text" inputmode="url" placeholder="https://portal.exemplo.br/" />
<p class="hint">
O endereço fica somente aqui, na memória local da extensão. Nada
dele aparece no código instalado.
</p>
<label for="ra">Matrícula / Código</label>
<input id="ra" type="text" inputmode="numeric" placeholder="2000101010" />
<label for="dn">Data de nascimento</label>
<input id="dn" type="text" inputmode="numeric" placeholder="01/01/2000" />
<label for="cpf">CPF</label>
<input id="cpf" type="text" inputmode="numeric" placeholder="000.000.000-00" />
<label class="check">
<input id="autoSubmit" type="checkbox" />
<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>
<span id="status" role="status" aria-live="polite"></span>
</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 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>
<script src="options.js"></script>
</body>
</html>

208
src/options.ts Normal file
View File

@@ -0,0 +1,208 @@
// *************************************************************************
// * (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 *
// *************************************************************************
import {
EMPTY_CONFIG,
loadConfig,
resetState,
saveConfig,
sitePattern,
} from './config.ts';
import {
formatCpf,
formatDate,
formatRa,
isCompleteCpf,
isCompleteDate,
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);
if (!found) throw new Error(`missing element #${id}`);
return found as T;
}
const fields = {
url: el<HTMLInputElement>('url'),
ra: el<HTMLInputElement>('ra'),
dn: el<HTMLInputElement>('dn'),
cpf: el<HTMLInputElement>('cpf'),
};
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);
}
/**
* Reformat as the user types, keeping the caret at the end.
*
* Anchoring the caret is only correct because these masks are append-only in
* practice: you type or paste a number left to right. It avoids the caret
* jumping to position zero after every keystroke.
*/
function liveFormat(input: HTMLInputElement, format: (v: string) => string): void {
input.addEventListener('input', () => {
const atEnd = input.selectionStart === input.value.length;
const formatted = format(input.value);
if (formatted === input.value) return;
input.value = formatted;
if (atEnd) input.setSelectionRange(formatted.length, formatted.length);
});
}
liveFormat(fields.ra, formatRa);
liveFormat(fields.dn, formatDate);
liveFormat(fields.cpf, formatCpf);
async function load(): Promise<void> {
const config = await loadConfig();
fields.url.value = config.url;
fields.ra.value = config.ra;
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. */
function validate(): string | null {
for (const input of Object.values(fields)) input.classList.remove('invalid');
const origin = toOrigin(fields.url.value);
if (origin === null) {
fields.url.classList.add('invalid');
return 'Endereço inválido.';
}
if (!isCompleteRa(fields.ra.value)) {
fields.ra.classList.add('invalid');
return 'Informe a matrícula.';
}
if (!isCompleteDate(fields.dn.value)) {
fields.dn.classList.add('invalid');
return 'Data de nascimento incompleta ou inexistente.';
}
if (!isCompleteCpf(fields.cpf.value)) {
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;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const complaint = validate();
if (complaint) {
setStatus(complaint, true);
return;
}
const config = {
url: fields.url.value.trim(),
ra: fields.ra.value,
dn: fields.dn.value,
cpf: fields.cpf.value,
autoSubmit: autoSubmit.checked,
selectors: readSelectors(),
};
const pattern = sitePattern(config);
if (pattern === null) {
setStatus('Endereço inválido.', true);
return;
}
// permissions.request() must be the FIRST async call in this handler.
// Firefox only honours it while the user gesture from the click is still
// active, and awaiting anything beforehand -- even a storage write --
// discards the gesture and the prompt is refused.
void chrome.permissions
.request({ origins: [pattern] })
.then(async (granted) => {
if (!granted) {
setStatus('Permissão negada: a extensão não pode agir nesse site.', true);
return;
}
await saveConfig(config);
// Saving is how you correct a typo, so it also clears the hourly
// limit and the logout cooldown: the next visit may try again.
await resetState();
setStatus('Salvo.');
})
.catch((error: unknown) => {
setStatus(`Falha ao salvar: ${String(error)}`, true);
});
});
el<HTMLButtonElement>('defaults').addEventListener('click', () => {
writeSelectors(DEFAULT_SELECTORS);
setStatus('Padrões restaurados. Salve para aplicar.');
});
el<HTMLButtonElement>('clear').addEventListener('click', () => {
void (async () => {
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.');
})();
});
void load();

22
src/popup.html Normal file
View File

@@ -0,0 +1,22 @@
<!--
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
* Licensed under the GNU General Public License v3.0 or later.
* See https://www.gnu.org/licenses/ and the LICENSE file.
-->
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<title>logsdu</title>
<link rel="stylesheet" href="ui.css" />
</head>
<body class="popup">
<p class="state" id="state">...</p>
<p class="hint" id="detail"></p>
<div class="row">
<button id="fill" type="button" disabled>Preencher agora</button>
<button id="options" type="button" class="ghost">Configurar</button>
</div>
<script src="popup.js"></script>
</body>
</html>

91
src/popup.ts Normal file
View File

@@ -0,0 +1,91 @@
// *************************************************************************
// * (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 *
// *************************************************************************
// The manual escape hatch: fill (and optionally submit) on demand, for when
// the automatic submit is switched off, rate limited, or suppressed after a
// logout.
import { isConfigured, loadConfig, matchesSite, sitePattern } from './config.ts';
const state = document.getElementById('state') as HTMLParagraphElement;
const detail = document.getElementById('detail') as HTMLParagraphElement;
const fillButton = document.getElementById('fill') as HTMLButtonElement;
const optionsButton = document.getElementById('options') as HTMLButtonElement;
optionsButton.addEventListener('click', () => {
void chrome.runtime.openOptionsPage();
});
async function activeTabOnSite(): Promise<number | null> {
const config = await loadConfig();
if (!isConfigured(config)) return null;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id || !tab.url) return null;
return matchesSite(config, tab.url) ? tab.id : null;
}
async function refresh(): Promise<void> {
const config = await loadConfig();
if (!isConfigured(config)) {
state.textContent = 'Não configurado';
detail.textContent = 'Informe o endereço do portal e os três dados de acesso.';
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';
detail.textContent = 'Esta aba não é o portal configurado.';
return;
}
state.textContent = 'Pronto';
detail.textContent = config.autoSubmit
? 'Login automático ligado.'
: 'Login automático desligado: preencha e clique em Entrar.';
fillButton.disabled = false;
}
fillButton.addEventListener('click', () => {
void (async () => {
const tabId = await activeTabOnSite();
if (tabId === null) return;
fillButton.disabled = true;
// Fill only. Pressing "Entrar" stays with the user here, which is the
// point of a manual button.
await chrome.tabs.sendMessage(tabId, { type: 'fill', submit: false });
window.close();
})();
});
void refresh();

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 {

149
src/ui.css Normal file
View File

@@ -0,0 +1,149 @@
/*
* (C)opyright 2026 by Ruben Carlo Benante <rcb@beco.cc>
* Licensed under the GNU General Public License v3.0 or later.
* See https://www.gnu.org/licenses/ and the LICENSE file.
*/
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #1b1b1b;
--muted: #5c5c5c;
--line: #d6d6d6;
--accent: #2f6f4e;
--field: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1e1e1e;
--fg: #ededed;
--muted: #a8a8a8;
--line: #3d3d3d;
--accent: #6cc294;
--field: #2a2a2a;
}
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 15px/1.5 system-ui, sans-serif;
}
.page {
max-width: 34rem;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
.popup {
width: 20rem;
padding: 1rem;
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.4rem;
}
h2 {
margin: 2rem 0 0.5rem;
font-size: 1rem;
}
.lede {
margin: 0 0 1.5rem;
color: var(--muted);
}
label {
display: block;
margin: 1rem 0 0.35rem;
font-weight: 600;
}
input[type='text'] {
width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--field);
color: inherit;
font: inherit;
}
input[type='text']:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
input.invalid {
border-color: #c0392b;
}
.hint {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.25rem;
font-weight: 400;
}
.check input {
margin: 0;
}
.row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 1.5rem;
flex-wrap: wrap;
}
button {
padding: 0.5rem 1rem;
border: 1px solid transparent;
border-radius: 6px;
background: var(--accent);
color: #fff;
font: inherit;
cursor: pointer;
}
button.ghost {
background: transparent;
border-color: var(--line);
color: var(--fg);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
#status {
color: var(--muted);
font-size: 0.9rem;
}
#status.error {
color: #c0392b;
}
.popup p {
margin: 0 0 0.75rem;
}
.popup .state {
font-weight: 600;
}

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

View File

@@ -8,12 +8,15 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"lib": ["ES2021", "DOM"]
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}