config and inject

This commit is contained in:
2026-08-10 14:26:44 -03:00
parent 436130ecb9
commit 42638eaaf9
2 changed files with 298 additions and 0 deletions

133
src/config.ts Normal file
View File

@@ -0,0 +1,133 @@
// *************************************************************************
// * (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 { toOrigin } from './format';
const CONFIG_KEY = 'config';
const STATE_KEY = 'state';
/** At most one automatic submit per hour. */
export const SUBMIT_INTERVAL_MS = 60 * 60 * 1000;
/** How long an explicit logout suppresses the automatic submit. */
export const LOGOUT_COOLDOWN_MS = 5 * 60 * 1000;
/**
* The four values the user configures. The site address is one of them so
* that no institution is named anywhere in the shipped code.
*
* This is stored unencrypted in extension-local storage, which is the same
* protection a browser-saved password gets: private to this profile, never
* synced, never transmitted, but readable by anyone with your unlocked
* account.
*/
export interface Config {
url: string;
ra: string;
dn: string;
cpf: string;
autoSubmit: boolean;
}
/** Bookkeeping that enforces the rate limit and the logout cooldown. */
export interface State {
lastSubmitAt: number;
logoutAt: number;
}
export const EMPTY_CONFIG: Config = {
url: '',
ra: '',
dn: '',
cpf: '',
autoSubmit: true,
};
const EMPTY_STATE: State = { lastSubmitAt: 0, logoutAt: 0 };
export async function loadConfig(): Promise<Config> {
const stored = await chrome.storage.local.get(CONFIG_KEY);
return { ...EMPTY_CONFIG, ...((stored[CONFIG_KEY] as Partial<Config>) ?? {}) };
}
export async function saveConfig(config: Config): Promise<void> {
await chrome.storage.local.set({ [CONFIG_KEY]: config });
}
export async function loadState(): Promise<State> {
const stored = await chrome.storage.local.get(STATE_KEY);
return { ...EMPTY_STATE, ...((stored[STATE_KEY] as Partial<State>) ?? {}) };
}
export async function saveState(state: State): Promise<void> {
await chrome.storage.local.set({ [STATE_KEY]: state });
}
/** Clear the rate limit, so fixing a typo lets you retry straight away. */
export async function resetState(): Promise<void> {
await saveState(EMPTY_STATE);
}
export function isConfigured(config: Config): boolean {
return (
toOrigin(config.url) !== null &&
config.ra !== '' &&
config.dn !== '' &&
config.cpf !== ''
);
}
/**
* Whether a given page belongs to the configured site.
*
* The content script is declared against every URL, so this is the guard that
* makes it a no-op everywhere else. Origins are compared rather than prefixes
* so that a lookalike host cannot match.
*/
export function matchesSite(config: Config, href: string): boolean {
const configured = toOrigin(config.url);
if (configured === null) return false;
try {
return new URL(href).origin === configured;
} catch {
return false;
}
}
export type SubmitDecision =
| { submit: true }
| { submit: false; reason: 'disabled' | 'rate-limited' | 'logout' };
/** Decide whether this page load is allowed to press "Entrar". */
export function decideSubmit(
config: Config,
state: State,
now: number,
): SubmitDecision {
if (!config.autoSubmit) return { submit: false, reason: 'disabled' };
if (now - state.logoutAt < LOGOUT_COOLDOWN_MS) {
return { submit: false, reason: 'logout' };
}
if (now - state.lastSubmitAt < SUBMIT_INTERVAL_MS) {
return { submit: false, reason: 'rate-limited' };
}
return { submit: true };
}

165
src/injected.ts Normal file
View File

