Regenerate tool models and require an allowlisted model URL

This commit is contained in:
Anthony Stirling
2026-08-28 11:58:49 +01:00
parent f2a1b88e52
commit 3b3c382325
5 changed files with 32 additions and 10 deletions
@@ -170,9 +170,9 @@ public class FormDetectionModelManager {
throw new IllegalStateException(
"Model '" + modelId + "' has no download URL/checksum configured yet");
}
String scheme = URI.create(url).getScheme();
if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException("Model URL must be http(s): " + url);
// Same allowlist the download enforces, so a bad catalog entry fails here, not mid-install.
if (!url.startsWith(ALLOWED_MODEL_URL_PREFIX)) {
throw new IllegalArgumentException("Model URL is not on the allowlist: " + url);
}
if (!SHA256_HEX.matcher(sha).matches()) {
throw new IllegalArgumentException("Checksum must be a 64-char hex SHA-256");
@@ -38,6 +38,9 @@ import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;
class FormDetectionModelManagerTest {
/** Catalog URLs must satisfy the production allowlist; the stub below serves them locally. */
private static final String ALLOWED_URL = "https://huggingface.co";
private HttpServer server;
private byte[] modelBytes;
private String modelSha;
@@ -103,8 +106,9 @@ class FormDetectionModelManagerTest {
return new FormDetectionModelManager(paths, catalog, props, ep, noEngine()) {
@Override
HttpURLConnection openModelDownload(String url) throws IOException {
String local = "http://127.0.0.1:" + port + URI.create(url).getPath();
HttpURLConnection conn =
(HttpURLConnection) URI.create(url).toURL().openConnection();
(HttpURLConnection) URI.create(local).toURL().openConnection();
conn.setConnectTimeout(5000);
conn.setReadTimeout(10000);
return conn;
@@ -128,7 +132,7 @@ class FormDetectionModelManagerTest {
void installsDownloadsVerifiesAndPublishesAtomically(@TempDir Path dir) throws Exception {
EndpointConfiguration ep = Mockito.mock(EndpointConfiguration.class);
FormDetectionModelManager m =
manager(dir, entry("http://127.0.0.1:" + port + "/model.onnx", modelSha), ep);
manager(dir, entry(ALLOWED_URL + "/model.onnx", modelSha), ep);
m.startInstall("test-model");
awaitState(m, "ready", 5000);
@@ -145,7 +149,7 @@ class FormDetectionModelManagerTest {
void rejectsChecksumMismatchAndLeavesNoFile(@TempDir Path dir) throws Exception {
EndpointConfiguration ep = Mockito.mock(EndpointConfiguration.class);
FormDetectionModelManager m =
manager(dir, entry("http://127.0.0.1:" + port + "/model.onnx", "0".repeat(64)), ep);
manager(dir, entry(ALLOWED_URL + "/model.onnx", "0".repeat(64)), ep);
m.startInstall("test-model");
awaitState(m, "failed", 5000);
@@ -174,7 +178,7 @@ class FormDetectionModelManagerTest {
FormDetectionModelManager m =
manager(
dir,
entry("http://127.0.0.1:" + port + "/gated.onnx", modelSha),
entry(ALLOWED_URL + "/gated.onnx", modelSha),
Mockito.mock(EndpointConfiguration.class));
m.startInstall("test-model"); // begins, blocks in handler
@@ -191,6 +195,16 @@ class FormDetectionModelManagerTest {
assertThrows(IllegalStateException.class, () -> m.startInstall("test-model"));
}
@Test
void rejectsCatalogUrlOutsideAllowlist(@TempDir Path dir) {
FormDetectionModelManager m =
manager(
dir,
entry("http://127.0.0.1:" + port + "/model.onnx", modelSha),
Mockito.mock(EndpointConfiguration.class));
assertThrows(IllegalArgumentException.class, () -> m.startInstall("test-model"));
}
@Test
void rejectsDownloadUrlOutsideAllowlist(@TempDir Path dir) {
RuntimePathConfig paths = Mockito.mock(RuntimePathConfig.class);
@@ -216,7 +230,7 @@ class FormDetectionModelManagerTest {
FormDetectionModelManager m =
manager(
dir,
entry("http://127.0.0.1:" + port + "/model.onnx", modelSha),
entry(ALLOWED_URL + "/model.onnx", modelSha),
Mockito.mock(EndpointConfiguration.class));
assertThrows(IllegalArgumentException.class, () -> m.startInstall("unknown"));
}
@@ -227,7 +241,7 @@ class FormDetectionModelManagerTest {
Path preDir = root.resolve("preinstalled");
Files.createDirectories(preDir);
Files.write(preDir.resolve("test-model.onnx"), modelBytes);
ModelCatalogEntry e = entry("http://127.0.0.1:" + port + "/model.onnx", modelSha);
ModelCatalogEntry e = entry(ALLOWED_URL + "/model.onnx", modelSha);
ApplicationProperties props = new ApplicationProperties();
props.getFormDetection().setPreinstalledModelDir(preDir.toString());
@@ -725,6 +725,9 @@ class OcrPdfParams(ApiModel):
)
ocr_type: OcrType = Field(..., description="Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'")
remove_images_after: bool | None = Field(None, description="Remove images from the output PDF if set to true")
rotate_pages: bool | None = Field(
None, description="Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true"
)
sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true")
@@ -37,7 +37,8 @@ export async function applyFields(
const cb = form.createCheckBox(name);
cb.addToPage(page, { x: r.x, y: r.y, width: r.w, height: r.h });
} else {
// pdf-lib has no first-class signature widget; a text field keeps it fillable.
// Signature and radio both land here: FormUtils.addFields coerces them to text too,
// so a document is identical whichever side applied the fields.
const tf = form.createTextField(name);
tf.addToPage(page, { x: r.x, y: r.y, width: r.w, height: r.h });
}
@@ -1022,6 +1022,10 @@ export interface ProcessPdfWithOcrRequest {
* Remove images from the output PDF if set to true
*/
removeImagesAfter?: boolean;
/**
* Auto-correct page orientation (90/180/270) using Tesseract OSD if set to true
*/
rotatePages?: boolean;
/**
* Include OCR text in a sidecar text file if set to true
*/