From 42638eaaf9c2710c3e8f848baf00937129b63449 Mon Sep 17 00:00:00 2001 From: Ruben Carlo Benante Date: Mon, 10 Aug 2026 14:26:44 -0300 Subject: [PATCH] config and inject --- src/config.ts | 133 ++++++++++++++++++++++++++++++++++++++ src/injected.ts | 165 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 src/config.ts create mode 100644 src/injected.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..afd457d --- /dev/null +++ b/src/config.ts @@ -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 { + const stored = await chrome.storage.local.get(CONFIG_KEY); + return { ...EMPTY_CONFIG, ...((stored[CONFIG_KEY] as Partial) ?? {}) }; +} + +export async function saveConfig(config: Config): Promise { + await chrome.storage.local.set({ [CONFIG_KEY]: config }); +} + +export async function loadState(): Promise { + const stored = await chrome.storage.local.get(STATE_KEY); + return { ...EMPTY_STATE, ...((stored[STATE_KEY] as Partial) ?? {}) }; +} + +export async function saveState(state: State): Promise { + 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 { + 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 }; +} diff --git a/src/injected.ts b/src/injected.ts new file mode 100644 index 0000000..3b5b700 --- /dev/null +++ b/src/injected.ts @@ -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 { + 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 { + 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) }); +}