@@ -0,0 +1,165 @@
// *************************************************************************
// * (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 *
// *************************************************************************
// This half runs in the PAGE world, not the content-script world, and that is
// the whole point of it existing as a separate file.
//
// The portal's three inputs are driven by Inputmask, which replaces the
// elements' `value` property with its own accessor. A content script writing
// `input.value` from the isolated world goes through Xrays straight to the
// native setter, bypassing that accessor: the pixels change, but Inputmask's
// internal buffer does not, and the page's own submit handler reads the stale
// buffer back through jQuery's .val(). Filling from inside the page, using the
// page's own jQuery and Inputmask instances, is the only way the values the
// user sees are the values that get posted.
//
// It also lets us wait for the real precondition. The portal loads its scripts
// through Cloudflare Rocket Loader, so at document_idle jQuery may not exist
// yet and the form's submit handler is certainly not bound. Clicking "Entrar"
// 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';
const POLL_INTERVAL_MS = 100;
const POLL_TIMEOUT_MS = 15000;
/* eslint-disable @typescript-eslint/no-explicit-any */
type JQueryLike = any;
function report(result: FillResult): void {
// The detail is a string because it has to survive the trip back to the
// isolated world; structured objects created in the page cannot always be
// read from a content script.
window.dispatchEvent(
new CustomEvent(EVENT_RESULT, { detail: JSON.stringify(result) }),
);
}
/** jQuery, the form, and (when submitting) the page's own submit handler. */
function readiness(needSubmit: boolean): JQueryLike | null {
const $ = (window as unknown as { jQuery?: JQueryLike }).jQuery;
if (!$) return null;
const form = $(FORM);
if (!form.length) return null;
if (needSubmit) {
// jQuery keeps its handler registry in the private _data store. If the
// submit handler is not bound yet, clicking would bypass the AJAX login
// the page actually implements.
const events = typeof $._data === 'function' ? $._data(form[0], 'events') : null;
if (!events || !events.submit) return null;
}
return $;
}
function waitFor(needSubmit: boolean): Promise<JQueryLike> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + POLL_TIMEOUT_MS;
const tick = (): void => {
const $ = readiness(needSubmit);
if ($) {
resolve($);
return;
}
if (Date.now() > deadline) {
reject(new Error('login form or page scripts never became ready'));
return;
}
window.setTimeout(tick, POLL_INTERVAL_MS);
};
tick();
});
}
/**
* Set one masked input.
*
* Inputmask 5 hangs an `inputmask` object off the element; using its setValue
* keeps the mask's buffer and the displayed text in agreement. The jQuery path
* is the fallback for the case where the mask has not been applied to that
* field.
*/
function setField($: JQueryLike, element: HTMLInputElement, value: string): void {
const mask = (element as unknown as { inputmask?: { setValue?: (v: string) => void } })
.inputmask;
if (mask && typeof mask.setValue === 'function') {
mask.setValue(value);
} else {
$(element).val(value);
}
$(element).trigger('input').trigger('change');
}
async function run(request: FillRequest): Promise<void> {
const $ = await waitFor(request.submit);
const form = $(FORM);
const targets: Array<[string, string]> = [
[FIELDS.ra, request.ra],
[FIELDS.dn, request.dn],
[FIELDS.cpf, request.cpf],
];
for (const [selector, value] of targets) {
// Scoped to the form: an unscoped CPF lookup would hit the "forgot your
// registration number" modal on the same page.
const field = form.find(selector);
if (!field.length) {
report({ filled: false, submitted: false, error: `missing field ${selector}` });
return;
}
setField($, field[0] as HTMLInputElement, value);
}
if (!request.submit) {
report({ filled: true, submitted: false });
return;
}
const button = form.find(SUBMIT)[0] as HTMLButtonElement | undefined;
if (!button) {
report({ filled: true, submitted: false, error: 'missing submit button' });
return;
}
// A native click, not form.submit(). The page calls preventDefault() on the
// submit event and posts by AJAX with a CSRF header taken from a meta tag;
// form.submit() would skip that handler entirely, while a click reproduces
// exactly what the user pressing "Entrar" does, CSRF included.
report({ filled: true, submitted: true });
button.click();
}
// The payload arrives on the script tag's own dataset, which sidesteps
// cross-world structured cloning. Read it and drop the element immediately, so
// the credentials are in the DOM for as short a time as possible.
const self = document.currentScript as HTMLScriptElement | null;
const payload = self?.dataset.logsdu ?? '';
self?.remove();
try {
void run(JSON.parse(payload) as FillRequest).catch((error: unknown) => {
report({ filled: false, submitted: false, error: String(error) });
});
} catch (error) {
report({ filled: false, submitted: false, error: String(error) });
}