Compare commits
3 Commits
6fd0f84806
...
freno-dev/
| Author | SHA1 | Date | |
|---|---|---|---|
| 97755042cb | |||
| a0e6ff1044 | |||
| 2fd2a4a71a |
@@ -67,7 +67,8 @@ describe("generateSitemap", () => {
|
|||||||
expect(locs).toContain("https://nessa.freno.me/");
|
expect(locs).toContain("https://nessa.freno.me/");
|
||||||
expect(locs).toContain("https://nessa.freno.me/contact");
|
expect(locs).toContain("https://nessa.freno.me/contact");
|
||||||
expect(locs).toContain("https://nessa.freno.me/privacy");
|
expect(locs).toContain("https://nessa.freno.me/privacy");
|
||||||
expect(locs.length).toBe(3);
|
expect(locs).toContain("https://nessa.freno.me/terms");
|
||||||
|
expect(locs.length).toBe(4);
|
||||||
|
|
||||||
for (const loc of locs) {
|
for (const loc of locs) {
|
||||||
expect(loc).not.toContain("://freno.me/");
|
expect(loc).not.toContain("://freno.me/");
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export const SITEMAP_ROUTES: Record<SiteId, SitemapEntry[]> = {
|
|||||||
nessa: [
|
nessa: [
|
||||||
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
{ path: "/", changefreq: "weekly", priority: 1.0 },
|
||||||
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
{ path: "/contact", changefreq: "monthly", priority: 0.6 },
|
||||||
{ path: "/privacy", changefreq: "yearly", priority: 0.4 }
|
{ path: "/privacy", changefreq: "yearly", priority: 0.4 },
|
||||||
|
{ path: "/terms", changefreq: "yearly", priority: 0.4 }
|
||||||
],
|
],
|
||||||
|
|
||||||
lineage: [
|
lineage: [
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { describe, expect, it, mock, beforeEach } from "bun:test";
|
|||||||
|
|
||||||
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
const sentEmails: { to: string; subject: string; html: string }[] = [];
|
||||||
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
const tokenAnswers: { allowed: boolean; retryAfterSec?: number }[] = [];
|
||||||
|
/** Every rate-limit key the route derived, in order. */
|
||||||
|
const rateKeys: string[] = [];
|
||||||
|
|
||||||
mock.module("~/server/email", () => ({
|
mock.module("~/server/email", () => ({
|
||||||
default: async (to: string, subject: string, html: string) => {
|
default: async (to: string, subject: string, html: string) => {
|
||||||
@@ -19,7 +21,10 @@ mock.module("~/server/email", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
mock.module("~/server/bug-report-rate-limit", () => ({
|
mock.module("~/server/bug-report-rate-limit", () => ({
|
||||||
takeBugReportToken: () => tokenAnswers.shift() ?? { allowed: true }
|
takeBugReportToken: (key: string) => {
|
||||||
|
rateKeys.push(key);
|
||||||
|
return tokenAnswers.shift() ?? { allowed: true };
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { POST } = await import("./report");
|
const { POST } = await import("./report");
|
||||||
@@ -72,6 +77,7 @@ describe("POST /api/the-nook/report", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sentEmails.length = 0;
|
sentEmails.length = 0;
|
||||||
tokenAnswers.length = 0;
|
tokenAnswers.length = 0;
|
||||||
|
rateKeys.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emails the owner a report containing the snapshot, escaped", async () => {
|
it("emails the owner a report containing the snapshot, escaped", async () => {
|
||||||
@@ -133,10 +139,49 @@ describe("POST /api/the-nook/report", () => {
|
|||||||
|
|
||||||
it("still succeeds when no fingerprint header exists", async () => {
|
it("still succeeds when no fingerprint header exists", async () => {
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
request(validPayload, { "x-forwarded-for": "203.0.113.9, 10.0.0.1" })
|
request(validPayload, {
|
||||||
|
"cf-connecting-ip": "203.0.113.9",
|
||||||
|
"x-forwarded-for": "203.0.113.9, 10.0.0.1"
|
||||||
|
})
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(sentEmails).toHaveLength(1);
|
expect(sentEmails).toHaveLength(1);
|
||||||
|
// The quota key is the Cloudflare-recorded client, not a header hop the
|
||||||
|
// sender chose.
|
||||||
|
expect(rateKeys).toEqual(["203.0.113.9"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rotating leftmost x-forwarded-for does not mint a fresh quota key", async () => {
|
||||||
|
// FRE-371: the old read keyed on the leftmost hop, which the sender
|
||||||
|
// controls outright — one scripted loop with a rotating header had
|
||||||
|
// unlimited reports. With the same Cloudflare-recorded client and a
|
||||||
|
// different spoofed prefix per request, the key must be stable.
|
||||||
|
for (const spoof of ["1.1.1.1", "2.2.2.2", "3.3.3.3"]) {
|
||||||
|
const res = await POST(
|
||||||
|
request(validPayload, {
|
||||||
|
"cf-connecting-ip": "203.0.113.9",
|
||||||
|
"x-forwarded-for": `${spoof}, 203.0.113.9, 10.0.0.1`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rateKeys).toEqual([
|
||||||
|
"203.0.113.9",
|
||||||
|
"203.0.113.9",
|
||||||
|
"203.0.113.9"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys on the rightmost-minus-one hop when Cloudflare's header is absent", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
request(validPayload, {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.0.0.1"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Never the leftmost (sender-chosen) hop.
|
||||||
|
expect(rateKeys).toEqual(["203.0.113.9"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delivers the landing page's beta request with only the email", async () => {
|
it("delivers the landing page's beta request with only the email", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import sendEmail from "~/server/email";
|
import sendEmail from "~/server/email";
|
||||||
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
import { CONTACT_RECIPIENT_EMAIL } from "~/lib/contact-config";
|
||||||
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
import { takeBugReportToken } from "~/server/bug-report-rate-limit";
|
||||||
|
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||||
import { json, error } from "./_lib";
|
import { json, error } from "./_lib";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,10 +104,9 @@ export async function POST(event: APIEvent): Promise<Response> {
|
|||||||
)
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const forwarded = event.request.headers.get("x-forwarded-for");
|
// FRE-371: the IP fallback must be the spoof-resistant read — a rotating
|
||||||
const rateKey =
|
// leftmost x-forwarded-for used to mint unlimited report quota.
|
||||||
fingerprint ||
|
const rateKey = fingerprint || resolveTrustedClientIp(event);
|
||||||
(forwarded ? forwarded.split(",")[0]!.trim() : "unknown");
|
|
||||||
const token = takeBugReportToken(rateKey);
|
const token = takeBugReportToken(rateKey);
|
||||||
if (!token.allowed) {
|
if (!token.allowed) {
|
||||||
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
return new Response(JSON.stringify({ error: "Too many reports" }), {
|
||||||
|
|||||||
446
src/routes/nessa/terms.tsx
Normal file
446
src/routes/nessa/terms.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* Nessa terms of use — `nessa.freno.me/terms`.
|
||||||
|
*
|
||||||
|
* The document behind the `Terms of Service` row in the Nessa app
|
||||||
|
* (`Nessa/Models/AppSettings.swift`, Settings › Legal). Before this page the
|
||||||
|
* row had no Nessa-owned document to resolve to: the app linked another
|
||||||
|
* product's terms, and `nessa.freno.me/terms` was a 404 (FRE-194).
|
||||||
|
*
|
||||||
|
* Nessa sells an auto-renewing subscription through the App Store, so App
|
||||||
|
* Review 3.1.2 requires the app to link a privacy policy **and** terms of
|
||||||
|
* use. Section 5 (subscriptions) and section 6 (Apple acknowledgements) are
|
||||||
|
* the clauses that requirement is read against, so keep them when editing.
|
||||||
|
*
|
||||||
|
* House voice follows `privacy.tsx`: "we" / "Nessa", no legal entity name,
|
||||||
|
* and no governing-law clause — the Nessa pages have never stated either, and
|
||||||
|
* naming an entity or a jurisdiction is a decision to make deliberately, not
|
||||||
|
* to introduce as a side effect of a link fix.
|
||||||
|
*
|
||||||
|
* PageHead is site-aware: only the base title is supplied; the ` | Nessa`
|
||||||
|
* suffix, the `https://nessa.freno.me/terms` canonical and the OG image are
|
||||||
|
* derived automatically. Internal links use public subdomain-relative paths
|
||||||
|
* (`/privacy`, `/deletion`, `/contact`) per nav-config.ts and
|
||||||
|
* page-head-meta.ts.
|
||||||
|
*/
|
||||||
|
import { A } from "@solidjs/router";
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { EdgeCacheHeaders } from "~/components/EdgeCacheHeaders";
|
||||||
|
import SubdomainHeader from "~/components/SubdomainHeader";
|
||||||
|
|
||||||
|
export default function NessaTermsOfUse() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeCacheHeaders maxAge={300} />
|
||||||
|
<PageHead
|
||||||
|
title="Terms of Use"
|
||||||
|
description="Terms of use for Nessa, a fitness app for tracking workouts and training with clubs, challenges, and friends."
|
||||||
|
/>
|
||||||
|
<SubdomainHeader />
|
||||||
|
<div class="min-h-screen px-[8vw] py-[10vh]">
|
||||||
|
<div class="py-4 text-xl">Nessa's Terms of Use</div>
|
||||||
|
<div class="py-2">Last Updated: November 7, 2026</div>
|
||||||
|
<div class="py-2">
|
||||||
|
Welcome to Nessa ('We', 'Us', 'Our').
|
||||||
|
These terms of use govern your access to and use of the Nessa app and
|
||||||
|
its related services. By using Nessa you agree to these terms. Our{" "}
|
||||||
|
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||||
|
privacy policy
|
||||||
|
</A>{" "}
|
||||||
|
explains how we handle your data, and it forms part of these terms.
|
||||||
|
</div>
|
||||||
|
<ol>
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">1.</span> Acceptance of These Terms
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Agreement:</div> By creating an account,
|
||||||
|
downloading, or otherwise using Nessa you agree to these terms.
|
||||||
|
If you do not agree to them, do not use Nessa.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Eligibility:</div> You must be at least
|
||||||
|
the age of digital consent in your country to use Nessa. If you
|
||||||
|
are using Nessa with a parent's or guardian's
|
||||||
|
account, they are responsible for your compliance with these
|
||||||
|
terms.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Where these terms apply:</div> These
|
||||||
|
terms are between you and us. Where you obtained Nessa through
|
||||||
|
a third party such as the Apple App Store, that
|
||||||
|
party's own terms apply to your relationship with them as
|
||||||
|
well.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">2.</span> The Service
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) What Nessa is:</div> Nessa is a fitness
|
||||||
|
application for iOS, with an Apple Watch companion, that lets
|
||||||
|
you record workouts and routes, review your training history,
|
||||||
|
and take part in community features — clubs, club challenges,
|
||||||
|
posts, comments, and leaderboards. Some features are free and
|
||||||
|
some require a paid subscription (see section 5).
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Changes and availability:</div> We are
|
||||||
|
continually improving Nessa, so features may change, move
|
||||||
|
between tiers, or be withdrawn. We do not guarantee that Nessa
|
||||||
|
will be available at all times, and we may suspend or discontinue
|
||||||
|
part or all of the service. Where a change materially affects a
|
||||||
|
paid feature you have subscribed to, we will tell you on the
|
||||||
|
subscription management page or by contact.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Not medical advice:</div> Nessa is not a
|
||||||
|
medical device and is not intended to diagnose, treat, cure, or
|
||||||
|
prevent any condition. Workout data, heart-rate and other
|
||||||
|
health readings, training plans, and estimates you see in Nessa
|
||||||
|
are for general information and motivation only. Consult a
|
||||||
|
qualified professional before starting or changing a training
|
||||||
|
programme, especially if you have a health condition. Never
|
||||||
|
ignore professional advice, or delay seeking it, because of
|
||||||
|
something you read in Nessa.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Your surroundings:</div> You are
|
||||||
|
responsible for your own safety while using Nessa, including
|
||||||
|
when recording an activity. Follow local laws and do not use
|
||||||
|
Nessa in a way that distracts you from driving or other
|
||||||
|
activities that require your full attention.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">3.</span> Your Account
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Sign-in:</div> Nessa accounts are
|
||||||
|
authenticated through Clerk, our identity provider. You are
|
||||||
|
responsible for keeping the credentials you use to sign in
|
||||||
|
secure, and you are responsible for the activity that happens
|
||||||
|
under your account.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Notify us:</div> Tell us promptly, using
|
||||||
|
the contact details in section 11, if you believe someone else
|
||||||
|
has used or may use your account without your permission.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) One account:</div> Keep to one account,
|
||||||
|
and give us accurate information so we can keep your training
|
||||||
|
history and your place in the community correct. Do not create
|
||||||
|
accounts to evade a moderation decision or to gain a fair
|
||||||
|
advantage in challenges or leaderboards.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">4.</span> Community Content and Acceptable
|
||||||
|
Use
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) You keep your content:</div> You own what
|
||||||
|
you post. By posting to a club, challenge, or other shared area
|
||||||
|
of Nessa you grant us a limited, non-exclusive licence to store,
|
||||||
|
reproduce, and display that content inside Nessa so that the
|
||||||
|
feature you used can work — for example, showing your post to
|
||||||
|
the members of your club. That licence ends when your content is
|
||||||
|
removed from our systems. We handle your content as described in
|
||||||
|
our{" "}
|
||||||
|
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||||
|
privacy policy
|
||||||
|
</A>
|
||||||
|
; we do not sell it.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) What you are responsible for:</div> You
|
||||||
|
are responsible for the content you post and the data you record
|
||||||
|
in Nessa, and for who you share it with. Use the privacy
|
||||||
|
settings on your profile and the privacy rules of the clubs you
|
||||||
|
join before sharing anything you would not want others to see.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Do not:</div> (i) post anything unlawful,
|
||||||
|
harassing, hateful, threatening, or sexually explicit involving
|
||||||
|
minors; (ii) post content you do not have the right to share, or
|
||||||
|
that infringes someone else's intellectual property;
|
||||||
|
(iii) falsify activity data, spoof a location, or otherwise
|
||||||
|
cheat a challenge, leaderboard, or competition; (iv) scrape,
|
||||||
|
bulk-download, or access Nessa through automated means we have
|
||||||
|
not authorised; (v) interfere with, overload, or probe Nessa for
|
||||||
|
vulnerabilities, or attempt to access another user's
|
||||||
|
account or data; (vi) resell, sublicense, or offer Nessa to
|
||||||
|
others as your own service.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Moderation:</div> We may remove or
|
||||||
|
restrict content, disqualify an entry from a challenge, or
|
||||||
|
suspend or close an account that breaks these terms. We are not
|
||||||
|
obliged to pre-monitor content. You can report content or a user
|
||||||
|
to us at any time using the contact details in section 11.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">5.</span> Subscriptions and Payments
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Paid tiers:</div> Nessa offers paid
|
||||||
|
subscription tiers, Nessa Plus and Nessa Pro, which unlock
|
||||||
|
features described in the app. Subscriptions are sold as
|
||||||
|
auto-renewing subscriptions through the Apple App Store and are
|
||||||
|
billed to your Apple Account.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Payment:</div> Payment is charged to your
|
||||||
|
Apple Account at confirmation of purchase. The price of your
|
||||||
|
plan is shown in the app before you buy, and it is the price on
|
||||||
|
your App Store purchase receipt.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Auto-renewal:</div> Unless you cancel at
|
||||||
|
least 24 hours before the end of the current period, your
|
||||||
|
subscription renews automatically. Your account is charged for
|
||||||
|
renewal within 24 hours before the end of the current period, at
|
||||||
|
the price of the plan you selected.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Managing your subscription:</div> You can
|
||||||
|
turn off auto-renewal, or cancel, at any time in your App Store
|
||||||
|
account settings after purchase. Changes made there take effect
|
||||||
|
at the end of the current period, and you keep access to the
|
||||||
|
paid features you have already paid for until then.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(e) Trials:</div> If we offer a free trial,
|
||||||
|
any unused portion of it is forfeited when you purchase a
|
||||||
|
subscription, and the trial converts to a paid auto-renewing
|
||||||
|
subscription unless you cancel it before it ends.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(f) Refunds:</div> Purchases made through the
|
||||||
|
App Store are refunded according to Apple's refund policy
|
||||||
|
and, where applicable law gives you a right to a refund,
|
||||||
|
according to that law. Tell us using the contact details in
|
||||||
|
section 11 and we will do what we can.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(g) Price changes:</div> We may change our
|
||||||
|
prices. A price change does not apply to a period you have
|
||||||
|
already paid for; it applies from your next renewal, and we give
|
||||||
|
notice where the law requires it.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(h) Downgrade:</div> When a subscription
|
||||||
|
lapses or is cancelled you keep the free features, and anything
|
||||||
|
you created that belongs to a paid feature (such as a custom
|
||||||
|
route) is kept but is not editable or usable until you
|
||||||
|
subscribe again.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">6.</span> Apple and Other Third Parties
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Not Apple's terms:</div> These terms
|
||||||
|
are between you and us, not with Apple Inc. Apple is not
|
||||||
|
responsible for Nessa or its content, and your use of Nessa must
|
||||||
|
comply with the Apple App Store Terms of Service.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) No Apple support:</div> Apple has no
|
||||||
|
obligation to furnish maintenance and support services for
|
||||||
|
Nessa. If Nessa fails, you may notify Apple, but Apple has no
|
||||||
|
further support obligation, and support and any warranties
|
||||||
|
relating to Nessa are ours to provide as described in these
|
||||||
|
terms.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Apple is not responsible for claims:</div>{" "}
|
||||||
|
Apple is not responsible for product liability claims,
|
||||||
|
intellectual property infringement claims, or any other claims
|
||||||
|
relating to Nessa or your possession and use of it. We, not
|
||||||
|
Apple, are responsible for addressing any such claims and for
|
||||||
|
any claims that you may have relating to Nessa.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(d) Compliance:</div> You confirm that you
|
||||||
|
are not located in a country subject to an embargo of the United
|
||||||
|
States or listed by the United States government as a
|
||||||
|
terrorist-supporting country, and that you are not listed on any
|
||||||
|
United States government list of prohibited or restricted
|
||||||
|
parties.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(e) Third-party services:</div> Nessa depends
|
||||||
|
on third-party services — Clerk for authentication, Apple
|
||||||
|
HealthKit for health and workout data, Apple's In-App
|
||||||
|
Purchase system for subscriptions, and our own hosting — each
|
||||||
|
governed by its own terms. Where those terms affect how you may
|
||||||
|
use the feature they power, they apply alongside these terms.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">7.</span> Intellectual Property
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Our rights:</div> The Nessa software, its
|
||||||
|
design, text, icons, and branding are owned by us or our
|
||||||
|
licensors and are protected by copyright and other laws. We
|
||||||
|
grant you a personal, non-exclusive, non-transferable, revocable
|
||||||
|
licence to use Nessa for your own personal, non-commercial
|
||||||
|
purposes.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Restrictions:</div> You may not copy,
|
||||||
|
modify, translate, distribute, sell, lease, sublicense, or
|
||||||
|
create derivative works from Nessa, and you may not reverse
|
||||||
|
engineer, decompile, or attempt to obtain its source code,
|
||||||
|
except to the extent applicable law allows this despite a
|
||||||
|
restriction.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">8.</span> Disclaimers and Limitation of
|
||||||
|
Liability
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) As is:</div> Nessa is provided "as
|
||||||
|
is" and "as available" to the fullest extent
|
||||||
|
permitted by applicable law. We do not warrant that Nessa will
|
||||||
|
be uninterrupted, secure, error-free, or that recorded data will
|
||||||
|
be accurate — GPS-derived distance, pace, elevation, and
|
||||||
|
estimates can be wrong, and device and sensor limits apply.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Your data:</div> You are responsible for
|
||||||
|
backing up the data you store on your device. We keep
|
||||||
|
Nessa's service data as described in the{" "}
|
||||||
|
<A href="/privacy" class="text-blue hover-underline-animation">
|
||||||
|
privacy policy
|
||||||
|
</A>{" "}
|
||||||
|
and{" "}
|
||||||
|
<A href="/deletion" class="text-blue hover-underline-animation">
|
||||||
|
deletion page
|
||||||
|
</A>
|
||||||
|
, but no service is a substitute for your own backup.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) Liability:</div> To the fullest extent
|
||||||
|
permitted by law, we are not liable for indirect, incidental,
|
||||||
|
special, consequential, or punitive damages, or for lost data,
|
||||||
|
lost profits, or loss of training records, arising out of your
|
||||||
|
use of, or inability to use, Nessa. Where a court would not
|
||||||
|
allow that exclusion, our liability for claims arising from your
|
||||||
|
use of Nessa is limited to the greater of the subscription fees
|
||||||
|
you paid us in the twelve months before the event giving rise to
|
||||||
|
the claim, or the minimum the applicable law requires. Nothing
|
||||||
|
in these terms limits liability that cannot lawfully be limited,
|
||||||
|
including for fraud or for death or personal injury caused by
|
||||||
|
our negligence.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">9.</span> Ending Your Use
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) You can leave:</div> You can stop using
|
||||||
|
Nessa at any time. You can delete your account and the content
|
||||||
|
associated with it through our{" "}
|
||||||
|
<A href="/deletion" class="text-blue hover-underline-animation">
|
||||||
|
account deletion page
|
||||||
|
</A>
|
||||||
|
. Deleting your account does not cancel a subscription that is
|
||||||
|
still in its paid period; cancel that in your App Store account
|
||||||
|
settings as described in section 5.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) We may end access:</div> We may suspend
|
||||||
|
or close your access to Nessa if you break these terms, if we
|
||||||
|
are required to by law, or if we reasonably believe your account
|
||||||
|
has been compromised. Where we can, we will tell you why.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(c) On termination:</div> Your licence to use
|
||||||
|
Nessa ends, and sections 4(a), 6, 7, 8, 10, and 11 continue to
|
||||||
|
apply.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">10.</span> Changes to These Terms
|
||||||
|
</div>
|
||||||
|
<div class="pl-4">
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(a) Updates:</div> We may update these terms
|
||||||
|
as Nessa changes. The current version is always the one at{" "}
|
||||||
|
<A href="/terms" class="text-blue hover-underline-animation">
|
||||||
|
nessa.freno.me/terms
|
||||||
|
</A>
|
||||||
|
, and the "Last Updated" date at the top of this page
|
||||||
|
shows when it last changed.
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">
|
||||||
|
<div class="-ml-6">(b) Continued use:</div> If you keep using
|
||||||
|
Nessa after a change takes effect, you accept the revised terms.
|
||||||
|
For changes that materially reduce your rights or your paid
|
||||||
|
features, we will give notice in the app or by contact before
|
||||||
|
they take effect.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-2">
|
||||||
|
<div class="pb-2 text-lg">
|
||||||
|
<span class="-ml-4 pr-2">11.</span> Contact Us
|
||||||
|
</div>
|
||||||
|
<div class="pb-2 pl-4">
|
||||||
|
<div class="-ml-6">(a) Reaching Out:</div> If you have any
|
||||||
|
questions or comments about these terms, want to report content,
|
||||||
|
or need help with a subscription, you can contact us{" "}
|
||||||
|
<A href="/contact" class="text-blue hover-underline-animation">
|
||||||
|
here
|
||||||
|
</A>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getCookie, setCookie } from "vinxi/http";
|
|||||||
import type { H3Event } from "vinxi/http";
|
import type { H3Event } from "vinxi/http";
|
||||||
import { t } from "~/server/api/utils";
|
import { t } from "~/server/api/utils";
|
||||||
import { logAuditEvent } from "~/server/audit";
|
import { logAuditEvent } from "~/server/audit";
|
||||||
|
import { resolveTrustedClientIp } from "~/server/trusted-client-ip";
|
||||||
import { env } from "~/env/server";
|
import { env } from "~/env/server";
|
||||||
import {
|
import {
|
||||||
AUTH_CONFIG,
|
AUTH_CONFIG,
|
||||||
@@ -328,39 +329,15 @@ async function cleanupExpiredRateLimits(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get client IP address from request headers.
|
* Get client IP address for a security decision (rate limiting, audit).
|
||||||
* Only trusts X-Forwarded-For outside of local development (set by the Vercel
|
*
|
||||||
* edge network in production; trusted in tests so the header-parsing path is
|
* Thin alias over `resolveTrustedClientIp` (./trusted-client-ip), which owns
|
||||||
* exercised). In local development, uses the socket address to prevent header
|
* the trust order: the previous leftmost `x-forwarded-for` read was
|
||||||
* spoofing.
|
* client-spoofable in our append-only proxy topology, and `x-real-ip` was
|
||||||
|
* read unconditionally — both fixed there (FRE-371).
|
||||||
*/
|
*/
|
||||||
export function getClientIP(event: H3Event): string {
|
export function getClientIP(event: H3Event): string {
|
||||||
// In production on Vercel, X-Forwarded-For is set by the edge network
|
return resolveTrustedClientIp(event);
|
||||||
// and cannot be spoofed by clients. In dev, ignore it.
|
|
||||||
if (env.NODE_ENV !== "development") {
|
|
||||||
const forwarded = getHeaderValue(event, "x-forwarded-for");
|
|
||||||
if (forwarded) {
|
|
||||||
return forwarded.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
const realIP = getHeaderValue(event, "x-real-ip");
|
|
||||||
if (realIP) {
|
|
||||||
return realIP;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try socket remote address
|
|
||||||
try {
|
|
||||||
const nodeReq = event.node.req;
|
|
||||||
if (nodeReq?.socket?.remoteAddress) {
|
|
||||||
const addr = nodeReq.socket.remoteAddress;
|
|
||||||
// Clean up IPv6-mapped IPv4 addresses
|
|
||||||
return addr.replace(/^::ffff:/, "");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// socket access failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -150,29 +150,100 @@ describe("Rate Limiting", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getClientIP", () => {
|
describe("getClientIP", () => {
|
||||||
it("should extract IP from x-forwarded-for header", () => {
|
// FRE-371: the read is spoof-resistant now. In the pinned topology
|
||||||
|
// (client → Cloudflare → origin proxy → app) both proxies APPEND, so
|
||||||
|
// x-forwarded-for arrives as `<spoof…>, <true client>, <CF edge>` and the
|
||||||
|
// leftmost hop is whatever the sender typed. Trust order:
|
||||||
|
// cf-connecting-ip, then x-forwarded-for rightmost-minus-one, then the
|
||||||
|
// socket peer — never the leftmost hop, never x-real-ip.
|
||||||
|
|
||||||
|
it("takes x-forwarded-for rightmost-minus-one, never the leftmost hop", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" }
|
headers: {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("192.168.1.1");
|
expect(ip).toBe("192.168.1.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should extract IP from x-real-ip header", () => {
|
it("prefers cf-connecting-ip (Cloudflare overwrites it at the edge)", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a rotating leftmost x-forwarded-for does not change the resolved client", () => {
|
||||||
|
// The spoof the old read fell for: same Cloudflare-recorded client,
|
||||||
|
// different sender-chosen prefix per request. The key must be stable,
|
||||||
|
// or each rotation mints a fresh rate-limit bucket.
|
||||||
|
const first = getClientIP(
|
||||||
|
createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "1.1.1.1, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const second = getClientIP(
|
||||||
|
createMockEvent({
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "2.2.2.2, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(first).toBe("203.0.113.7");
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores x-real-ip entirely (client-settable in this topology)", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: { "x-real-ip": "192.168.1.2" }
|
headers: { "x-real-ip": "192.168.1.2" }
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("192.168.1.2");
|
expect(ip).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should prefer x-forwarded-for over x-real-ip", () => {
|
it("ignores a client-supplied single-hop x-forwarded-for with no trusted proxy above it", () => {
|
||||||
|
// One hop only = the sender's own invention; discounting the trusted
|
||||||
|
// hop leaves nothing to believe.
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
|
headers: { "x-forwarded-for": "6.6.6.6" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-loopback socket peer is the client and beats every header", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
remoteAddress: "198.51.100.23",
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": "192.168.1.1",
|
"cf-connecting-ip": "6.6.6.6",
|
||||||
"x-real-ip": "192.168.1.2"
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1",
|
||||||
|
"x-real-ip": "6.6.6.6"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("198.51.100.23");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a loopback socket peer lets the forwarded headers speak", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: {
|
||||||
|
"x-forwarded-for": "6.6.6.6, 192.168.1.1, 10.0.0.1"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,13 +269,23 @@ describe("Rate Limiting", () => {
|
|||||||
it("should handle IPv6 addresses", () => {
|
it("should handle IPv6 addresses", () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
headers: {
|
headers: {
|
||||||
"x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
|
"x-forwarded-for":
|
||||||
|
"2001:0db8:85a3:0000:0000:8a2e:0370:7334, 10.0.0.1"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const ip = getClientIP(event);
|
const ip = getClientIP(event);
|
||||||
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a candidate hop that is not an address", () => {
|
||||||
|
const event = createMockEvent({
|
||||||
|
headers: { "x-forwarded-for": "not-an-ip, 10.0.0.1" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const ip = getClientIP(event);
|
||||||
|
expect(ip).toBe("unknown");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rateLimitLogin", () => {
|
describe("rateLimitLogin", () => {
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ export function createMockEvent(options: {
|
|||||||
cookies?: Record<string, string>;
|
cookies?: Record<string, string>;
|
||||||
method?: string;
|
method?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
/** Socket peer the server observed; omit for a peer-less synthetic event. */
|
||||||
|
remoteAddress?: string;
|
||||||
}): H3Event {
|
}): H3Event {
|
||||||
const {
|
const {
|
||||||
headers = {},
|
headers = {},
|
||||||
cookies = {},
|
cookies = {},
|
||||||
method = "POST",
|
method = "POST",
|
||||||
url = "http://localhost:3000/"
|
url = "http://localhost:3000/",
|
||||||
|
remoteAddress
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Build the cookie header string from the cookies object only
|
// Build the cookie header string from the cookies object only
|
||||||
@@ -49,7 +52,10 @@ export function createMockEvent(options: {
|
|||||||
headers: headersObj,
|
headers: headersObj,
|
||||||
node: {
|
node: {
|
||||||
req: {
|
req: {
|
||||||
headers: allHeaders
|
headers: allHeaders,
|
||||||
|
...(remoteAddress
|
||||||
|
? { socket: { remoteAddress } }
|
||||||
|
: {})
|
||||||
},
|
},
|
||||||
res: {
|
res: {
|
||||||
cookies: {}
|
cookies: {}
|
||||||
|
|||||||
171
src/server/trusted-client-ip.test.ts
Normal file
171
src/server/trusted-client-ip.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveTrustedClientIp,
|
||||||
|
isIpAddress,
|
||||||
|
isLoopbackAddress,
|
||||||
|
UNKNOWN_CLIENT_IP,
|
||||||
|
type ClientIpEventLike
|
||||||
|
} from "./trusted-client-ip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit coverage for the security-path client-IP read (FRE-371). The caller
|
||||||
|
* suites (rate-limit.test.ts, report.test.ts) pin the behaviour through
|
||||||
|
* getClientIP and the route; this file pins the helper's own edges: the peer
|
||||||
|
* forms a real socket can present, the APIEvent wrapper, and failing closed.
|
||||||
|
*/
|
||||||
|
function event(opts: {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
remoteAddress?: string;
|
||||||
|
wrapped?: boolean;
|
||||||
|
}): ClientIpEventLike {
|
||||||
|
const inner: ClientIpEventLike = {
|
||||||
|
...(opts.remoteAddress
|
||||||
|
? { node: { req: { socket: { remoteAddress: opts.remoteAddress } } } }
|
||||||
|
: {}),
|
||||||
|
request: { headers: new Headers(opts.headers ?? {}) }
|
||||||
|
};
|
||||||
|
// An APIEvent wraps its H3Event as `nativeEvent` (routes/api/* shape).
|
||||||
|
return opts.wrapped ? { nativeEvent: inner } : inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveTrustedClientIp", () => {
|
||||||
|
it("trust order: non-loopback peer, then cf-connecting-ip, then rightmost-minus-one x-forwarded-for", () => {
|
||||||
|
// Peer wins.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "198.51.100.23",
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("198.51.100.23");
|
||||||
|
|
||||||
|
// Loopback peer → Cloudflare's record.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "203.0.113.7",
|
||||||
|
"x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
|
||||||
|
// No CF header → the hop below the trusted proxy.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "x-forwarded-for": "6.6.6.6, 203.0.113.7, 10.0.0.1" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the wrapped H3Event of an APIEvent-shaped input", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
wrapped: true,
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a truncated address", () => {
|
||||||
|
// The full /128 is the bucket key; a truncated one would pool attackers
|
||||||
|
// with their neighbours.
|
||||||
|
const ip = resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "2001:0db8:85a3:0000:0000:8a2e:0370:7334" }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed to the stricter unknown key", () => {
|
||||||
|
// One hop = the sender's own invention; a non-address hop; nothing at all.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "x-forwarded-for": "6.6.6.6" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "cf-connecting-ip": "not-an-ip" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
expect(resolveTrustedClientIp(event({}))).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises the loopback forms a real peer presents", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
// IPv6-mapped IPv4 loopback from a node socket.
|
||||||
|
remoteAddress: "::ffff:127.0.0.1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "::1",
|
||||||
|
headers: { "cf-connecting-ip": "203.0.113.7" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
// A non-loopback peer that merely looks odd is still the client.
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({
|
||||||
|
remoteAddress: "::ffff:203.0.113.7",
|
||||||
|
headers: { "cf-connecting-ip": "6.6.6.6" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toBe("203.0.113.7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never believes x-real-ip", () => {
|
||||||
|
expect(
|
||||||
|
resolveTrustedClientIp(
|
||||||
|
event({ headers: { "x-real-ip": "203.0.113.7" } })
|
||||||
|
)
|
||||||
|
).toBe(UNKNOWN_CLIENT_IP);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("address helpers", () => {
|
||||||
|
it("isIpAddress", () => {
|
||||||
|
expect(isIpAddress("203.0.113.7")).toBe(true);
|
||||||
|
expect(isIpAddress("::1")).toBe(true);
|
||||||
|
expect(isIpAddress("::ffff:203.0.113.7")).toBe(true);
|
||||||
|
expect(isIpAddress("2001:db8::1")).toBe(true);
|
||||||
|
expect(isIpAddress("not-an-ip")).toBe(false);
|
||||||
|
expect(isIpAddress("256.1.1.1")).toBe(false);
|
||||||
|
expect(isIpAddress("")).toBe(false);
|
||||||
|
expect(isIpAddress(null)).toBe(false);
|
||||||
|
expect(isIpAddress(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isLoopbackAddress", () => {
|
||||||
|
expect(isLoopbackAddress("127.0.0.1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("127.255.0.3")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("::ffff:127.0.0.1")).toBe(true);
|
||||||
|
expect(isLoopbackAddress("203.0.113.7")).toBe(false);
|
||||||
|
expect(isLoopbackAddress("::2")).toBe(false);
|
||||||
|
// Unparseable input is not loopback — fails closed.
|
||||||
|
expect(isLoopbackAddress("garbage")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
250
src/server/trusted-client-ip.ts
Normal file
250
src/server/trusted-client-ip.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* The client address, resolved for security decisions (FRE-371).
|
||||||
|
*
|
||||||
|
* This is the ONLY client-IP read allowed to feed a security decision — rate
|
||||||
|
* limiting, abuse quotas, account lockout, audit trails. It exists separately
|
||||||
|
* from `request-client.ts`'s `resolveClientIp` (the analytics read) on
|
||||||
|
* purpose:
|
||||||
|
*
|
||||||
|
* - `resolveClientIp` carries a standing rule (FRE-369 review, 1d9287a):
|
||||||
|
* it never feeds a security decision. Its blast radius is a poisoned
|
||||||
|
* analytics row; ours is granting or denying access.
|
||||||
|
* - Rate limiting needs the UN-truncated address; the analytics module only
|
||||||
|
* ever hands out truncated prefixes.
|
||||||
|
* - `request-client.ts` ships on FRE-366's branch, which is under review;
|
||||||
|
* a security fix must not merge-depend on an analytics diff.
|
||||||
|
*
|
||||||
|
* The address-parsing primitives below are therefore a deliberate port of the
|
||||||
|
* reviewed ones, not an import. If the two ever drift, this file's version is
|
||||||
|
* the one security paths follow.
|
||||||
|
*
|
||||||
|
* WHY the old read was spoofable: `security.ts`'s `getClientIP` took the
|
||||||
|
* LEFTMOST `x-forwarded-for` hop, believing the header "cannot be spoofed".
|
||||||
|
* In the pinned topology — client → Cloudflare → same-host origin proxy →
|
||||||
|
* app — both proxies *append*, so the header arrives as
|
||||||
|
* `<client-supplied spoof…>, <true client>, <Cloudflare edge>` and the
|
||||||
|
* leftmost hop is exactly the one the sender chose. Rotating
|
||||||
|
* `X-Forwarded-For: <random>` per request minted a fresh rate-limit bucket
|
||||||
|
* and unlimited bug-report quota. `x-real-ip` was read unconditionally too;
|
||||||
|
* nothing in this topology overwrites it, so it is equally client text and is
|
||||||
|
* no longer read at all.
|
||||||
|
*
|
||||||
|
* Trust order (matches the convention FRE-366 landed after its security
|
||||||
|
* review, re-derived here for security stakes):
|
||||||
|
*
|
||||||
|
* 1. The direct socket peer, when it is a real routable address: it IS the
|
||||||
|
* client (dev, direct hit), and any forwarded header from it is the
|
||||||
|
* client describing itself — ignored.
|
||||||
|
* 2. `cf-connecting-ip` — reachable only when the peer is the loopback
|
||||||
|
* origin proxy (or unreadable, e.g. a synthetic event in tests).
|
||||||
|
* Cloudflare *overwrites* this header at the edge, so a client-supplied
|
||||||
|
* value never survives. Must parse as an address.
|
||||||
|
* 3. `x-forwarded-for` counted from the RIGHT: drop the rightmost hop (the
|
||||||
|
* Cloudflare edge address the origin proxy appended) and take the hop
|
||||||
|
* before it. Never the leftmost. Must parse as an address.
|
||||||
|
* 4. "unknown" — callers give unknown-IP keys stricter shared limits, so
|
||||||
|
* an unresolvable client fails toward a tighter bucket, never a looser
|
||||||
|
* one.
|
||||||
|
*
|
||||||
|
* Deploy-side precondition this code cannot verify (same as the analytics
|
||||||
|
* read): the origin must be reachable only through Cloudflare and the
|
||||||
|
* same-host proxy. If the app is directly internet-reachable, a client can
|
||||||
|
* set every header read here and no in-app read can recover the truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cloudflare's "who actually connected" header: overwritten at the edge. */
|
||||||
|
const CF_CONNECTING_IP = "cf-connecting-ip";
|
||||||
|
const X_FORWARDED_FOR = "x-forwarded-for";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hops discounted from the right of `x-forwarded-for` before the value is
|
||||||
|
* taken: the Cloudflare edge address the origin proxy appended. Pinned to 1
|
||||||
|
* because there is exactly one trusted proxy between Cloudflare and the app;
|
||||||
|
* raise it only if the topology gains a trusted hop.
|
||||||
|
*/
|
||||||
|
const FORWARDED_HOPS_TO_DISCOUNT = 1;
|
||||||
|
|
||||||
|
/** Returned when no trustworthy address exists. Callers rate-limit it harder. */
|
||||||
|
export const UNKNOWN_CLIENT_IP = "unknown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shapes this module reads: an H3Event (`security.ts` callers) or a
|
||||||
|
* SolidStart APIEvent (`routes/api/*`), which wraps its H3Event as
|
||||||
|
* `nativeEvent`. Every field is optional because test doubles are minimal;
|
||||||
|
* anything unreadable simply falls through to the next trust step.
|
||||||
|
*/
|
||||||
|
export interface ClientIpEventLike {
|
||||||
|
request?: { headers?: unknown };
|
||||||
|
headers?: unknown;
|
||||||
|
node?: {
|
||||||
|
req?: {
|
||||||
|
headers?: Record<string, unknown>;
|
||||||
|
socket?: { remoteAddress?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
nativeEvent?: ClientIpEventLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The client address for a security decision, per the trust order above.
|
||||||
|
* Never truncated: rate-limit buckets are per-address, and truncating would
|
||||||
|
* let an attacker share a bucket with the innocents behind their /24.
|
||||||
|
*/
|
||||||
|
export function resolveTrustedClientIp(event: ClientIpEventLike): string {
|
||||||
|
// An APIEvent's socket and (authoritative) headers live on the wrapped
|
||||||
|
// H3Event; a bare APIEvent-shaped double without one is read as-is.
|
||||||
|
const source = event.nativeEvent ?? event;
|
||||||
|
|
||||||
|
const peer = directPeer(source);
|
||||||
|
if (peer && isIpAddress(peer) && !isLoopbackAddress(peer)) return peer;
|
||||||
|
|
||||||
|
// The peer is the loopback origin proxy (or unreadable): forwarded headers
|
||||||
|
// may be believed, in the order above.
|
||||||
|
const connecting = cleanHop(headerValue(source, CF_CONNECTING_IP));
|
||||||
|
if (isIpAddress(connecting)) return connecting;
|
||||||
|
|
||||||
|
const hops = (headerValue(source, X_FORWARDED_FOR) ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map(cleanHop)
|
||||||
|
.filter(Boolean);
|
||||||
|
const index = hops.length - 1 - FORWARDED_HOPS_TO_DISCOUNT;
|
||||||
|
if (index >= 0 && isIpAddress(hops[index])) return hops[index]!;
|
||||||
|
|
||||||
|
return UNKNOWN_CLIENT_IP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The socket peer, IPv6-mapped IPv4 flattened (`::ffff:1.2.3.4` → `1.2.3.4`). */
|
||||||
|
function directPeer(event: ClientIpEventLike): string | null {
|
||||||
|
try {
|
||||||
|
const addr = event.node?.req?.socket?.remoteAddress;
|
||||||
|
return addr ? addr.replace(/^::ffff:/, "") : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header read tolerant of every event shape in play: Fetch-style
|
||||||
|
* `request.headers.get`, an h3 `event.headers` (Headers or plain object), and
|
||||||
|
* the raw node request headers. Header names are case-insensitive per HTTP;
|
||||||
|
* the plain-object fallbacks lowercase to match how node and the test doubles
|
||||||
|
* store them.
|
||||||
|
*/
|
||||||
|
function headerValue(event: ClientIpEventLike, name: string): string | null {
|
||||||
|
try {
|
||||||
|
const fromHeaders = (h: unknown): string | null => {
|
||||||
|
if (!h) return null;
|
||||||
|
if (typeof (h as Headers).get === "function") {
|
||||||
|
return (h as Headers).get(name) ?? null;
|
||||||
|
}
|
||||||
|
if (typeof h === "object") {
|
||||||
|
const record = h as Record<string, unknown>;
|
||||||
|
const value = record[name] ?? record[name.toLowerCase()];
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
fromHeaders(event.request?.headers) ??
|
||||||
|
fromHeaders(event.headers) ??
|
||||||
|
fromHeaders(event.node?.req?.headers)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHop(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Addresses: parse and validate ───────────────────────────────────────────
|
||||||
|
// Ported from the reviewed `request-client.ts` (FRE-366 @ 1d9287a); see the
|
||||||
|
// header comment for why this is a copy and not an import.
|
||||||
|
|
||||||
|
/** Any address this file understands: a dotted quad or an IPv6 literal. */
|
||||||
|
export function isIpAddress(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && addressBytes(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an address is the loopback the production proxy presents.
|
||||||
|
*
|
||||||
|
* Parses rather than string-matches, so the forms a real peer can present in —
|
||||||
|
* `::ffff:127.0.0.53`, `0:0:0:0:0:0:0:1`, an IPv4-mapped literal — are all
|
||||||
|
* recognised. An address this does not parse is *not* loopback, which fails
|
||||||
|
* closed: forwarded headers from an unrecognised peer are ignored rather than
|
||||||
|
* believed (a non-loopback peer short-circuits before headers are read).
|
||||||
|
*/
|
||||||
|
export function isLoopbackAddress(ip: string): boolean {
|
||||||
|
const bytes = addressBytes(ip);
|
||||||
|
if (!bytes) return false;
|
||||||
|
if (bytes.length === 4) return bytes[0] === 127;
|
||||||
|
|
||||||
|
// IPv6 loopback ::1 — and ::ffff:127/8, which is loopback too.
|
||||||
|
const mapped = ipv4FromBytes(bytes);
|
||||||
|
if (mapped) return isLoopbackAddress(mapped);
|
||||||
|
const tail = bytes[15]!;
|
||||||
|
return bytes.slice(0, 15).every((byte) => byte === 0) && tail === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpv4(value: string): boolean {
|
||||||
|
const parts = value.split(".");
|
||||||
|
if (parts.length !== 4) return false;
|
||||||
|
return parts.every(
|
||||||
|
(part) => /^\d{1,3}$/.test(part) && Number(part) <= 255 && !/^0\d/.test(part)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4 bytes for IPv4, 16 for IPv6 (including `::ffff:a.b.c.d`), else null. */
|
||||||
|
function addressBytes(value: string): number[] | null {
|
||||||
|
const raw = value.trim().replace(/^\[|\]$/g, "").split("%")[0]!;
|
||||||
|
if (isIpv4(raw)) return raw.split(".").map(Number);
|
||||||
|
if (!raw.includes(":")) return null;
|
||||||
|
|
||||||
|
const halves = raw.split("::");
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const compressed = halves.length === 2;
|
||||||
|
|
||||||
|
const head = groupsToBytes(halves[0] ?? "");
|
||||||
|
const tail = groupsToBytes(halves[1] ?? "");
|
||||||
|
if (!head || !tail) return null;
|
||||||
|
|
||||||
|
const total = head.length + tail.length;
|
||||||
|
if (compressed) {
|
||||||
|
if (total > 8) return null;
|
||||||
|
return [...head, ...new Array(8 - total).fill(0), ...tail].flatMap(split16);
|
||||||
|
}
|
||||||
|
if (total !== 8) return null;
|
||||||
|
return [...head, ...tail].flatMap(split16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One side of `::` as 16-bit groups; a trailing `1.2.3.4` counts as two. */
|
||||||
|
function groupsToBytes(side: string): number[] | null {
|
||||||
|
if (side === "") return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const part of side.split(":")) {
|
||||||
|
if (/^[0-9a-fA-F]{1,4}$/.test(part)) {
|
||||||
|
out.push(parseInt(part, 16));
|
||||||
|
} else if (isIpv4(part)) {
|
||||||
|
const o = part.split(".").map(Number);
|
||||||
|
out.push((o[0]! << 8) | o[1]!, (o[2]! << 8) | o[3]!);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function split16(group: number): number[] {
|
||||||
|
return [(group >> 8) & 0xff, group & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The embedded IPv4 of an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`), else null. */
|
||||||
|
function ipv4FromBytes(bytes: number[]): string | null {
|
||||||
|
const prefix = bytes.slice(0, 10).every((byte) => byte === 0);
|
||||||
|
const marker = bytes[10] === 0xff && bytes[11] === 0xff;
|
||||||
|
if (!prefix || !marker) return null;
|
||||||
|
return `${bytes[12]}.${bytes[13]}.${bytes[14]}.${bytes[15]}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user