Skip to content

Commit 3d2a276

Browse files
authored
Merge pull request #44 from gbrlpzz/fix/security-hardening-and-optimizations
Harden sync/auth/offline shell; fix throttle + audit bugs
2 parents 2f00e6c + 838f20e commit 3d2a276

23 files changed

Lines changed: 521 additions & 168 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,3 @@ exports must include failure-oriented tests and pass `npm run check`.
213213

214214
`collect` is open-source software licensed under the
215215
[Apache License 2.0](LICENSE). Copyright © 2026 Gabriele Pizzi.
216-

public/sw.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// collect service worker: precached app shell + runtime cache-first assets.
22
// The hashed asset list is emitted by the Vite build (precache-manifest.json)
33
// so an installed app loads offline from the first launch.
4-
const CACHE = "collect-shell-v4";
4+
const CACHE = "collect-shell-v5";
55
const CORE = [
66
"/",
77
"/manifest.webmanifest",
@@ -82,16 +82,27 @@ self.addEventListener("fetch", (event) => {
8282
if (requestUrl.origin !== self.location.origin) return;
8383

8484
if (event.request.mode === "navigate") {
85+
// Two surfaces share one deployment: the marketing homepage at "/" and
86+
// the installed field app at "/app". Cache each navigation under its own
87+
// shell key and fall back to that key offline, so a contributor who last
88+
// opened the homepage is never served the wrong shell in the field.
89+
const shell = requestUrl.pathname.startsWith("/app")
90+
? "/index.html"
91+
: "/homepage.html";
8592
event.respondWith(
8693
fetch(event.request)
8794
.then((response) => {
8895
if (response.ok) {
8996
const copy = response.clone();
90-
void caches.open(CACHE).then((cache) => cache.put("/", copy));
97+
void caches.open(CACHE).then((cache) => cache.put(shell, copy));
9198
}
9299
return response;
93100
})
94-
.catch(() => caches.match("/")),
101+
.catch(() =>
102+
caches
103+
.match(shell)
104+
.then((cached) => cached ?? caches.match("/index.html")),
105+
),
95106
);
96107
return;
97108
}

scripts/provision.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,10 @@ async function updateAuthConfig({
159159
}
160160
}
161161

162-
async function issueMagicLink({ projectRef, publishableKey, appUrl, email }) {
162+
async function issueMagicLink({ projectRef, publishableKey, appPath, email }) {
163163
const supabaseUrl = `https://${projectRef}.supabase.co`;
164164
const response = await fetch(
165-
`${supabaseUrl}/auth/v1/otp?redirect_to=${encodeURIComponent(appUrl)}`,
165+
`${supabaseUrl}/auth/v1/otp?redirect_to=${encodeURIComponent(appPath)}`,
166166
{
167167
method: "POST",
168168
headers: {
@@ -259,7 +259,7 @@ async function main() {
259259
await issueMagicLink({
260260
projectRef,
261261
publishableKey,
262-
appUrl,
262+
appPath,
263263
email: adminEmail,
264264
});
265265
console.log(

src/app/submission.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,18 @@ export async function commitLocalObservation({
4141
const { values: cleanedValues, response: attentionResponse } =
4242
extractAttentionResponse(values);
4343

44+
// Only declared schema keys may enter the research payload. The draft also
45+
// carries a UI-only "observed_date" seed for the demo schema; on real
46+
// deployments that key is not a declared field and must not be persisted as
47+
// research data. Filtering here also guarantees the reserved attention key
48+
// (already stripped above) can never re-enter the payload.
49+
const declaredKeys = new Set(project.fields.map((field) => field.key));
50+
let submittedValues = Object.fromEntries(
51+
Object.entries(cleanedValues).filter(([key]) => declaredKeys.has(key)),
52+
);
53+
4454
// A fresh location fix is captured at submit time when the browser permits it.
4555
// Failure to obtain a fix never blocks the durable local receipt.
46-
let submittedValues = cleanedValues;
4756
// The fresh fix is written to every declared location field key so a schema
4857
// with a non-"location" key (e.g. "gps") still records coordinates. The
4958
// merge stays on cleanedValues so stripped auxiliary keys (e.g. the

src/app/syncController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ export function syncNow({
214214
// transient failures stay retryable. One failed observation never
215215
// blocks the rest of the queue.
216216
const actionRequired =
217-
/unknown schema|revoked|forbidden|not authorized|permission|conflict|corrupt|assignment is not active|belongs to another|immutable|does not match the published schema|is not a published option|not configured as the first administrator|size does not match|checksum|integrity|invalid option|not active|closed/i.test(
217+
/unknown schema|revoked|consent|forbidden|not authorized|permission|conflict|corrupt|assignment is not active|belongs to another|immutable|does not match the published schema|is not a published option|not configured as the first administrator|size does not match|checksum|integrity|invalid option|not active|closed/i.test(
218218
message,
219219
);
220220
await recordOutboxFailure(observation.id, message, actionRequired);

src/homepage/PreviewForm.tsx

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,30 @@ import { Icon } from "../components/Icon";
77
* Email and inquiry details are required so we can properly route and respond.
88
* Inserts one row into the private preview_requests queue (RLS: anonymous insert only).
99
*/
10-
const DEFAULT_SUPABASE_URL = "https://lrqlrufwrytpwhgclmyo.supabase.co";
11-
const DEFAULT_PUBLISHABLE_KEY =
12-
"sb_publishable_BAsTV49V04O0WZVtVgohqg_BD5JReFE";
13-
14-
const baseUrl = (
15-
import.meta.env.VITE_SUPABASE_URL || DEFAULT_SUPABASE_URL
16-
).replace(/\/+$/, "");
17-
const FORM_ENDPOINT = `${baseUrl}/rest/v1/preview_requests`;
18-
const FORM_KEY =
19-
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY ||
20-
import.meta.env.VITE_SUPABASE_ANON_KEY ||
21-
DEFAULT_PUBLISHABLE_KEY;
10+
// The preview form posts into the same Supabase project that powers the app.
11+
// It must never fall back to a hardcoded production project: a self-hosted
12+
// build without VITE_SUPABASE_* would otherwise write interest requests into
13+
// someone else's database. When unconfigured, the form renders a clear note.
14+
/**
15+
* Read the target project lazily (not at module scope) so test and runtime
16+
* environments can configure VITE_SUPABASE_* after import, and so a build
17+
* without the variables still renders a clear "not configured" note instead
18+
* of silently posting into a hardcoded production project.
19+
*/
20+
function previewFormConfig(): { endpoint: string; key: string } | null {
21+
const baseUrl = import.meta.env.VITE_SUPABASE_URL?.trim();
22+
const key =
23+
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY?.trim() ||
24+
import.meta.env.VITE_SUPABASE_ANON_KEY?.trim();
25+
if (!baseUrl || !key) return null;
26+
return {
27+
endpoint: `${baseUrl.replace(/\/+$/, "")}/rest/v1/preview_requests`,
28+
key,
29+
};
30+
}
2231

2332
export function PreviewForm({ initialEmail = "" }: { initialEmail?: string }) {
33+
const config = previewFormConfig();
2434
const [email, setEmail] = useState(initialEmail);
2535
const [inquiry, setInquiry] = useState("");
2636
const [error, setError] = useState<string | null>(null);
@@ -33,6 +43,7 @@ export function PreviewForm({ initialEmail = "" }: { initialEmail?: string }) {
3343

3444
const submit = async (event: React.FormEvent) => {
3545
event.preventDefault();
46+
if (!config) return;
3647
setError(null);
3748

3849
const trimmedEmail = email.trim();
@@ -50,11 +61,11 @@ export function PreviewForm({ initialEmail = "" }: { initialEmail?: string }) {
5061

5162
setSending(true);
5263
try {
53-
const response = await fetch(FORM_ENDPOINT, {
64+
const response = await fetch(config.endpoint, {
5465
method: "POST",
5566
headers: {
56-
apikey: FORM_KEY,
57-
Authorization: `Bearer ${FORM_KEY}`,
67+
apikey: config.key,
68+
Authorization: `Bearer ${config.key}`,
5869
"Content-Type": "application/json",
5970
Prefer: "return=minimal",
6071
},
@@ -90,6 +101,16 @@ export function PreviewForm({ initialEmail = "" }: { initialEmail?: string }) {
90101
}
91102
};
92103

104+
if (!config) {
105+
return (
106+
<div className="hp-form" role="status">
107+
<p className="hp-form-note">
108+
The preview request form is not configured for this deployment.
109+
</p>
110+
</div>
111+
);
112+
}
113+
93114
if (sent) {
94115
return (
95116
<div className="hp-form-success" role="status">
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Canonical deployed app origin for magic links, device-link tokens, and
3+
* reminder deep links. Every function reads it from APP_URL so a self-hosted
4+
* deployment never silently redirects users to another tenant's instance.
5+
* Failing loudly (rather than inventing a URL) matches the client-side
6+
* sendMagicLink guard, which refuses to send a broken link when VITE_APP_URL
7+
* is missing.
8+
*/
9+
function appOrigin(): string {
10+
const url = Deno.env.get("APP_URL")?.trim();
11+
if (!url) {
12+
throw new Response("APP_URL is not configured on this deployment", {
13+
status: 500,
14+
});
15+
}
16+
return url.replace(/\/+$/, "");
17+
}
18+
19+
/**
20+
* The installed field app entry point. The single deployment serves the app
21+
* under /app (the marketing homepage lives at /), so every magic-link,
22+
* device-link, and reminder redirect must target /app to reach the surface
23+
* that actually processes the auth callback.
24+
*/
25+
export function appEntryUrl(): string {
26+
return `${appOrigin()}/app`;
27+
}

supabase/functions/_shared/csv.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Neutralize CSV/Excel formula injection and produce RFC-4180-compatible
3+
* cells/rows. Values beginning with "=", "+", "-", "@", tab, or CR are
4+
* prefixed with a single quote so spreadsheet applications render them as
5+
* text instead of executing them as formulas.
6+
*/
7+
export function csvCell(value: unknown): string {
8+
if (value === null || value === undefined) return '""';
9+
const rawText = typeof value === "string" ? value : JSON.stringify(value);
10+
const text = /^[=+\-@\t\r]/.test(rawText) ? `'${rawText}` : rawText;
11+
return `"${text.replaceAll('"', '""')}"`;
12+
}
13+
14+
export function csvRow(values: unknown[]): string {
15+
return values.map(csvCell).join(",");
16+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { SupabaseClient } from "npm:@supabase/supabase-js@2.112.2";
2+
import { sha256 } from "./hash.ts";
3+
4+
/**
5+
* Increment the shared per-source-IP request budget and report whether the
6+
* caller is still allowed. This is the effective control for both the
7+
* anonymous self-service sign-in-code request and the sign-in-code exchange:
8+
* a code's hash cannot be known without the code itself, so per-code attempt
9+
* counters can never observe a wrong guess.
10+
*/
11+
export async function bumpIpRateLimit(
12+
request: Request,
13+
service: SupabaseClient,
14+
): Promise<boolean> {
15+
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
16+
"";
17+
const { data: allowed } = await service
18+
.rpc("bump_signin_code_request", { p_ip_hash: await sha256(ip) })
19+
.maybeSingle();
20+
return allowed !== false;
21+
}

supabase/functions/claim-invites/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Deno.serve(async (request) => {
2020
.from("project_invites")
2121
.select("id,project_id")
2222
.eq("status", "pending")
23-
.ilike("email", email);
23+
.eq("email", email);
2424
if (inviteError) {
2525
return json(
2626
{ error: "Invitations could not be checked" },

0 commit comments

Comments
 (0)