config and inject
This commit is contained in:
133
src/config.ts
Normal file
133
src/config.ts
Normal 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
165
src/injected.ts
Normal 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) });
|
||||
}
|
||||
Reference in New Issue
Block a user